## Context Wave 3A (commitc9d221d5) added the `# KYVERNO_LIFECYCLE_V1` marker to the 27 pre-existing `ignore_changes = [...dns_config]` sites so they could be grepped and audited. It did NOT address pod-owning resources that were simply missing the suppression entirely. Post-Wave-3A sampling (2026-04-18) found that navidrome, f1-stream, frigate, servarr, monitoring, crowdsec, and many other stacks showed perpetual `dns_config` drift every plan because their `kubernetes_deployment` / `kubernetes_stateful_set` / `kubernetes_cron_job_v1` resources had no `lifecycle {}` block at all. Root cause (same as Wave 3A): Kyverno's admission webhook stamps `dns_config { option { name = "ndots"; value = "2" } }` on every pod's `spec.template.spec.dns_config` to prevent NxDomain search-domain flooding (see `k8s-ndots-search-domain-nxdomain-flood` skill). Without `ignore_changes` on every Terraform-managed pod-owner, Terraform repeatedly tries to strip the injected field. ## This change Extends the Wave 3A convention by sweeping EVERY `kubernetes_deployment`, `kubernetes_stateful_set`, `kubernetes_daemon_set`, `kubernetes_cron_job_v1`, `kubernetes_job_v1` (+ their `_v1` variants) in the repo and ensuring each carries the right `ignore_changes` path: - **kubernetes_deployment / stateful_set / daemon_set / job_v1**: `spec[0].template[0].spec[0].dns_config` - **kubernetes_cron_job_v1**: `spec[0].job_template[0].spec[0].template[0].spec[0].dns_config` (extra `job_template[0]` nesting — the CronJob's PodTemplateSpec is one level deeper) Each injection / extension is tagged `# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2` inline so the suppression is discoverable via `rg 'KYVERNO_LIFECYCLE_V1' stacks/`. Two insertion paths are handled by a Python pass (`/tmp/add_dns_config_ignore.py`): 1. **No existing `lifecycle {}`**: inject a brand-new block just before the resource's closing `}`. 108 new blocks on 93 files. 2. **Existing `lifecycle {}` (usually for `DRIFT_WORKAROUND: CI owns image tag` from Wave 4, commit a62b43d1)**: extend its `ignore_changes` list with the dns_config path. Handles both inline (`= [x]`) and multiline (`= [\n x,\n]`) forms; ensures the last pre-existing list item carries a trailing comma so the extended list is valid HCL. 34 extensions. The script skips anything already mentioning `dns_config` inside an `ignore_changes`, so re-running is a no-op. ## Scale - 142 total lifecycle injections/extensions - 93 `.tf` files touched - 108 brand-new `lifecycle {}` blocks + 34 extensions of existing ones - Every Tier 0 and Tier 1 stack with a pod-owning resource is covered - Together with Wave 3A's 27 pre-existing markers → **169 greppable `KYVERNO_LIFECYCLE_V1` dns_config sites across the repo** ## What is NOT in this change - `stacks/trading-bot/main.tf` — entirely commented-out block (`/* … */`). Python script touched the file, reverted manually. - `_template/main.tf.example` skeleton — kept minimal on purpose; any future stack created from it should either inherit the Wave 3A one-line form or add its own on first `kubernetes_deployment`. - `terraform fmt` fixes to pre-existing alignment issues in meshcentral, nvidia/modules/nvidia, vault — unrelated to this commit. Left for a separate fmt-only pass. - Non-pod resources (`kubernetes_service`, `kubernetes_secret`, `kubernetes_manifest`, etc.) — they don't own pods so they don't get Kyverno dns_config mutation. ## Verification Random sample post-commit: ``` $ cd stacks/navidrome && ../../scripts/tg plan → No changes. $ cd stacks/f1-stream && ../../scripts/tg plan → No changes. $ cd stacks/frigate && ../../scripts/tg plan → No changes. $ rg -c 'KYVERNO_LIFECYCLE_V1' stacks/ --include='*.tf' --include='*.tf.example' \ | awk -F: '{s+=$2} END {print s}' 169 ``` ## Reproduce locally 1. `git pull` 2. `rg 'KYVERNO_LIFECYCLE_V1' stacks/ | wc -l` → 169+ 3. `cd stacks/navidrome && ../../scripts/tg plan` → expect 0 drift on the deployment's dns_config field. Refs: code-seq (Wave 3B dns_config class closed; kubernetes_manifest annotation class handled separately in8d94688dfor tls_secret) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
982 lines
31 KiB
HCL
982 lines
31 KiB
HCL
variable "tls_secret_name" {
|
|
type = string
|
|
sensitive = true
|
|
}
|
|
|
|
variable "nfs_server" {
|
|
type = string
|
|
}
|
|
resource "kubernetes_namespace" "vault" {
|
|
metadata {
|
|
name = "vault"
|
|
labels = {
|
|
tier = local.tiers.core
|
|
}
|
|
}
|
|
lifecycle {
|
|
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
|
|
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
|
|
}
|
|
}
|
|
|
|
module "tls_secret" {
|
|
source = "../../modules/kubernetes/setup_tls_secret"
|
|
namespace = kubernetes_namespace.vault.metadata[0].name
|
|
tls_secret_name = var.tls_secret_name
|
|
}
|
|
|
|
# NFS StorageClass pointing to Proxmox host (replaces nfs-truenas for vault)
|
|
resource "kubernetes_storage_class" "nfs_proxmox" {
|
|
metadata {
|
|
name = "nfs-proxmox"
|
|
}
|
|
storage_provisioner = "nfs.csi.k8s.io"
|
|
reclaim_policy = "Retain"
|
|
volume_binding_mode = "Immediate"
|
|
allow_volume_expansion = true
|
|
parameters = {
|
|
server = "192.168.1.127"
|
|
share = "/srv/nfs"
|
|
}
|
|
mount_options = ["soft", "actimeo=5", "retrans=3", "timeo=30"]
|
|
}
|
|
|
|
resource "helm_release" "vault" {
|
|
name = "vault"
|
|
namespace = kubernetes_namespace.vault.metadata[0].name
|
|
create_namespace = false
|
|
repository = "https://helm.releases.hashicorp.com"
|
|
chart = "vault"
|
|
version = "0.29.1"
|
|
atomic = false # HA pods start sealed — readiness probes fail until unsealed
|
|
timeout = 600
|
|
|
|
values = [yamlencode({
|
|
global = { enabled = true }
|
|
|
|
server = {
|
|
enabled = true
|
|
|
|
resources = {
|
|
requests = { memory = "384Mi", cpu = "100m" }
|
|
limits = { memory = "384Mi" }
|
|
}
|
|
|
|
# Allow scheduling on GPU node (node1)
|
|
tolerations = [{
|
|
key = "nvidia.com/gpu"
|
|
operator = "Exists"
|
|
effect = "NoSchedule"
|
|
}]
|
|
|
|
dataStorage = {
|
|
enabled = true
|
|
size = "2Gi"
|
|
storageClass = "nfs-proxmox" # Proxmox host NFS (was nfs-truenas)
|
|
}
|
|
|
|
auditStorage = {
|
|
enabled = true
|
|
size = "2Gi"
|
|
storageClass = "nfs-proxmox" # Proxmox host NFS (was nfs-truenas)
|
|
}
|
|
|
|
standalone = { enabled = false }
|
|
|
|
ha = {
|
|
enabled = true
|
|
replicas = 3
|
|
|
|
raft = {
|
|
enabled = true
|
|
setNodeId = true
|
|
config = <<-EOT
|
|
ui = true
|
|
|
|
listener "tcp" {
|
|
tls_disable = 1
|
|
address = "[::]:8200"
|
|
cluster_address = "[::]:8201"
|
|
}
|
|
|
|
storage "raft" {
|
|
path = "/vault/data"
|
|
retry_join {
|
|
leader_api_addr = "http://vault-0.vault-internal:8200"
|
|
}
|
|
retry_join {
|
|
leader_api_addr = "http://vault-1.vault-internal:8200"
|
|
}
|
|
retry_join {
|
|
leader_api_addr = "http://vault-2.vault-internal:8200"
|
|
}
|
|
}
|
|
|
|
service_registration "kubernetes" {}
|
|
EOT
|
|
}
|
|
}
|
|
|
|
# Mount unseal key secret
|
|
extraVolumes = [{
|
|
type = "secret"
|
|
name = "vault-unseal-key"
|
|
}]
|
|
|
|
# Auto-unseal sidecar — polls every 10s, unseals if sealed
|
|
extraContainers = [{
|
|
name = "auto-unseal"
|
|
image = "hashicorp/vault:1.18.1"
|
|
command = ["/bin/sh", "-c"]
|
|
args = [join("", [
|
|
"while true; do ",
|
|
"sealed=$(VAULT_ADDR=http://127.0.0.1:8200 vault status -format=json 2>/dev/null | grep '\"sealed\"' | grep -o 'true\\|false'); ",
|
|
"if [ \"$sealed\" = \"true\" ]; then ",
|
|
"echo \"$(date): Vault is sealed, unsealing...\"; ",
|
|
"VAULT_ADDR=http://127.0.0.1:8200 vault operator unseal $(cat /vault/unseal-key/unseal-key); ",
|
|
"fi; ",
|
|
"sleep 10; ",
|
|
"done"
|
|
])]
|
|
volumeMounts = [{
|
|
name = "userconfig-vault-unseal-key" # Helm chart prefixes extraVolumes with "userconfig-"
|
|
mountPath = "/vault/unseal-key"
|
|
readOnly = true
|
|
}]
|
|
resources = {
|
|
requests = { cpu = "10m", memory = "128Mi" }
|
|
limits = { memory = "128Mi" }
|
|
}
|
|
}]
|
|
}
|
|
|
|
ui = { enabled = true }
|
|
injector = { enabled = false }
|
|
csi = { enabled = false }
|
|
})]
|
|
}
|
|
|
|
# --- Self-read: Vault's own OIDC credentials from KV ---
|
|
|
|
data "vault_kv_secret_v2" "vault" {
|
|
mount = "secret"
|
|
name = "vault"
|
|
depends_on = [helm_release.vault]
|
|
}
|
|
|
|
# --- OIDC Authentication via Authentik ---
|
|
|
|
resource "vault_jwt_auth_backend" "oidc" {
|
|
path = "oidc"
|
|
type = "oidc"
|
|
oidc_discovery_url = "https://authentik.viktorbarzin.me/application/o/vault/"
|
|
oidc_client_id = data.vault_kv_secret_v2.vault.data["authentik_client_id"]
|
|
oidc_client_secret = data.vault_kv_secret_v2.vault.data["authentik_client_secret"]
|
|
default_role = "default"
|
|
tune {
|
|
listing_visibility = "hidden"
|
|
}
|
|
depends_on = [helm_release.vault]
|
|
}
|
|
|
|
resource "vault_jwt_auth_backend_role" "default" {
|
|
backend = vault_jwt_auth_backend.oidc.path
|
|
role_name = "default"
|
|
token_policies = ["default"]
|
|
token_ttl = 604800
|
|
token_max_ttl = 604800
|
|
user_claim = "email"
|
|
groups_claim = "groups"
|
|
role_type = "oidc"
|
|
allowed_redirect_uris = [
|
|
"https://vault.viktorbarzin.me/ui/vault/auth/oidc/oidc/callback",
|
|
"http://localhost:8250/oidc/callback",
|
|
]
|
|
oidc_scopes = ["openid", "email", "profile"]
|
|
}
|
|
|
|
resource "vault_policy" "admin" {
|
|
name = "vault-admin"
|
|
policy = <<-EOT
|
|
path "*" {
|
|
capabilities = ["create", "read", "update", "delete", "list", "sudo"]
|
|
}
|
|
EOT
|
|
}
|
|
|
|
resource "vault_policy" "sops_admin" {
|
|
name = "sops-admin"
|
|
policy = <<-EOT
|
|
path "transit/encrypt/sops-state-*" { capabilities = ["update"] }
|
|
path "transit/decrypt/sops-state-*" { capabilities = ["update"] }
|
|
path "transit/keys/sops-state-*" { capabilities = ["create", "read", "update"] }
|
|
EOT
|
|
}
|
|
|
|
resource "vault_identity_group" "admins" {
|
|
name = "authentik-admins"
|
|
type = "external"
|
|
policies = [vault_policy.admin.name, vault_policy.sops_admin.name]
|
|
}
|
|
|
|
resource "vault_identity_group_alias" "admins" {
|
|
name = "authentik Admins"
|
|
mount_accessor = vault_jwt_auth_backend.oidc.accessor
|
|
canonical_id = vault_identity_group.admins.id
|
|
}
|
|
|
|
module "ingress" {
|
|
source = "../../modules/kubernetes/ingress_factory"
|
|
dns_type = "proxied"
|
|
namespace = kubernetes_namespace.vault.metadata[0].name
|
|
name = "vault"
|
|
service_name = "vault-active"
|
|
tls_secret_name = var.tls_secret_name
|
|
port = 8200
|
|
extra_annotations = {
|
|
"gethomepage.dev/enabled" = "true"
|
|
"gethomepage.dev/name" = "Vault"
|
|
"gethomepage.dev/description" = "HashiCorp Vault - Secrets Management"
|
|
"gethomepage.dev/icon" = "vault.png"
|
|
"gethomepage.dev/group" = "Core Platform"
|
|
"gethomepage.dev/pod-selector" = ""
|
|
}
|
|
}
|
|
|
|
# --- Audit Logging ---
|
|
|
|
resource "vault_audit" "file" {
|
|
type = "file"
|
|
options = {
|
|
file_path = "/vault/audit/vault-audit.log"
|
|
}
|
|
depends_on = [helm_release.vault]
|
|
}
|
|
|
|
# --- Raft Snapshot Backups ---
|
|
|
|
module "vault_backup_nfs_host" {
|
|
source = "../../modules/kubernetes/nfs_volume"
|
|
name = "vault-backup-host"
|
|
namespace = kubernetes_namespace.vault.metadata[0].name
|
|
nfs_server = "192.168.1.127"
|
|
nfs_path = "/srv/nfs/vault-backup"
|
|
storage = "5Gi"
|
|
}
|
|
|
|
resource "kubernetes_cron_job_v1" "vault_backup" {
|
|
metadata {
|
|
name = "vault-raft-backup"
|
|
namespace = kubernetes_namespace.vault.metadata[0].name
|
|
}
|
|
spec {
|
|
schedule = "0 2 * * 0"
|
|
successful_jobs_history_limit = 3
|
|
failed_jobs_history_limit = 3
|
|
concurrency_policy = "Forbid"
|
|
job_template {
|
|
metadata {}
|
|
spec {
|
|
backoff_limit = 6
|
|
template {
|
|
metadata {}
|
|
spec {
|
|
container {
|
|
name = "backup"
|
|
image = "hashicorp/vault:1.18.1"
|
|
command = ["/bin/sh", "-c"]
|
|
args = [join("", [
|
|
"set -eu; ",
|
|
"_t0=$(date +%s); ",
|
|
"_rb0=$(awk '/^read_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0); ",
|
|
"_wb0=$(awk '/^write_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0); ",
|
|
"export VAULT_ADDR=http://vault-active.vault.svc.cluster.local:8200 && ",
|
|
"export VAULT_TOKEN=$(cat /vault/token/vault-root-token) && ",
|
|
"TIMESTAMP=$(date +%Y%m%d-%H%M%S) && ",
|
|
"vault operator raft snapshot save /backup/vault-raft-$TIMESTAMP.db && ",
|
|
"find /backup -name '*.db' -mtime +30 -delete && ",
|
|
"echo \"Backup done: vault-raft-$TIMESTAMP.db\" && ls -lh /backup/ && ",
|
|
"_dur=$(( $(date +%s) - _t0 )); ",
|
|
"_rb1=$(awk '/^read_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0); ",
|
|
"_wb1=$(awk '/^write_bytes/{print $2}' /proc/$$/io 2>/dev/null || echo 0); ",
|
|
"echo '=== Backup IO Stats ==='; ",
|
|
"echo \"duration: $${_dur}s\"; ",
|
|
"echo \"read: $(( (_rb1 - _rb0) / 1048576 )) MiB\"; ",
|
|
"echo \"written: $(( (_wb1 - _wb0) / 1048576 )) MiB\"; ",
|
|
"echo \"output: $(ls -lh /backup/vault-raft-$TIMESTAMP.db | awk '{print $5}')\"; ",
|
|
"_out_bytes=$(stat -c%s /backup/vault-raft-$TIMESTAMP.db); ",
|
|
"wget -qO- --post-data \"backup_duration_seconds $${_dur}\nbackup_read_bytes $((_rb1 - _rb0))\nbackup_written_bytes $((_wb1 - _wb0))\nbackup_output_bytes $${_out_bytes}\nbackup_last_success_timestamp $(date +%s)\n\" ",
|
|
"\"http://prometheus-prometheus-pushgateway.monitoring:9091/metrics/job/vault-raft-backup\" || true"
|
|
])]
|
|
volume_mount {
|
|
mount_path = "/backup"
|
|
name = "backup-storage"
|
|
}
|
|
volume_mount {
|
|
mount_path = "/vault/token"
|
|
name = "vault-token"
|
|
read_only = true
|
|
}
|
|
}
|
|
restart_policy = "OnFailure"
|
|
volume {
|
|
name = "backup-storage"
|
|
persistent_volume_claim {
|
|
claim_name = module.vault_backup_nfs_host.claim_name
|
|
}
|
|
}
|
|
volume {
|
|
name = "vault-token"
|
|
secret {
|
|
secret_name = "vault-root-token"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
lifecycle {
|
|
# KYVERNO_LIFECYCLE_V1: Kyverno admission webhook mutates dns_config with ndots=2
|
|
ignore_changes = [spec[0].job_template[0].spec[0].template[0].spec[0].dns_config]
|
|
}
|
|
}
|
|
|
|
# =============================================================================
|
|
# Kubernetes Auth Method
|
|
# =============================================================================
|
|
# Used by ESO, Woodpecker CI, and OpenClaw to authenticate to Vault.
|
|
|
|
resource "vault_auth_backend" "kubernetes" {
|
|
type = "kubernetes"
|
|
depends_on = [helm_release.vault]
|
|
}
|
|
|
|
resource "vault_kubernetes_auth_backend_config" "k8s" {
|
|
backend = vault_auth_backend.kubernetes.path
|
|
kubernetes_host = "https://kubernetes.default.svc"
|
|
}
|
|
|
|
# --- CI Policy & Role (Woodpecker) ---
|
|
|
|
resource "vault_policy" "ci" {
|
|
name = "ci"
|
|
policy = <<-EOT
|
|
path "secret/data/*" {
|
|
capabilities = ["read", "list"]
|
|
}
|
|
path "secret/metadata/*" {
|
|
capabilities = ["list"]
|
|
}
|
|
# Allow CI to write k8s_users during automated user provisioning
|
|
path "secret/data/platform" {
|
|
capabilities = ["create", "read", "update"]
|
|
}
|
|
# Allow CI to get dynamic K8s deploy tokens for user namespaces
|
|
path "kubernetes/creds/*-deployer" {
|
|
capabilities = ["read"]
|
|
}
|
|
# SOPS state encrypt/decrypt (per-stack Transit keys)
|
|
path "transit/encrypt/sops-state-*" {
|
|
capabilities = ["update"]
|
|
}
|
|
path "transit/decrypt/sops-state-*" {
|
|
capabilities = ["update"]
|
|
}
|
|
path "transit/keys/sops-state-*" {
|
|
capabilities = ["read"]
|
|
}
|
|
EOT
|
|
}
|
|
|
|
resource "vault_kubernetes_auth_backend_role" "ci" {
|
|
backend = vault_auth_backend.kubernetes.path
|
|
role_name = "ci"
|
|
bound_service_account_names = ["default"]
|
|
bound_service_account_namespaces = ["woodpecker"]
|
|
token_policies = [vault_policy.ci.name]
|
|
token_ttl = 604800 # 7d
|
|
token_period = 604800 # periodic: auto-renews indefinitely
|
|
}
|
|
|
|
# --- ESO Policy & Role ---
|
|
|
|
resource "vault_policy" "eso_reader" {
|
|
name = "eso-reader"
|
|
policy = <<-EOT
|
|
# KV secrets
|
|
path "secret/data/*" {
|
|
capabilities = ["read", "list"]
|
|
}
|
|
# Deny access to vault's administrative secrets
|
|
path "secret/data/vault" {
|
|
capabilities = ["deny"]
|
|
}
|
|
path "database/static-creds/*" {
|
|
capabilities = ["read"]
|
|
}
|
|
EOT
|
|
}
|
|
|
|
resource "vault_kubernetes_auth_backend_role" "eso" {
|
|
backend = vault_auth_backend.kubernetes.path
|
|
role_name = "eso"
|
|
bound_service_account_names = ["external-secrets"]
|
|
bound_service_account_namespaces = ["external-secrets"]
|
|
token_policies = [vault_policy.eso_reader.name]
|
|
token_ttl = 864000 # 10d (staggered from ci/openclaw)
|
|
token_period = 864000 # periodic: auto-renews indefinitely
|
|
}
|
|
|
|
# --- Woodpecker Secret Sync Policy & Role ---
|
|
|
|
resource "vault_policy" "woodpecker_sync" {
|
|
name = "woodpecker-sync"
|
|
policy = <<-EOT
|
|
path "secret/data/ci/*" {
|
|
capabilities = ["read", "list"]
|
|
}
|
|
EOT
|
|
}
|
|
|
|
resource "vault_kubernetes_auth_backend_role" "woodpecker_sync" {
|
|
backend = vault_auth_backend.kubernetes.path
|
|
role_name = "woodpecker-sync"
|
|
bound_service_account_names = ["default"]
|
|
bound_service_account_namespaces = ["woodpecker"]
|
|
token_policies = [vault_policy.woodpecker_sync.name]
|
|
token_ttl = 691200 # 8d (staggered from others)
|
|
token_period = 691200 # periodic: auto-renews indefinitely
|
|
}
|
|
|
|
# --- OpenClaw Policy & Role ---
|
|
|
|
resource "vault_policy" "openclaw_k8s" {
|
|
name = "openclaw-k8s"
|
|
policy = <<-EOT
|
|
path "kubernetes/creds/openclaw" {
|
|
capabilities = ["read"]
|
|
}
|
|
path "secret/data/*" {
|
|
capabilities = ["read", "list"]
|
|
}
|
|
EOT
|
|
}
|
|
|
|
resource "vault_kubernetes_auth_backend_role" "openclaw" {
|
|
backend = vault_auth_backend.kubernetes.path
|
|
role_name = "openclaw"
|
|
bound_service_account_names = ["openclaw"]
|
|
bound_service_account_namespaces = ["openclaw"]
|
|
token_policies = [vault_policy.openclaw_k8s.name]
|
|
token_ttl = 777600 # 9d (staggered from others)
|
|
token_period = 777600 # periodic: auto-renews indefinitely
|
|
}
|
|
|
|
# --- Terraform State Policy & Role (Claude Agent) ---
|
|
|
|
resource "vault_policy" "terraform_state" {
|
|
name = "terraform-state"
|
|
policy = <<-EOT
|
|
path "database/static-creds/pg-terraform-state" {
|
|
capabilities = ["read"]
|
|
}
|
|
EOT
|
|
}
|
|
|
|
resource "vault_kubernetes_auth_backend_role" "terraform_state" {
|
|
backend = vault_auth_backend.kubernetes.path
|
|
role_name = "terraform-state"
|
|
bound_service_account_names = ["default"]
|
|
bound_service_account_namespaces = ["claude-agent"]
|
|
token_policies = [vault_policy.terraform_state.name]
|
|
token_ttl = 518400 # 6d (staggered from others: ci=7d, eso=10d, woodpecker=8d, openclaw=9d)
|
|
token_period = 518400 # periodic: auto-renews indefinitely
|
|
}
|
|
|
|
# =============================================================================
|
|
# Database Secrets Engine — Static Password Rotation
|
|
# =============================================================================
|
|
# Rotates app-level DB passwords automatically. Root/operator passwords excluded.
|
|
|
|
resource "vault_mount" "database" {
|
|
path = "database"
|
|
type = "database"
|
|
depends_on = [helm_release.vault]
|
|
}
|
|
|
|
# MySQL connection — app user rotation only
|
|
resource "vault_database_secret_backend_connection" "mysql" {
|
|
backend = vault_mount.database.path
|
|
name = "mysql"
|
|
allowed_roles = [
|
|
"mysql-speedtest", "mysql-wrongmove", "mysql-codimd",
|
|
"mysql-nextcloud", "mysql-shlink", "mysql-grafana",
|
|
"mysql-technitium", "mysql-phpipam"
|
|
]
|
|
|
|
mysql {
|
|
connection_url = "{{username}}:{{password}}@tcp(mysql.dbaas.svc.cluster.local:3306)/"
|
|
username = "root"
|
|
password = data.vault_kv_secret_v2.vault.data["dbaas_root_password"]
|
|
}
|
|
depends_on = [vault_mount.database]
|
|
}
|
|
|
|
# PostgreSQL connection — CNPG superuser
|
|
resource "vault_database_secret_backend_connection" "postgresql" {
|
|
backend = vault_mount.database.path
|
|
name = "postgresql"
|
|
allowed_roles = [
|
|
# "pg-trading", # Commented out 2026-04-06 - trading-bot disabled
|
|
"pg-health", "pg-linkwarden",
|
|
"pg-affine", "pg-woodpecker", "pg-claude-memory",
|
|
"pg-terraform-state", "pg-payslip-ingest"
|
|
]
|
|
|
|
postgresql {
|
|
connection_url = "postgresql://{{username}}:{{password}}@postgresql.dbaas.svc.cluster.local:5432/postgres?sslmode=disable"
|
|
username = "postgres"
|
|
password = data.vault_kv_secret_v2.vault.data["dbaas_postgresql_root_password"]
|
|
}
|
|
depends_on = [vault_mount.database]
|
|
}
|
|
|
|
# --- MySQL Static Roles ---
|
|
|
|
resource "vault_database_secret_backend_static_role" "mysql_speedtest" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.mysql.name
|
|
name = "mysql-speedtest"
|
|
username = "speedtest"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "mysql_wrongmove" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.mysql.name
|
|
name = "mysql-wrongmove"
|
|
username = "wrongmove"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "mysql_codimd" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.mysql.name
|
|
name = "mysql-codimd"
|
|
username = "codimd"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "mysql_nextcloud" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.mysql.name
|
|
name = "mysql-nextcloud"
|
|
username = "nextcloud"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "mysql_shlink" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.mysql.name
|
|
name = "mysql-shlink"
|
|
username = "shlink"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "mysql_grafana" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.mysql.name
|
|
name = "mysql-grafana"
|
|
username = "grafana"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "mysql_technitium" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.mysql.name
|
|
name = "mysql-technitium"
|
|
username = "technitium"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "mysql_phpipam" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.mysql.name
|
|
name = "mysql-phpipam"
|
|
username = "phpipam"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
# --- PostgreSQL Static Roles ---
|
|
|
|
/*
|
|
# Commented out 2026-04-06 - trading-bot disabled
|
|
resource "vault_database_secret_backend_static_role" "pg_trading" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.postgresql.name
|
|
name = "pg-trading"
|
|
username = "trading"
|
|
rotation_period = 604800
|
|
}
|
|
*/
|
|
|
|
resource "vault_database_secret_backend_static_role" "pg_health" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.postgresql.name
|
|
name = "pg-health"
|
|
username = "health"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "pg_linkwarden" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.postgresql.name
|
|
name = "pg-linkwarden"
|
|
username = "linkwarden"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "pg_affine" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.postgresql.name
|
|
name = "pg-affine"
|
|
username = "affine"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "pg_woodpecker" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.postgresql.name
|
|
name = "pg-woodpecker"
|
|
username = "woodpecker"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "pg_claude_memory" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.postgresql.name
|
|
name = "pg-claude-memory"
|
|
username = "claude_memory"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "pg_terraform_state" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.postgresql.name
|
|
name = "pg-terraform-state"
|
|
username = "terraform_state"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
resource "vault_database_secret_backend_static_role" "pg_payslip_ingest" {
|
|
backend = vault_mount.database.path
|
|
db_name = vault_database_secret_backend_connection.postgresql.name
|
|
name = "pg-payslip-ingest"
|
|
username = "payslip_ingest"
|
|
rotation_period = 604800
|
|
}
|
|
|
|
# =============================================================================
|
|
# Kubernetes Secrets Engine — Dynamic K8s Credentials
|
|
# =============================================================================
|
|
|
|
resource "vault_kubernetes_secret_backend" "k8s" {
|
|
path = "kubernetes"
|
|
kubernetes_host = "https://kubernetes.default.svc"
|
|
disable_local_ca_jwt = false
|
|
depends_on = [helm_release.vault]
|
|
}
|
|
|
|
# RBAC for Vault to manage K8s tokens/SAs
|
|
resource "kubernetes_cluster_role" "vault_k8s_engine" {
|
|
metadata { name = "vault-k8s-engine" }
|
|
rule {
|
|
api_groups = [""]
|
|
resources = ["serviceaccounts/token"]
|
|
verbs = ["create"]
|
|
}
|
|
rule {
|
|
api_groups = [""]
|
|
resources = ["serviceaccounts"]
|
|
verbs = ["get", "create", "update", "delete"]
|
|
}
|
|
rule {
|
|
api_groups = ["rbac.authorization.k8s.io"]
|
|
resources = ["rolebindings", "clusterrolebindings"]
|
|
verbs = ["create", "update", "delete"]
|
|
}
|
|
rule {
|
|
api_groups = ["rbac.authorization.k8s.io"]
|
|
resources = ["roles", "clusterroles"]
|
|
verbs = ["bind", "escalate"]
|
|
}
|
|
}
|
|
|
|
resource "kubernetes_cluster_role_binding" "vault_k8s_engine" {
|
|
metadata { name = "vault-k8s-engine" }
|
|
subject {
|
|
kind = "ServiceAccount"
|
|
name = "vault"
|
|
namespace = kubernetes_namespace.vault.metadata[0].name
|
|
}
|
|
role_ref {
|
|
api_group = "rbac.authorization.k8s.io"
|
|
kind = "ClusterRole"
|
|
name = kubernetes_cluster_role.vault_k8s_engine.metadata[0].name
|
|
}
|
|
}
|
|
|
|
# --- K8s Dashboard — short-lived admin tokens ---
|
|
|
|
resource "vault_kubernetes_secret_backend_role" "dashboard_admin" {
|
|
backend = vault_kubernetes_secret_backend.k8s.path
|
|
name = "dashboard-admin"
|
|
allowed_kubernetes_namespaces = ["kubernetes-dashboard"]
|
|
token_default_ttl = 3600
|
|
token_max_ttl = 86400
|
|
service_account_name = "kubernetes-dashboard"
|
|
}
|
|
|
|
# --- CI Deployer — scoped pipeline credentials ---
|
|
|
|
resource "kubernetes_cluster_role" "ci_deployer" {
|
|
metadata { name = "ci-deployer" }
|
|
rule {
|
|
api_groups = ["apps"]
|
|
resources = ["deployments"]
|
|
verbs = ["get", "list", "patch"]
|
|
}
|
|
rule {
|
|
api_groups = [""]
|
|
resources = ["pods"]
|
|
verbs = ["get", "list"]
|
|
}
|
|
}
|
|
|
|
resource "vault_kubernetes_secret_backend_role" "ci_deployer" {
|
|
backend = vault_kubernetes_secret_backend.k8s.path
|
|
name = "ci-deployer"
|
|
allowed_kubernetes_namespaces = ["*"]
|
|
token_default_ttl = 1800
|
|
token_max_ttl = 3600
|
|
kubernetes_role_type = "ClusterRole"
|
|
kubernetes_role_name = kubernetes_cluster_role.ci_deployer.metadata[0].name
|
|
}
|
|
|
|
# --- OpenClaw — short-lived tokens for existing SA ---
|
|
|
|
resource "vault_kubernetes_secret_backend_role" "openclaw" {
|
|
backend = vault_kubernetes_secret_backend.k8s.path
|
|
name = "openclaw"
|
|
allowed_kubernetes_namespaces = ["*"]
|
|
token_default_ttl = 3600
|
|
token_max_ttl = 86400
|
|
service_account_name = "openclaw"
|
|
}
|
|
|
|
# --- Local Admin — dynamic kubeconfig tokens ---
|
|
|
|
resource "vault_kubernetes_secret_backend_role" "local_admin" {
|
|
backend = vault_kubernetes_secret_backend.k8s.path
|
|
name = "local-admin"
|
|
allowed_kubernetes_namespaces = ["*"]
|
|
token_default_ttl = 3600
|
|
token_max_ttl = 86400
|
|
kubernetes_role_type = "ClusterRole"
|
|
kubernetes_role_name = "cluster-admin"
|
|
}
|
|
|
|
# =============================================================================
|
|
# Multi-User Namespace Onboarding
|
|
# =============================================================================
|
|
# All resources below are auto-generated from the k8s_users map in Vault KV.
|
|
# Adding a new user requires only a JSON entry in secret/platform → k8s_users.
|
|
|
|
data "vault_kv_secret_v2" "platform" {
|
|
mount = "secret"
|
|
name = "platform"
|
|
depends_on = [helm_release.vault]
|
|
}
|
|
|
|
locals {
|
|
k8s_users = jsondecode(data.vault_kv_secret_v2.platform.data["k8s_users"])
|
|
|
|
# Flatten user -> namespace pairs for namespace-owners
|
|
namespace_owner_namespaces = flatten([
|
|
for name, user in local.k8s_users : [
|
|
for ns in user.namespaces : {
|
|
user_key = name
|
|
namespace = ns
|
|
email = user.email
|
|
}
|
|
] if user.role == "namespace-owner"
|
|
])
|
|
|
|
# Unique namespaces across all namespace-owners
|
|
user_namespaces = toset(flatten([
|
|
for name, user in local.k8s_users : user.namespaces
|
|
if user.role == "namespace-owner"
|
|
]))
|
|
}
|
|
|
|
resource "kubernetes_namespace" "user_namespace" {
|
|
for_each = nonsensitive(local.user_namespaces)
|
|
|
|
metadata {
|
|
name = each.value
|
|
labels = {
|
|
tier = "4-aux"
|
|
"resource-governance/custom-quota" = "true"
|
|
"managed-by" = "vault-user-onboarding"
|
|
}
|
|
}
|
|
lifecycle {
|
|
# KYVERNO_LIFECYCLE_V1: goldilocks-vpa-auto-mode ClusterPolicy stamps this label on every namespace
|
|
ignore_changes = [metadata[0].labels["goldilocks.fairwinds.com/vpa-update-mode"]]
|
|
}
|
|
}
|
|
|
|
resource "vault_policy" "namespace_owner" {
|
|
for_each = nonsensitive({
|
|
for name, user in local.k8s_users : name => user
|
|
if user.role == "namespace-owner"
|
|
})
|
|
|
|
name = "namespace-owner-${each.key}"
|
|
policy = <<-EOT
|
|
# Read/write own secrets
|
|
path "secret/data/${each.key}" {
|
|
capabilities = ["create", "read", "update", "delete", "list"]
|
|
}
|
|
path "secret/data/${each.key}/*" {
|
|
capabilities = ["create", "read", "update", "delete", "list"]
|
|
}
|
|
path "secret/metadata/${each.key}" {
|
|
capabilities = ["list", "read", "delete"]
|
|
}
|
|
path "secret/metadata/${each.key}/*" {
|
|
capabilities = ["list", "read", "delete"]
|
|
}
|
|
%{for ns in each.value.namespaces}
|
|
# Dynamic K8s credentials for ${ns} namespace
|
|
path "kubernetes/creds/${ns}-deployer" {
|
|
capabilities = ["read"]
|
|
}
|
|
%{endfor}
|
|
EOT
|
|
}
|
|
|
|
# =============================================================================
|
|
# Transit Secrets Engine — SOPS State Encryption
|
|
# =============================================================================
|
|
|
|
resource "vault_mount" "transit" {
|
|
path = "transit"
|
|
type = "transit"
|
|
depends_on = [helm_release.vault]
|
|
}
|
|
|
|
# --- SOPS State Encryption — Per-Stack Transit Keys ---
|
|
# Namespace-owners get Transit keys for their stacks only.
|
|
# Admin gets a wildcard policy via vault-admin.
|
|
|
|
resource "vault_transit_secret_backend_key" "sops_user_stack" {
|
|
for_each = nonsensitive(local.user_namespaces)
|
|
|
|
backend = vault_mount.transit.path
|
|
name = "sops-state-${each.value}"
|
|
depends_on = [vault_mount.transit]
|
|
}
|
|
|
|
resource "vault_policy" "sops_user" {
|
|
for_each = nonsensitive({
|
|
for name, user in local.k8s_users : name => user
|
|
if user.role == "namespace-owner"
|
|
})
|
|
|
|
name = "sops-user-${each.key}"
|
|
policy = join("\n", [
|
|
for ns in each.value.namespaces : <<-EOT
|
|
path "transit/encrypt/sops-state-${ns}" { capabilities = ["update"] }
|
|
path "transit/decrypt/sops-state-${ns}" { capabilities = ["update"] }
|
|
path "transit/keys/sops-state-${ns}" { capabilities = ["read"] }
|
|
EOT
|
|
])
|
|
}
|
|
|
|
resource "vault_identity_group" "sops_user" {
|
|
for_each = nonsensitive({
|
|
for name, user in local.k8s_users : name => user
|
|
if user.role == "namespace-owner"
|
|
})
|
|
|
|
name = "sops-${each.key}"
|
|
type = "external"
|
|
policies = [vault_policy.sops_user[each.key].name]
|
|
}
|
|
|
|
resource "vault_identity_group_alias" "sops_user" {
|
|
for_each = nonsensitive({
|
|
for name, user in local.k8s_users : name => user
|
|
if user.role == "namespace-owner"
|
|
})
|
|
|
|
name = "sops-${each.key}"
|
|
mount_accessor = vault_jwt_auth_backend.oidc.accessor
|
|
canonical_id = vault_identity_group.sops_user[each.key].id
|
|
}
|
|
|
|
resource "vault_identity_entity" "namespace_owner" {
|
|
for_each = nonsensitive({
|
|
for name, user in local.k8s_users : name => user
|
|
if user.role == "namespace-owner"
|
|
})
|
|
|
|
name = each.key
|
|
policies = [vault_policy.namespace_owner[each.key].name, vault_policy.sops_user[each.key].name]
|
|
}
|
|
|
|
resource "vault_identity_entity_alias" "namespace_owner" {
|
|
for_each = nonsensitive({
|
|
for name, user in local.k8s_users : name => user
|
|
if user.role == "namespace-owner"
|
|
})
|
|
|
|
name = each.value.email
|
|
mount_accessor = vault_jwt_auth_backend.oidc.accessor
|
|
canonical_id = vault_identity_entity.namespace_owner[each.key].id
|
|
}
|
|
|
|
resource "kubernetes_role" "user_deployer" {
|
|
for_each = nonsensitive(local.user_namespaces)
|
|
|
|
metadata {
|
|
name = "${each.value}-deployer"
|
|
namespace = each.value
|
|
}
|
|
rule {
|
|
api_groups = ["apps"]
|
|
resources = ["deployments"]
|
|
verbs = ["get", "list", "patch", "update"]
|
|
}
|
|
rule {
|
|
api_groups = [""]
|
|
resources = ["pods"]
|
|
verbs = ["get", "list"]
|
|
}
|
|
|
|
depends_on = [kubernetes_namespace.user_namespace]
|
|
}
|
|
|
|
resource "vault_kubernetes_secret_backend_role" "user_deployer" {
|
|
for_each = nonsensitive(local.user_namespaces)
|
|
|
|
backend = vault_kubernetes_secret_backend.k8s.path
|
|
name = "${each.value}-deployer"
|
|
allowed_kubernetes_namespaces = [each.value]
|
|
token_default_ttl = 1800
|
|
token_max_ttl = 3600
|
|
kubernetes_role_type = "Role"
|
|
kubernetes_role_name = kubernetes_role.user_deployer[each.key].metadata[0].name
|
|
}
|