4 Commits

Author SHA1 Message Date
unkinben 13c1f5c1ba gitea: consume the KV admin seed only at engine-config creation
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Why: after the engine is first configured, rotate-root changes the live admin
password so it diverges from the KV seed. Re-reading the (possibly stale) seed
on a later apply must not push it back and clobber the rotated password. Ben
asked that the seed be used only when initially creating the config.

Change:
- Add lifecycle ignore_changes on admin_username/admin_password to the
  gitea_secret_backend resource, making the module create-only for the seeded
  credential. Noted inline that the rancher/litellm seed modules lack this.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-27 19:31:12 +10:00
unkinben a960757ab2 gitea: add the gitea token secrets engine (mount, config, teabot roles)
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Why: teabot's implementer and reviewer bot users should mint ephemeral,
scoped Gitea tokens on demand instead of holding standing personal access
tokens (Gitea tokens never expire on their own). This registers and mounts
the new vault-plugin-secrets-gitea engine and declares its roles, mirroring
the rancher engine wiring.

Change:
- Register the plugin in the catalog (config/plugins/vault-plugin-secrets-gitea.yaml)
  pinned to the released v0.1.0 binary sha256.
- Add gitea_secret_backend + gitea_secret_backend_role modules and wire them
  through config.hcl, terragrunt.hcl, and vault_cluster variables/main, using
  the giteavaultsecret provider (terraform-unkin registry, v0.1.0).
- Mount the engine at gitea/ against https://git.unkin.net; seeded site-admin
  credentials are read from KV (service/vault/au/syd1/secret_backend/gitea/config).
- Add teabot-implementer (write:repository, write:issue) and teabot-reviewer
  (read:repository, write:issue) roles, ttl 1h / max_ttl 4h.

Claude-Session: https://claude.ai/code/session_015ur3i7D2azsMAWTSVABApv
2026-07-27 19:05:08 +10:00
unkinben 31424ea6ff ci: fetch vault from artifactapi instead of dnf install (#99)
ci/woodpecker/push/apply Pipeline was successful
## Why

CI installs vault by shelling out to `dnf install vault -y`. That reads
metadata for every enabled repo (appstream/baseos/crb/epel/ha) and downloads
the 169MB vendored vault RPM from the `unkin` repo on **every** plan/apply run
(~39s per job measured in `almalinux9-opentofu:20260606`).

## Change

- Replace `dnf install vault -y` with a pinned `curl` of the upstream vault zip
  from the artifactapi `hashicorp-releases` remote proxy, extracted with the
  image's `python3` (`python3 -m zipfile`) to `/usr/local/bin/vault`.
- Pin the version via a new `VAULT_VERSION` env var (`1.20.0`); bump the var to
  upgrade.

## Speedup

Measured in `git.unkin.net/unkin/almalinux9-opentofu:20260606`:

| approach | time |
|---|---|
| `dnf install vault -y` (current) | ~39s |
| `dnf --disablerepo='*' --enablerepo=unkin` (still pulls 169MB RPM) | ~9s |
| curl zip from artifactapi + python extract (this PR) | ~6.6s |

~32s saved per plan/apply job. The zip is cached by artifactapi after first
fetch (warm ~3s).

## Caveats

- Assumes the `almalinux9-opentofu` image ships `curl` + `python3` (both
  present in `:20260606`).
- Relies on the existing artifactapi `hashicorp-releases` generic remote whose
  patterns already allow `vault/.*vault_.*_linux_amd64.zip`.

---------

Co-authored-by: benvin <neotheo@gmail.com>
Reviewed-on: #99
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-25 09:47:34 +10:00
unkinben 1fa5900787 Add terraform-enc Vault/Consul plumbing + encapi token grant (#98)
ci/woodpecker/push/apply Pipeline was successful
The new **terragrunt-enc** repo manages all encapi ENC data (statuses, roles, node classifications) via Terraform/Terragrunt and needs its own Vault/Consul plumbing, mirroring terraform-git and terraform-incus. This supersedes the dual-write approach in terraform-incus PR #39; the equivalent terraform-incus grant (PR #97) is being closed, so the encapi-token grant is created fresh here for the new approle.

Changes:
- Add approle role `terraform_enc` and k8s auth role `woodpecker_terraform_enc` (bound to the `terraform-enc` ServiceAccount in the `woodpecker` namespace) for CI auth.
- Add consul secret backend role `terraform-enc` plus its ACL rules granting `write` on `infra/terraform/enc/` (its terragrunt state prefix), and a policy letting both auth roles read `consul_root/au/syd1/creds/terraform-enc`.
- Grant both auth roles read on `kv/data/kubernetes/namespace/encapi/default/environment` (the ENCAPI_WRITE_TOKEN) so `make apply` can write to encapi via the encapi provider.

Reviewed-on: #98
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-07-24 23:18:56 +10:00
23 changed files with 341 additions and 14 deletions
+2 -1
View File
@@ -7,8 +7,9 @@ steps:
image: git.unkin.net/unkin/almalinux9-opentofu:20260606
environment:
VAULT_AUTH_METHOD: kubernetes
VAULT_VERSION: "1.20.0"
commands:
- dnf install vault -y
- curl -fsSL -o /tmp/vault.zip "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/hashicorp-releases/vault/$${VAULT_VERSION}/vault_$${VAULT_VERSION}_linux_amd64.zip" && python3 -m zipfile -e /tmp/vault.zip /tmp/ && install -m0755 /tmp/vault /usr/local/bin/vault && rm -f /tmp/vault.zip /tmp/vault /tmp/LICENSE.txt
- make plan
- make apply
backend_options:
+2 -1
View File
@@ -6,8 +6,9 @@ steps:
image: git.unkin.net/unkin/almalinux9-opentofu:20260606
environment:
VAULT_AUTH_METHOD: kubernetes
VAULT_VERSION: "1.20.0"
commands:
- dnf install vault -y
- curl -fsSL -o /tmp/vault.zip "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/hashicorp-releases/vault/$${VAULT_VERSION}/vault_$${VAULT_VERSION}_linux_amd64.zip" && python3 -m zipfile -e /tmp/vault.zip /tmp/ && install -m0755 /tmp/vault /usr/local/bin/vault && rm -f /tmp/vault.zip /tmp/vault /tmp/LICENSE.txt
- make plan
backend_options:
kubernetes:
@@ -0,0 +1,9 @@
token_ttl: 120
token_max_ttl: 120
bind_secret_id: false
token_bound_cidrs:
- "10.10.12.200/32"
- "198.18.25.102/32"
- "198.18.26.91/32"
- "198.18.27.40/32"
use_deterministic_role_id: true
@@ -0,0 +1,7 @@
bound_service_account_names:
- terraform-enc
bound_service_account_namespaces:
- woodpecker
token_ttl: 600
token_max_ttl: 600
audience: https://kubernetes.default.svc.cluster.local
+13
View File
@@ -239,5 +239,18 @@ locals {
})
if startswith(file_path, "rancher_secret_backend_role/")
}
gitea_secret_backend = {
for file_path, content in local.all_configs :
trimsuffix(basename(file_path), ".yaml") => content
if startswith(file_path, "gitea_secret_backend/")
}
gitea_secret_backend_role = {
for file_path, content in local.all_configs :
trimsuffix(replace(file_path, "gitea_secret_backend_role/", ""), ".yaml") => merge(content, {
name = trimsuffix(basename(file_path), ".yaml")
backend = dirname(replace(file_path, "gitea_secret_backend_role/", ""))
})
if startswith(file_path, "gitea_secret_backend_role/")
}
}
}
@@ -0,0 +1,5 @@
consul_roles:
- terraform-enc
ttl: 120
max_ttl: 300
datacenters: []
+11
View File
@@ -0,0 +1,11 @@
# Mounts the gitea token secrets engine at "gitea" and writes its config.
# The seeded site-admin credentials are sensitive and read from KV, not stored
# here:
# kv/service/vault/au/syd1/secret_backend/gitea/config
# -> keys: admin_username (required), admin_password (required)
# Populate that KV path with a purpose-built Gitea site-admin bot (2FA disabled)
# BEFORE applying, then run `vault write -f gitea/config/rotate-root` after the
# first apply so only Vault holds the admin password.
description: "Gitea ephemeral scoped access token engine"
gitea_url: "https://git.unkin.net"
request_timeout_seconds: 30
@@ -0,0 +1,14 @@
# Role minting ephemeral tokens for the teabot-implementer bot user.
# The implementer clones/pushes code and opens pull requests, so it gets write
# on repositories (clone + push + PR create) and write on issues (PR/issue
# comments). Read is implied by write. No admin/org/user-write scopes.
# Reading gitea/creds/teabot-implementer mints a lease-bound token deleted from
# Gitea on revoke/expiry.
---
username: teabot-implementer
scopes:
- write:repository
- write:issue
token_name_prefix: vault-teabot-implementer
ttl: 3600 # 1h
max_ttl: 14400 # 4h
@@ -0,0 +1,14 @@
# Role minting ephemeral tokens for the teabot-reviewer bot user.
# The reviewer reads code and posts pull-request reviews/comments, so it gets
# read on repositories (fetch diffs) and write on issues (PR reviews + issue/PR
# comments). No repository-write, admin, org, or user scopes.
# Reading gitea/creds/teabot-reviewer mints a lease-bound token deleted from
# Gitea on revoke/expiry.
---
username: teabot-reviewer
scopes:
- read:repository
- write:issue
token_name_prefix: vault-teabot-reviewer
ttl: 3600 # 1h
max_ttl: 14400 # 4h
@@ -0,0 +1,11 @@
# config/plugins/vault-plugin-secrets-gitea.yaml
# Imports (registers) the gitea secrets plugin in the catalog. Filename =
# catalog name = mount type. The binary is installed on the OpenBao nodes by
# Puppet (openbao-plugin-secrets-gitea RPM ->
# /opt/openbao-plugins/vault-plugin-secrets-gitea).
#
# sha256 pins the released v0.1.0 binary; bump it in lockstep with any RPM
# upgrade or OpenBao will refuse to launch the plugin.
type: secret
command: vault-plugin-secrets-gitea
sha256: "8f67fbc216effada5fd7399888a710b62fad83be0b31761a439e7dec3d56509b"
+3
View File
@@ -78,6 +78,9 @@ inputs = {
rancher_secret_backend_service_account = local.config.rancher_secret_backend_service_account
rancher_secret_backend_role = local.config.rancher_secret_backend_role
gitea_secret_backend = local.config.gitea_secret_backend
gitea_secret_backend_role = local.config.gitea_secret_backend_role
# Pass policy maps to vault_cluster module
policy_auth_map = local.policies.policy_auth_map
policy_rules_map = local.policies.policy_rules_map
+34
View File
@@ -421,6 +421,40 @@ module "rancher_secret_backend_role" {
depends_on = [module.rancher_secret_backend_service_account]
}
module "gitea_secret_backend" {
source = "./modules/gitea_secret_backend"
for_each = var.gitea_secret_backend
path = each.key
plugin = each.value.plugin
description = each.value.description
gitea_url = each.value.gitea_url
country = var.country
region = var.region
ca_cert = each.value.ca_cert
tls_skip_verify = each.value.tls_skip_verify
request_timeout_seconds = each.value.request_timeout_seconds
depends_on = [module.plugin]
}
module "gitea_secret_backend_role" {
source = "./modules/gitea_secret_backend_role"
for_each = var.gitea_secret_backend_role
backend = each.value.backend
name = each.value.name
username = each.value.username
scopes = each.value.scopes
token_name_prefix = each.value.token_name_prefix
ttl = each.value.ttl
max_ttl = each.value.max_ttl
depends_on = [module.gitea_secret_backend]
}
module "vault_policy" {
source = "./modules/vault_policy"
@@ -0,0 +1,34 @@
# Mounts the gitea secrets engine and writes its connection config via the
# giteavaultsecret provider. The plugin is registered ("imported") in the
# catalog separately (config/plugins/vault-plugin-secrets-gitea.yaml). The
# seeded site-admin credentials are sensitive and read from KV, not stored in
# git:
# kv/service/vault/<country>/<region>/secret_backend/<path>/config
# Expected keys: admin_username (required), admin_password (required).
data "vault_kv_secret_v2" "config" {
mount = "kv"
name = "service/vault/${var.country}/${var.region}/secret_backend/${var.path}/config"
}
resource "gitea_secret_backend" "this" {
path = var.path
plugin = var.plugin
description = var.description
gitea_url = var.gitea_url
admin_username = data.vault_kv_secret_v2.config.data["admin_username"]
admin_password = data.vault_kv_secret_v2.config.data["admin_password"]
ca_cert = var.ca_cert
tls_skip_verify = var.tls_skip_verify
request_timeout_seconds = var.request_timeout_seconds
lifecycle {
# The KV seed is a bootstrap credential: it is consumed only when the engine
# config is first created. After creation the live admin password is rotated
# in place (vault write -f gitea/config/rotate-root) and diverges from the
# seed, so re-reading the (possibly stale) KV value must never push it back.
# Ignoring the credential attributes makes this module create-only for them.
# (The sibling rancher/litellm seed modules do not yet do this and would
# re-push their seed on a subsequent apply.)
ignore_changes = [admin_username, admin_password]
}
}
@@ -0,0 +1,13 @@
terraform {
required_version = ">= 1.10"
required_providers {
vault = {
source = "hashicorp/vault"
version = "5.6.0"
}
gitea = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/giteavaultsecret"
version = "0.1.0"
}
}
}
@@ -0,0 +1,49 @@
variable "path" {
description = "Mount path of the gitea secrets engine (e.g. \"gitea\")"
type = string
}
variable "plugin" {
description = "Registered plugin name to mount (the catalog name = mount type)"
type = string
default = "vault-plugin-secrets-gitea"
}
variable "description" {
description = "Human-friendly description of the mount"
type = string
default = null
}
variable "gitea_url" {
description = "Base URL of the Gitea server (e.g. https://git.unkin.net)"
type = string
}
variable "country" {
description = "Country segment of the KV path holding the seeded admin credentials"
type = string
}
variable "region" {
description = "Region segment of the KV path holding the seeded admin credentials"
type = string
}
variable "ca_cert" {
description = "PEM CA certificate that signed the Gitea server's TLS cert (optional; omit to use the system trust store)"
type = string
default = null
}
variable "tls_skip_verify" {
description = "Skip TLS verification of the Gitea server (not recommended)"
type = bool
default = false
}
variable "request_timeout_seconds" {
description = "HTTP timeout in seconds for calls from the plugin to Gitea"
type = number
default = 30
}
@@ -0,0 +1,12 @@
# A role that mints short-lived, scoped gitea tokens for a target Gitea user.
# Reading gitea/creds/<name> produces a lease-bound token that is deleted from
# Gitea when the lease is revoked or reaches max_ttl.
resource "gitea_secret_backend_role" "this" {
backend = var.backend
name = var.name
username = var.username
scopes = var.scopes
token_name_prefix = var.token_name_prefix
ttl = var.ttl
max_ttl = var.max_ttl
}
@@ -0,0 +1,9 @@
terraform {
required_version = ">= 1.10"
required_providers {
gitea = {
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/giteavaultsecret"
version = "0.1.0"
}
}
}
@@ -0,0 +1,37 @@
variable "backend" {
description = "Mount path of the gitea secrets engine this role belongs to"
type = string
}
variable "name" {
description = "Role name (read gitea/creds/<name> to mint a token)"
type = string
}
variable "username" {
description = "Target Gitea username the minted tokens belong to"
type = string
}
variable "scopes" {
description = "Gitea access-token scopes granted to minted tokens (write: implies read:)"
type = list(string)
}
variable "token_name_prefix" {
description = "Prefix for the generated Gitea token name (optional)"
type = string
default = null
}
variable "ttl" {
description = "Default lease TTL in seconds for minted tokens"
type = number
default = null
}
variable "max_ttl" {
description = "Maximum lease TTL in seconds for minted tokens"
type = number
default = null
}
+27
View File
@@ -388,6 +388,33 @@ variable "rancher_secret_backend_role" {
default = {}
}
variable "gitea_secret_backend" {
description = "Map of gitea token secret engines to create (mount + config; seeded admin creds read from KV)"
type = map(object({
plugin = optional(string, "vault-plugin-secrets-gitea")
description = optional(string)
gitea_url = string
ca_cert = optional(string)
tls_skip_verify = optional(bool, false)
request_timeout_seconds = optional(number, 30)
}))
default = {}
}
variable "gitea_secret_backend_role" {
description = "Map of gitea token-minting roles to create"
type = map(object({
name = string
backend = string
username = string
scopes = list(string)
token_name_prefix = optional(string)
ttl = optional(number)
max_ttl = optional(number)
}))
default = {}
}
variable "policy_auth_map" {
description = "Map of auth mounts -> auth roles -> policy names"
type = map(map(list(string)))
@@ -0,0 +1,14 @@
# Allow the terragrunt-enc runner to generate credentials for the
# terraform-enc role in consul (used to lock/write its terragrunt state under
# infra/terraform/enc/ on the consul backend).
---
rules:
- path: "consul_root/au/syd1/creds/terraform-enc"
capabilities:
- read
auth:
approle:
- terraform_enc
k8s/au/syd1:
- woodpecker_terraform_enc
@@ -0,0 +1,14 @@
# Allow the terragrunt-enc runner to read the encapi environment secret
# (ENCAPI_WRITE_TOKEN), so `make apply` can write ENC data (statuses, roles,
# nodes) to encapi via the encapi Terraform provider.
---
rules:
- path: "kv/data/kubernetes/namespace/encapi/default/environment"
capabilities:
- read
auth:
approle:
- terraform_enc
k8s/au/syd1:
- woodpecker_terraform_enc
@@ -1,12 +0,0 @@
# Allow the Terraform Incus runner to read the encapi environment secret
# (ENCAPI_WRITE_TOKEN), so `make apply` can dual-write ENC data to encapi via
# the encapi Terraform provider.
---
rules:
- path: "kv/data/kubernetes/namespace/encapi/default/environment"
capabilities:
- read
auth:
approle:
- terraform_incus
@@ -0,0 +1,7 @@
key_prefix "infra/terraform/enc/" {
policy = "write"
}
session_prefix "" {
policy = "write"
}