Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
979a413a5b | ||
|
|
2f62351731 | ||
|
|
f81ff0e2fc | ||
|
|
cb1219bf96 | ||
|
|
3c851b16c9 | ||
|
|
e93d4ba86c | ||
|
|
7a1213d504 | ||
|
|
d21ee9630f | ||
|
|
40aa23dfdf | ||
|
|
0666715a5e | ||
|
|
e4caa7e841 | ||
|
|
1ce9f86635 | ||
|
|
cc62f74404 | ||
|
|
d7b96fb29b |
@@ -31,6 +31,17 @@ AUTHENTIK_BOOTSTRAP_TOKEN=
|
|||||||
# openssl rand -base64 24
|
# openssl rand -base64 24
|
||||||
AUTHENTIK_PG_PASSWORD=
|
AUTHENTIK_PG_PASSWORD=
|
||||||
|
|
||||||
|
# ── ZITADEL (Authentik replacement) ─────────────────────────────────────────────
|
||||||
|
# Instance URL, same hostname as the OIDC issuer.
|
||||||
|
ZITADEL_URL="https://zitadel.riotpiao.com"
|
||||||
|
|
||||||
|
# PAT of the 'iam-admin' machine user, created by the chart's setup job on the
|
||||||
|
# first sync. Leave empty until then. Read it with:
|
||||||
|
# kubectl -n iam get secret iam-admin-pat -o jsonpath='{.data.pat}' | base64 -d
|
||||||
|
# scripts/iam/zitadel-provision.py reads this variable first, then the cluster
|
||||||
|
# Secret. The masterkey and the client secrets never live in this file.
|
||||||
|
ZITADEL_BOOTSTRAP_TOKEN=
|
||||||
|
|
||||||
# ── MinIO ──────────────────────────────────────────────────────────────────────
|
# ── MinIO ──────────────────────────────────────────────────────────────────────
|
||||||
MINIO_ROOT_USER=
|
MINIO_ROOT_USER=
|
||||||
MINIO_ROOT_PASSWORD=
|
MINIO_ROOT_PASSWORD=
|
||||||
|
|||||||
+12
@@ -47,6 +47,11 @@ k8s/storage/test/test
|
|||||||
terraform/.terraform/
|
terraform/.terraform/
|
||||||
terraform/*.tfstate
|
terraform/*.tfstate
|
||||||
terraform/*.tfstate.*
|
terraform/*.tfstate.*
|
||||||
|
# The Zitadel root module keeps its own state (see terraform/zitadel/providers.tf).
|
||||||
|
# That state carries generated client secrets and machine keys.
|
||||||
|
terraform/zitadel/*.tfstate
|
||||||
|
terraform/zitadel/*.tfstate.*
|
||||||
|
terraform/zitadel/*.tfvars
|
||||||
terraform.tfvars.local
|
terraform.tfvars.local
|
||||||
skills-lock.json
|
skills-lock.json
|
||||||
secrets-plaintext.yaml
|
secrets-plaintext.yaml
|
||||||
@@ -69,3 +74,10 @@ k8s/**/*-secret.yaml
|
|||||||
|
|
||||||
# IAM provisioning scripts contain credential references — never commit
|
# IAM provisioning scripts contain credential references — never commit
|
||||||
scripts/iam/*.py
|
scripts/iam/*.py
|
||||||
|
# Exception: this one reads every credential from the cluster at run time and
|
||||||
|
# embeds none. Keep it reviewable in git.
|
||||||
|
!scripts/iam/zitadel-provision.py
|
||||||
|
|
||||||
|
# Runtime credential export from the old Authentik provisioning run. Holds live
|
||||||
|
# M2M tokens; never commit it.
|
||||||
|
/authentik-m2m-tokens.env
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ metadata:
|
|||||||
name: comfyui
|
name: comfyui
|
||||||
namespace: comfyui
|
namespace: comfyui
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
# No cert-manager annotation and no tls: block. This host is covered by
|
||||||
|
# the *.riotpiao.com wildcard, which nginx serves as its
|
||||||
|
# default-ssl-certificate (ingress-nginx/riotpiao-com-tls).
|
||||||
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||||
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||||
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
nginx.ingress.kubernetes.io/proxy-body-size: "0"
|
||||||
@@ -16,10 +18,6 @@ metadata:
|
|||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
spec:
|
spec:
|
||||||
ingressClassName: nginx
|
ingressClassName: nginx
|
||||||
tls:
|
|
||||||
- secretName: comfyui-tls
|
|
||||||
hosts:
|
|
||||||
- comfyui.riotpiao.com
|
|
||||||
rules:
|
rules:
|
||||||
- host: comfyui.riotpiao.com
|
- host: comfyui.riotpiao.com
|
||||||
http:
|
http:
|
||||||
|
|||||||
@@ -249,3 +249,26 @@ spec:
|
|||||||
selfHeal: true
|
selfHeal: true
|
||||||
syncOptions:
|
syncOptions:
|
||||||
- CreateNamespace=true
|
- CreateNamespace=true
|
||||||
|
---
|
||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: local-path-provisioner
|
||||||
|
namespace: argocd
|
||||||
|
annotations:
|
||||||
|
argocd.argoproj.io/sync-wave: "1"
|
||||||
|
spec:
|
||||||
|
project: homelab
|
||||||
|
source:
|
||||||
|
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
|
||||||
|
targetRevision: main
|
||||||
|
path: k8s/infra/local-path
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: local-path-storage
|
||||||
|
# No CreateNamespace: the Namespace object (with its privileged PSA label,
|
||||||
|
# which the helper Pods need for their hostPath) is part of the manifest.
|
||||||
|
syncPolicy:
|
||||||
|
automated:
|
||||||
|
prune: true
|
||||||
|
selfHeal: true
|
||||||
|
|||||||
@@ -57,6 +57,55 @@ spec:
|
|||||||
prune: true
|
prune: true
|
||||||
selfHeal: true
|
selfHeal: true
|
||||||
---
|
---
|
||||||
|
# ZITADEL — the Authentik replacement. Coexistence, not cutover: Authentik keeps
|
||||||
|
# serving in the same `iam` namespace until the last consumer moves, then this
|
||||||
|
# becomes the only IdP. Migration plan (outside git):
|
||||||
|
# ~/workplace/homelab-doc/ZITADEL_MIGRATION.html
|
||||||
|
#
|
||||||
|
# Wave 3 ordering: sops-secrets (wave 0) creates the `zitadel-masterkey` Secret,
|
||||||
|
# databases (wave 2) creates the `zitadel-db` CNPG cluster and its
|
||||||
|
# `zitadel-db-app` DSN secret. Both must exist before the init job starts.
|
||||||
|
#
|
||||||
|
# automated sync is deliberately absent for the first sync — same reason as
|
||||||
|
# forgejo. The chart ships `pre-install,pre-upgrade` Helm hooks (zitadel-init,
|
||||||
|
# zitadel-setup), which ArgoCD maps to PreSync and re-runs on every sync, manual
|
||||||
|
# or automated. A human reads the first sync's hook output before any repeat can
|
||||||
|
# run. The init job runs `init zitadel` only (CNPG owns the database and the app
|
||||||
|
# user), and the setup job creates the first org and the `iam-admin` machine key
|
||||||
|
# that Terraform consumes. Read the SECOND-SYNC HAZARD block at the top of
|
||||||
|
# k8s/infra/iam/zitadel-values.yaml before enabling automated sync.
|
||||||
|
apiVersion: argoproj.io/v1alpha1
|
||||||
|
kind: Application
|
||||||
|
metadata:
|
||||||
|
name: zitadel
|
||||||
|
namespace: argocd
|
||||||
|
finalizers:
|
||||||
|
- resources-finalizer.argocd.argoproj.io
|
||||||
|
annotations:
|
||||||
|
argocd.argoproj.io/sync-wave: "3"
|
||||||
|
spec:
|
||||||
|
project: homelab
|
||||||
|
revisionHistoryLimit: 3
|
||||||
|
sources:
|
||||||
|
- repoURL: https://charts.zitadel.com
|
||||||
|
chart: zitadel
|
||||||
|
# Pinned: the chart trails the upstream app release, and the verified
|
||||||
|
# masterkey/passwap facts were read at v4.19.x. A floating tag would
|
||||||
|
# change the app version without a diff anyone reviews.
|
||||||
|
targetRevision: 10.4.0
|
||||||
|
helm:
|
||||||
|
valueFiles:
|
||||||
|
- $values/k8s/infra/iam/zitadel-values.yaml
|
||||||
|
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
|
||||||
|
targetRevision: main
|
||||||
|
ref: values
|
||||||
|
destination:
|
||||||
|
server: https://kubernetes.default.svc
|
||||||
|
namespace: iam
|
||||||
|
syncPolicy:
|
||||||
|
syncOptions:
|
||||||
|
- CreateNamespace=true
|
||||||
|
---
|
||||||
# Raw IAM manifests: key-rotation cronjob + authentik migration job.
|
# Raw IAM manifests: key-rotation cronjob + authentik migration job.
|
||||||
apiVersion: argoproj.io/v1alpha1
|
apiVersion: argoproj.io/v1alpha1
|
||||||
kind: Application
|
kind: Application
|
||||||
|
|||||||
@@ -38,3 +38,4 @@ files:
|
|||||||
- forgejo-registry-token.enc.yaml
|
- forgejo-registry-token.enc.yaml
|
||||||
- pi-sandbox-secrets.enc.yaml
|
- pi-sandbox-secrets.enc.yaml
|
||||||
- etcd-defrag-creds.enc.yaml
|
- etcd-defrag-creds.enc.yaml
|
||||||
|
- zitadel-masterkey.enc.yaml
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: zitadel-masterkey
|
||||||
|
namespace: iam
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
masterkey: ENC[AES256_GCM,data:dGp0wSCUJORP6W5PHLKYaQEqrZsgiAwU1GqcuvuZV+g=,iv:Wwt6E/XXwsoVEQxe4gG1m5oJLOcNUXngftADQvZQepw=,tag:1Vget5gs+t455U9zLavdwg==,type:str]
|
||||||
|
sops:
|
||||||
|
age:
|
||||||
|
- enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBld0tUWHNWem13cDlxRG14
|
||||||
|
QWRlTjA5SzZnS2pBd2M0WWkzQWxKZW9HZWdJCk00QWJJcWZFM1ZTSUlReUlGZG4z
|
||||||
|
RFBSUWRlVUx2NGN2OFAwazBYRTVVNkEKLS0tIHpSSW9IWHhNa0d3SmEydXU4akVj
|
||||||
|
MGJkN2U4WmtLbVFMZm55U1hEZElZMlUKKV9twnC9GC7MSmNLxEBHbPl4RN5Hsd+z
|
||||||
|
NViCKxUoQAB8apn2XVHZcDnyfKNfr1P3aSa3IIHyVY3prgo1OFDpdw==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||||
|
encrypted_regex: ^(data|stringData)$
|
||||||
|
lastmodified: "2026-10-24T08:57:27Z"
|
||||||
|
mac: ENC[AES256_GCM,data:hXDAddVmnlf/yinY2c2Q8yfsjqi6n9cRbEbwykqYScd6mDPfdF7BriTiu9K+CV/IvIT4zcMTZbhBeQ/vTCAh0TZDp1lOVglwtNH/imgagyzPmcCvbCIkZKwF5vwWQIGHcDI6pk4/zVQkqt7zKTf8+Qj0pNh5UrAjVeEFE7cuJkQ=,iv:oIQvROFGbiESmMTthHOJiNM+Gy5BCfM5XtDWbszOPmc=,tag:AaYrJrwXuKD7w0p+TTa7FA==,type:str]
|
||||||
|
version: 3.13.2
|
||||||
@@ -8,6 +8,19 @@
|
|||||||
# depend on them existing first.
|
# depend on them existing first.
|
||||||
installCRDs: true
|
installCRDs: true
|
||||||
|
|
||||||
|
# Point the ACME DNS-01 self-check at the public resolvers directly.
|
||||||
|
#
|
||||||
|
# Without these flags cert-manager resolves through the pod resolver
|
||||||
|
# (CoreDNS at 10.96.0.10). The self-check then never saw the TXT record and
|
||||||
|
# reported "Waiting for DNS-01 challenge propagation" forever. The wildcard
|
||||||
|
# certificate renewal stalled on 2026-09-23 and the cert expired on
|
||||||
|
# 2026-10-23, which broke every Cloudflare Tunnel origin with
|
||||||
|
# "x509: certificate has expired".
|
||||||
|
#
|
||||||
|
extraArgs:
|
||||||
|
- --dns01-recursive-nameservers=1.1.1.1:53,8.8.8.8:53
|
||||||
|
- --dns01-recursive-nameservers-only
|
||||||
|
|
||||||
# Tolerate the control-plane taint (talos-cp-1 is the only schedulable node).
|
# Tolerate the control-plane taint (talos-cp-1 is the only schedulable node).
|
||||||
tolerations:
|
tolerations:
|
||||||
- key: node-role.kubernetes.io/control-plane
|
- key: node-role.kubernetes.io/control-plane
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
# k8s/bootstrap/ingress/riotpiao-com-cert.yaml
|
# k8s/bootstrap/ingress/riotpiao-com-cert.yaml
|
||||||
# Public wildcard cert for *.riotpiao.com, signed by Let's Encrypt (DNS-01 via
|
# Public wildcard cert for *.riotpiao.com, signed by Let's Encrypt (DNS-01 via
|
||||||
# Cloudflare, see ../cert-manager/letsencrypt-issuer.yaml). Distinct from
|
# Cloudflare, see ../cert-manager/letsencrypt-issuer.yaml).
|
||||||
# homelab-tls (self-signed homelab-ca, internal *.riotpiao.com) —
|
|
||||||
# reference riotpiao-com-tls explicitly on ingresses that expose riotpiao.com
|
|
||||||
# hosts to the internet; it is not nginx's default-ssl-certificate.
|
|
||||||
#
|
#
|
||||||
# issuerRef starts on letsencrypt-staging. Switch to letsencrypt-prod once the
|
# nginx serves this secret as its default-ssl-certificate
|
||||||
# staging cert issues cleanly, then delete this Certificate's Secret to force
|
# (../ingress/nginx-values.yaml), so every Ingress that omits a tls: block uses
|
||||||
# a re-issue against prod (staging certs aren't browser-trusted).
|
# it. Do not add per-host tls: blocks for *.riotpiao.com hosts: they create a
|
||||||
|
# second, single-host certificate and split the wildcard coverage.
|
||||||
|
#
|
||||||
|
# Renewal is automatic and does not drop traffic:
|
||||||
|
# - cert-manager starts a new order 30 days before expiry (renewBefore).
|
||||||
|
# - cert-manager writes the renewed pair into the same secret.
|
||||||
|
# - ingress-nginx watches the secret and hot-reloads. No pod restart.
|
||||||
|
# - The old pair stays valid until the new one is written, so no gap exists.
|
||||||
|
#
|
||||||
|
# The DNS-01 self-check must reach the public resolvers directly. Without the
|
||||||
|
# --dns01-recursive-nameservers flags in ../../cert-manager/cert-manager-values.yaml
|
||||||
|
# the self-check never sees the TXT record and renewal stalls until expiry.
|
||||||
apiVersion: cert-manager.io/v1
|
apiVersion: cert-manager.io/v1
|
||||||
kind: Certificate
|
kind: Certificate
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ ingress:
|
|||||||
enabled: true
|
enabled: true
|
||||||
className: nginx
|
className: nginx
|
||||||
annotations:
|
annotations:
|
||||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
# No cert-manager annotation. forgejo.riotpiao.com is covered by the
|
||||||
|
# *.riotpiao.com wildcard, served as nginx's default-ssl-certificate.
|
||||||
|
#
|
||||||
# This chart Ingress and the hand-written one in
|
# This chart Ingress and the hand-written one in
|
||||||
# k8s/bootstrap/ingress/ingress.yaml both claim forgejo.riotpiao.com.
|
# k8s/bootstrap/ingress/ingress.yaml both claim forgejo.riotpiao.com.
|
||||||
# ingress-nginx breaks the tie by oldest creationTimestamp, and the chart's
|
# ingress-nginx breaks the tie by oldest creationTimestamp, and the chart's
|
||||||
@@ -125,10 +127,9 @@ ingress:
|
|||||||
paths:
|
paths:
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
tls:
|
# tls: intentionally omitted. The chart would create a second, single-host
|
||||||
- secretName: forgejo-tls
|
# certificate (forgejo-tls) that expired on 2026-10-23 and competed with the
|
||||||
hosts:
|
# wildcard. The wildcard covers this host.
|
||||||
- forgejo.riotpiao.com
|
|
||||||
|
|
||||||
# Resources
|
# Resources
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ kind: Kustomization
|
|||||||
resources:
|
resources:
|
||||||
- namespaces.yaml
|
- namespaces.yaml
|
||||||
- authentik-db.yaml
|
- authentik-db.yaml
|
||||||
|
- zitadel-db.yaml
|
||||||
- temporal-db.yaml
|
- temporal-db.yaml
|
||||||
- paperless-db.yaml
|
- paperless-db.yaml
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Dedicated CNPG Postgres for ZITADEL (GitOps, wave 2 — before the zitadel
|
||||||
|
# chart at wave 3). Same shape as authentik-db.yaml: CNPG generates the secret
|
||||||
|
# `zitadel-db-app` and the service `zitadel-db-rw` in ns iam, and
|
||||||
|
# k8s/infra/iam/zitadel-values.yaml reads the DSN from that secret locally (no
|
||||||
|
# cross-namespace secret copy).
|
||||||
|
#
|
||||||
|
# ZITADEL requires PostgreSQL 14+; the repo convention image is 16.2.
|
||||||
|
apiVersion: postgresql.cnpg.io/v1
|
||||||
|
kind: Cluster
|
||||||
|
metadata:
|
||||||
|
name: zitadel-db
|
||||||
|
namespace: iam
|
||||||
|
annotations:
|
||||||
|
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
|
||||||
|
spec:
|
||||||
|
instances: 3
|
||||||
|
imageName: ghcr.io/cloudnative-pg/postgresql:16.2
|
||||||
|
bootstrap:
|
||||||
|
initdb:
|
||||||
|
database: zitadel
|
||||||
|
owner: app
|
||||||
|
encoding: UTF8
|
||||||
|
localeCollate: C
|
||||||
|
localeCType: C
|
||||||
|
enableSuperuserAccess: false
|
||||||
|
resources:
|
||||||
|
requests: { memory: "512Mi", cpu: "250m" }
|
||||||
|
limits: { memory: "2Gi", cpu: "1" }
|
||||||
|
storage:
|
||||||
|
size: 10Gi
|
||||||
|
storageClass: longhorn-cnpg
|
||||||
|
monitoring:
|
||||||
|
enablePodMonitor: true
|
||||||
|
affinity:
|
||||||
|
# preferred + tolerations: HA across nodes without deadlocking a partly-
|
||||||
|
# schedulable 3-CP (same as authentik-db and forgejo-db).
|
||||||
|
podAntiAffinityType: preferred
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
tolerations:
|
||||||
|
- key: node-role.kubernetes.io/control-plane
|
||||||
|
operator: Exists
|
||||||
|
effect: NoSchedule
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# k8s/infra/iam/zitadel-values.yaml
|
||||||
|
# ZITADEL — Authentik replacement. Coexistence is deliberate: Authentik keeps
|
||||||
|
# running, read-only, in the same `iam` namespace until the last consumer moves.
|
||||||
|
# Migration plan: ~/workplace/homelab-doc/ZITADEL_MIGRATION.html (outside git).
|
||||||
|
#
|
||||||
|
# Chart: zitadel/zitadel 10.4.0, app v4.19.2. The plan cites v4.19.4; the chart
|
||||||
|
# has not published it yet. Pin the chart, never `*`, until the v4.19.4 chart
|
||||||
|
# exists — a silent app bump invalidates the verified masterkey/passwap facts.
|
||||||
|
#
|
||||||
|
# URL: https://zitadel.riotpiao.com. TLS terminates at ingress-nginx, which
|
||||||
|
# serves the wildcard riotpiao-com-tls as its default-ssl-certificate, so
|
||||||
|
# ExternalSecure=true and TLS.Enabled=false (no per-rule tls: block needed).
|
||||||
|
#
|
||||||
|
# Database: CNPG cluster zitadel-db (wave 2). The DSN comes from CNPG's
|
||||||
|
# generated `zitadel-db-app` secret. When ZITADEL_DATABASE_POSTGRES_DSN is set,
|
||||||
|
# ZITADEL ignores the discrete Database.Postgres.* fields, so neither a
|
||||||
|
# password nor a host is written into this file.
|
||||||
|
#
|
||||||
|
# Masterkey: Secret `zitadel-masterkey` (k8s/argocd/secrets/
|
||||||
|
# zitadel-masterkey.enc.yaml), key `masterkey`, exactly 32 bytes. It wraps the
|
||||||
|
# rows in system.encryption_keys, which encrypt every client secret and private
|
||||||
|
# key. Losing it makes those values unreadable: keep the offline sealed copy.
|
||||||
|
#
|
||||||
|
# Ingress uses the chart's own objects (controller: nginx adds the gRPC backend
|
||||||
|
# annotation ZITADEL needs for its HTTP/2 API). Do not hand-write a second
|
||||||
|
# Ingress for this host.
|
||||||
|
|
||||||
|
zitadel:
|
||||||
|
configmapConfig:
|
||||||
|
ExternalSecure: true
|
||||||
|
ExternalDomain: zitadel.riotpiao.com
|
||||||
|
TLS:
|
||||||
|
Enabled: false
|
||||||
|
|
||||||
|
# Rendered into a Secret by the chart; read by pods and init jobs alike.
|
||||||
|
masterkeySecretName: zitadel-masterkey
|
||||||
|
|
||||||
|
resources:
|
||||||
|
requests: { memory: "512Mi", cpu: "250m" }
|
||||||
|
limits: { memory: "2Gi", cpu: "1" }
|
||||||
|
|
||||||
|
# FirstInstance defaults stay for the FIRST sync: the chart creates org
|
||||||
|
# `zitadel` plus the `iam-admin` machine user and its machine-key Secret.
|
||||||
|
# Terraform consumes that key to declare projects, roles, OIDC apps and the
|
||||||
|
# groups action.
|
||||||
|
#
|
||||||
|
# SECOND-SYNC HAZARD — read before enabling automated sync.
|
||||||
|
# The chart marks eight objects as `pre-install,pre-upgrade` hooks (two
|
||||||
|
# ServiceAccounts, two ConfigMaps, Role, RoleBinding, zitadel-init,
|
||||||
|
# zitadel-setup), and one cleanup Job as `post-delete`. ArgoCD maps the first
|
||||||
|
# eight to PreSync with `before-hook-creation`: it deletes and re-creates them
|
||||||
|
# on EVERY sync, not only on a chart change.
|
||||||
|
# - zitadel-init (`init zitadel`) — schema step
|
||||||
|
# - zitadel-setup (`setup`) — first instance + iam-admin machine key
|
||||||
|
# On an initialized instance the setup step can fail, which aborts the sync
|
||||||
|
# before the Deployment is applied. If it succeeds instead, its
|
||||||
|
# machinekeyWriter container re-applies the iam-admin key Secret that
|
||||||
|
# Terraform reads, which silently rotates a credential in use.
|
||||||
|
# After the first sync reports healthy:
|
||||||
|
# 1. set configmapConfig.FirstInstance.Skip: true here — permanently: the
|
||||||
|
# setup steps are schema-only once the instance exists, and Skip alone
|
||||||
|
# stops the machinekeyWriter rotation
|
||||||
|
# 2. set initJob.enabled: false below
|
||||||
|
# 3. commit, sync once, then enable automated+selfHeal on the Application
|
||||||
|
# For a later schema upgrade, re-enable initJob.enabled only — never set
|
||||||
|
# FirstInstance.Skip back to false.
|
||||||
|
# ArgoCD never prunes a hook Job, so a Job that failed before step 2 stays and
|
||||||
|
# keeps ZitadelSetupJobFailed firing until a human deletes it.
|
||||||
|
|
||||||
|
# DSN mode (chart renders this through the zitadel.dbEnv helper). CNPG's `uri`
|
||||||
|
# key is a postgresql:// URL, which ZITADEL accepts as a DSN.
|
||||||
|
env:
|
||||||
|
- name: ZITADEL_DATABASE_POSTGRES_DSN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: zitadel-db-app
|
||||||
|
key: uri
|
||||||
|
|
||||||
|
# Bundled Bitnami Postgres is disabled: the CNPG cluster above is the system of
|
||||||
|
# record. Enabling it would create a second, unmanaged database.
|
||||||
|
postgresql:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# CNPG already created the database, the `app` user and its grants, so the init
|
||||||
|
# job runs only the ZITADEL-internal step. Running the full default command
|
||||||
|
# would try to create a database and a superuser it does not own.
|
||||||
|
initJob:
|
||||||
|
command: "zitadel"
|
||||||
|
activeDeadlineSeconds: 600
|
||||||
|
|
||||||
|
# Two API replicas: the read path survives one node drain, and both share the
|
||||||
|
# same masterkey and database. The login UI stays at one replica — a session
|
||||||
|
# cookie is not shared, so a second pod buys nothing.
|
||||||
|
replicaCount: 2
|
||||||
|
revisionHistoryLimit: 3
|
||||||
|
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
controller: nginx
|
||||||
|
|
||||||
|
login:
|
||||||
|
enabled: true
|
||||||
|
replicaCount: 1
|
||||||
|
ingress:
|
||||||
|
enabled: true
|
||||||
|
className: nginx
|
||||||
|
|
||||||
|
# Chart-owned ServiceMonitor. The cluster's Prometheus runs with
|
||||||
|
# serviceMonitorSelectorNilUsesHelmValues=false, so the release label is not
|
||||||
|
# required for discovery. Endpoint is /debug/metrics on port http2-server.
|
||||||
|
metrics:
|
||||||
|
enabled: true
|
||||||
|
serviceMonitor:
|
||||||
|
enabled: true
|
||||||
|
scrapeInterval: 30s
|
||||||
|
scrapeTimeout: 10s
|
||||||
|
|
||||||
|
# The 3-CP / 2-worker cluster mixes control-plane taints with worker capacity.
|
||||||
|
# Without the toleration a rolling upgrade can leave both replicas Pending.
|
||||||
|
tolerations:
|
||||||
|
- key: node-role.kubernetes.io/control-plane
|
||||||
|
operator: Exists
|
||||||
|
effect: NoSchedule
|
||||||
|
|
||||||
|
affinity:
|
||||||
|
podAntiAffinity:
|
||||||
|
preferredDuringSchedulingIgnoredDuringExecution:
|
||||||
|
- weight: 100
|
||||||
|
podAffinityTerm:
|
||||||
|
topologyKey: kubernetes.io/hostname
|
||||||
|
labelSelector:
|
||||||
|
matchLabels:
|
||||||
|
app.kubernetes.io/name: zitadel
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
# No commonLabels on purpose: this is a vendored third-party manifest and
|
||||||
|
# kustomize would also rewrite the provisioner Deployment's selector, which
|
||||||
|
# is immutable on re-apply.
|
||||||
|
resources:
|
||||||
|
- local-path-storage.yaml
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# Vendored from https://github.com/rancher/local-path-provisioner
|
||||||
|
# deploy/local-path-storage.yaml @ v0.0.37 (release 2026-08-05)
|
||||||
|
#
|
||||||
|
# Local modifications (the only three; re-apply them on any re-vendor):
|
||||||
|
# 1. the namespace carries pod-security.kubernetes.io/enforce=privileged, because the
|
||||||
|
# provisioner's helper Pods mount a hostPath and this cluster defaults to the
|
||||||
|
# baseline policy, which forbids that;
|
||||||
|
# 2. config.json nodePathMap is /var/lib/local-path-provisioner — Talos mounts the root
|
||||||
|
# filesystem read-only, so upstream's /opt path cannot be created;
|
||||||
|
# 3. the helper Pod's busybox image is pinned to a tag instead of a floating name.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: local-path-storage
|
||||||
|
labels:
|
||||||
|
pod-security.kubernetes.io/enforce: privileged
|
||||||
|
pod-security.kubernetes.io/audit: privileged
|
||||||
|
pod-security.kubernetes.io/warn: privileged
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: local-path-provisioner-service-account
|
||||||
|
namespace: local-path-storage
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: local-path-provisioner-role
|
||||||
|
namespace: local-path-storage
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["pods"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "patch", "update", "delete"]
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRole
|
||||||
|
metadata:
|
||||||
|
name: local-path-provisioner-role
|
||||||
|
rules:
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["nodes", "persistentvolumeclaims", "configmaps", "pods", "pods/log"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["persistentvolumes"]
|
||||||
|
verbs: ["get", "list", "watch", "create", "patch", "update", "delete"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["events"]
|
||||||
|
verbs: ["create", "patch"]
|
||||||
|
- apiGroups: ["storage.k8s.io"]
|
||||||
|
resources: ["storageclasses"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: local-path-provisioner-bind
|
||||||
|
namespace: local-path-storage
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: local-path-provisioner-role
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: local-path-provisioner-service-account
|
||||||
|
namespace: local-path-storage
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: ClusterRoleBinding
|
||||||
|
metadata:
|
||||||
|
name: local-path-provisioner-bind
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: ClusterRole
|
||||||
|
name: local-path-provisioner-role
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: local-path-provisioner-service-account
|
||||||
|
namespace: local-path-storage
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: local-path-provisioner
|
||||||
|
namespace: local-path-storage
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: local-path-provisioner
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: local-path-provisioner
|
||||||
|
spec:
|
||||||
|
serviceAccountName: local-path-provisioner-service-account
|
||||||
|
containers:
|
||||||
|
- name: local-path-provisioner
|
||||||
|
image: docker.io/rancher/local-path-provisioner:v0.0.37
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
command:
|
||||||
|
- local-path-provisioner
|
||||||
|
- --debug
|
||||||
|
- start
|
||||||
|
- --config
|
||||||
|
- /etc/config/config.json
|
||||||
|
ports:
|
||||||
|
- name: health
|
||||||
|
containerPort: 8080
|
||||||
|
protocol: TCP
|
||||||
|
startupProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: health
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
failureThreshold: 12
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /health
|
||||||
|
port: health
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /ready
|
||||||
|
port: health
|
||||||
|
initialDelaySeconds: 5
|
||||||
|
periodSeconds: 5
|
||||||
|
volumeMounts:
|
||||||
|
- name: config-volume
|
||||||
|
mountPath: /etc/config/
|
||||||
|
env:
|
||||||
|
- name: POD_NAMESPACE
|
||||||
|
valueFrom:
|
||||||
|
fieldRef:
|
||||||
|
fieldPath: metadata.namespace
|
||||||
|
- name: CONFIG_MOUNT_PATH
|
||||||
|
value: /etc/config/
|
||||||
|
- name: HEALTH_PORT
|
||||||
|
value: "8080"
|
||||||
|
volumes:
|
||||||
|
- name: config-volume
|
||||||
|
configMap:
|
||||||
|
name: local-path-config
|
||||||
|
|
||||||
|
---
|
||||||
|
apiVersion: storage.k8s.io/v1
|
||||||
|
kind: StorageClass
|
||||||
|
metadata:
|
||||||
|
name: local-path
|
||||||
|
provisioner: rancher.io/local-path
|
||||||
|
volumeBindingMode: WaitForFirstConsumer
|
||||||
|
reclaimPolicy: Delete
|
||||||
|
|
||||||
|
---
|
||||||
|
kind: ConfigMap
|
||||||
|
apiVersion: v1
|
||||||
|
metadata:
|
||||||
|
name: local-path-config
|
||||||
|
namespace: local-path-storage
|
||||||
|
data:
|
||||||
|
config.json: |-
|
||||||
|
{
|
||||||
|
"nodePathMap":[
|
||||||
|
{
|
||||||
|
"node":"DEFAULT_PATH_FOR_NON_LISTED_NODES",
|
||||||
|
"paths":["/var/lib/local-path-provisioner"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
setup: |-
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
mkdir -m 0777 -p "$VOL_DIR"
|
||||||
|
teardown: |-
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
rm -rf "$VOL_DIR"
|
||||||
|
helperPod.yaml: |-
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: helper-pod
|
||||||
|
spec:
|
||||||
|
priorityClassName: system-node-critical
|
||||||
|
tolerations:
|
||||||
|
- key: node.kubernetes.io/disk-pressure
|
||||||
|
operator: Exists
|
||||||
|
effect: NoSchedule
|
||||||
|
containers:
|
||||||
|
- name: helper-pod
|
||||||
|
image: docker.io/library/busybox:1.36
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
@@ -41,13 +41,24 @@ spec:
|
|||||||
# - /var/lib/longhorn-disk1 (sdb1, 600GB)
|
# - /var/lib/longhorn-disk1 (sdb1, 600GB)
|
||||||
# - /var/lib/longhorn-disk2 (sdc1, 500GB)
|
# - /var/lib/longhorn-disk2 (sdc1, 500GB)
|
||||||
# - /var/lib/longhorn-disk3 (sdd1, 160GB)
|
# - /var/lib/longhorn-disk3 (sdd1, 160GB)
|
||||||
# - /var/lib/longhorn-disk4 (sde1, 2TB)
|
#
|
||||||
|
# /var/lib/longhorn-disk4 (sde1, the 2TB PERC virtual disk) was RETIRED
|
||||||
|
# on 2026-10-12 and must not be re-added. The device returned SCSI I/O
|
||||||
|
# errors at ~7,000/minute, the XFS filesystem on it hung, and every
|
||||||
|
# process that touched it blocked in unkillable D state. Those unkillable
|
||||||
|
# processes held Longhorn's per-node rebuild slots forever, so no new
|
||||||
|
# replica could start on talos-cp-2 and every new volume there failed to
|
||||||
|
# attach. It is removed from the Terraform disk list too, so a re-render
|
||||||
|
# does not put it back.
|
||||||
|
|
||||||
echo "Checking talos-cp-2..."
|
echo "Checking talos-cp-2..."
|
||||||
CURRENT_DISKS=$(kubectl -n longhorn-system get nodes.longhorn.io talos-cp-2 -o json | jq -r '.spec.disks | keys | length')
|
CURRENT_DISKS=$(kubectl -n longhorn-system get nodes.longhorn.io talos-cp-2 -o json | jq -r '.spec.disks | keys | length')
|
||||||
echo " Current disk count: $CURRENT_DISKS"
|
echo " Current disk count: $CURRENT_DISKS"
|
||||||
|
|
||||||
if [ "$CURRENT_DISKS" -lt 5 ]; then
|
# 4 = default-disk-080400000000 + disk1..disk3. It was 5 while disk4
|
||||||
|
# existed; leaving the old threshold would re-apply the patch on every
|
||||||
|
# run once disk4 is gone.
|
||||||
|
if [ "$CURRENT_DISKS" -lt 4 ]; then
|
||||||
echo " Adding extra disks to talos-cp-2..."
|
echo " Adding extra disks to talos-cp-2..."
|
||||||
kubectl -n longhorn-system patch nodes.longhorn.io talos-cp-2 --type merge -p '{
|
kubectl -n longhorn-system patch nodes.longhorn.io talos-cp-2 --type merge -p '{
|
||||||
"spec": {
|
"spec": {
|
||||||
@@ -75,14 +86,6 @@ spec:
|
|||||||
"path": "/var/lib/longhorn-disk3",
|
"path": "/var/lib/longhorn-disk3",
|
||||||
"storageReserved": 5368709120,
|
"storageReserved": 5368709120,
|
||||||
"tags": []
|
"tags": []
|
||||||
},
|
|
||||||
"disk4": {
|
|
||||||
"allowScheduling": true,
|
|
||||||
"diskType": "filesystem",
|
|
||||||
"evictionRequested": false,
|
|
||||||
"path": "/var/lib/longhorn-disk4",
|
|
||||||
"storageReserved": 21474836480,
|
|
||||||
"tags": []
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# ZITADEL alert parity with the Authentik rules it will replace.
|
||||||
|
#
|
||||||
|
# Only two signals are ported at this point. The metrics endpoint
|
||||||
|
# (/debug/metrics, scraped by the chart's own ServiceMonitor in ns iam) exposes
|
||||||
|
# no request or error counters that this cluster has verified, so no error-rate
|
||||||
|
# rule is written here. Writing one against a guessed series name would produce
|
||||||
|
# a permanently-firing or permanently-silent alert — both worse than none.
|
||||||
|
# AuthentikOutpostDown is deliberately not ported: it monitors an outpost that
|
||||||
|
# carries zero providers and is deleted with the migration, not migrated.
|
||||||
|
apiVersion: monitoring.coreos.com/v1
|
||||||
|
kind: PrometheusRule
|
||||||
|
metadata:
|
||||||
|
name: zitadel-rules
|
||||||
|
namespace: iam
|
||||||
|
spec:
|
||||||
|
groups:
|
||||||
|
- name: zitadel.rules
|
||||||
|
interval: 30s
|
||||||
|
rules:
|
||||||
|
- alert: ZitadelDown
|
||||||
|
# absent() is required: equality alone cannot fire when the series is
|
||||||
|
# gone (ServiceMonitor deleted, Service selector changed, pods scaled
|
||||||
|
# to zero). Those are exactly the outages a target-down rule exists for.
|
||||||
|
expr: absent(up{job="zitadel"}) or up{job="zitadel"} == 0
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "ZITADEL API target is down"
|
||||||
|
description: "Prometheus cannot scrape the ZITADEL API for 10 minutes. Token issuance and every OIDC login depend on it. Check the zitadel pods and the zitadel-db cluster in namespace iam. Value: {{ $value }}"
|
||||||
|
|
||||||
|
- alert: ZitadelLoginUIDown
|
||||||
|
expr: absent(kube_deployment_status_replicas_available{namespace="iam",deployment="zitadel-login"}) or kube_deployment_status_replicas_available{namespace="iam",deployment="zitadel-login"} == 0
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "ZITADEL login UI has no available replica"
|
||||||
|
description: "The login UI serves /ui/v2/login. With zero available replicas a browser login completes at the IdP but cannot render. Value: {{ $value }}"
|
||||||
|
|
||||||
|
- alert: ZitadelRestarting
|
||||||
|
# pod!~"zitadel-db-.*" matters: the CNPG database pods share the
|
||||||
|
# `zitadel` name prefix, and a database restart must not page as an
|
||||||
|
# IdP crash loop.
|
||||||
|
expr: absent(kube_pod_container_status_restarts_total{namespace="iam",pod=~"zitadel.*",pod!~"zitadel-db-.*"}) or increase(kube_pod_container_status_restarts_total{namespace="iam",pod=~"zitadel.*",pod!~"zitadel-db-.*"}[1h]) > 3
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "ZITADEL pod restarts more than 3 times per hour"
|
||||||
|
description: "A crash loop on the IdP is a cluster-wide login outage. Check the pod logs and the masterkey/database secret references. Value: {{ $value }}"
|
||||||
|
|
||||||
|
# The chart's zitadel-init and zitadel-setup Jobs are PreSync hooks with
|
||||||
|
# restartPolicy: Never, so their restart counter stays 0. Without this
|
||||||
|
# rule a failed schema init or first-instance setup reaches nobody but
|
||||||
|
# the person who runs `kubectl -n iam get job`.
|
||||||
|
# No absent() guard here on purpose: the chart ships only
|
||||||
|
# `before-hook-creation`, so a successful hook Job is replaced at the
|
||||||
|
# next PreSync. Once the jobs are retired, absent() would fire on a
|
||||||
|
# healthy cluster.
|
||||||
|
- alert: ZitadelSetupJobFailed
|
||||||
|
expr: kube_job_status_failed{namespace="iam",job_name=~"zitadel-(init|setup).*"} > 0
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "ZITADEL init/setup job failed"
|
||||||
|
description: "A PreSync job failed, so ArgoCD aborted the sync before the Deployment was applied. Read the job pod logs, then delete the Job object to let the next sync retry. Value: {{ $value }}"
|
||||||
@@ -8,6 +8,7 @@ resources:
|
|||||||
- alerts/ingress-alerts.yaml
|
- alerts/ingress-alerts.yaml
|
||||||
- alerts/svc-argocd-rules.yaml
|
- alerts/svc-argocd-rules.yaml
|
||||||
- alerts/svc-authentik-rules.yaml
|
- alerts/svc-authentik-rules.yaml
|
||||||
|
- alerts/svc-zitadel-rules.yaml
|
||||||
- alerts/svc-forgejo-rules.yaml
|
- alerts/svc-forgejo-rules.yaml
|
||||||
- alerts/svc-grafana-rules.yaml
|
- alerts/svc-grafana-rules.yaml
|
||||||
- alerts/svc-minio-rules.yaml
|
- alerts/svc-minio-rules.yaml
|
||||||
|
|||||||
Executable
+488
@@ -0,0 +1,488 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Provision ZITADEL end to end: machine key, Terraform, SOPS secrets, users.
|
||||||
|
|
||||||
|
One command replaces the manual sequence the Authentik script
|
||||||
|
(scripts/iam/provision-rbac.py) used to do in a single pass. Phases run in a
|
||||||
|
fixed order and every phase is safe to re-run:
|
||||||
|
|
||||||
|
keys extract the iam-admin machine key and PAT from namespace iam
|
||||||
|
apply terraform apply terraform/zitadel (project, roles, apps, machines)
|
||||||
|
secrets write generated client ids/secrets back into the SOPS files
|
||||||
|
users import one human user, converting the Django password hash
|
||||||
|
verify check discovery, client count and the groups-claim gate
|
||||||
|
|
||||||
|
`all` runs keys, apply, secrets, verify. It never runs users: the password hash
|
||||||
|
must be supplied by a human, one user at a time.
|
||||||
|
|
||||||
|
Nothing here talks to Authentik. Authentik keeps serving until a consumer is
|
||||||
|
moved deliberately.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
scripts/iam/zitadel-provision.py keys
|
||||||
|
scripts/iam/zitadel-provision.py apply --org-id <id>
|
||||||
|
scripts/iam/zitadel-provision.py secrets --dry-run
|
||||||
|
scripts/iam/zitadel-provision.py secrets --apply-config
|
||||||
|
scripts/iam/zitadel-provision.py users --django-hash '<pbkdf2_sha256$...>'
|
||||||
|
scripts/iam/zitadel-provision.py verify
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import stat
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO = Path(__file__).resolve().parents[2]
|
||||||
|
TF_DIR = REPO / "terraform" / "zitadel"
|
||||||
|
SOPS_DIR = REPO / "k8s" / "argocd" / "secrets"
|
||||||
|
SOPS_CONFIG = REPO / ".sops.yaml"
|
||||||
|
CONF_DIR = Path(os.environ.get("ZITADEL_CONF_DIR", Path.home() / ".config" / "zitadel"))
|
||||||
|
NAMESPACE = os.environ.get("ZITADEL_NAMESPACE", "iam")
|
||||||
|
BASE_URL = os.environ.get("ZITADEL_BASE_URL", "https://zitadel.riotpiao.com")
|
||||||
|
|
||||||
|
_TEMP_FILES: set[str] = set()
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_temps(*_a) -> None:
|
||||||
|
for path in list(_TEMP_FILES):
|
||||||
|
try:
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
_TEMP_FILES.discard(path)
|
||||||
|
|
||||||
|
|
||||||
|
for _sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
||||||
|
signal.signal(_sig, lambda s, f: (_cleanup_temps(), sys.exit(130)))
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg: str) -> None:
|
||||||
|
_cleanup_temps()
|
||||||
|
print(f"FATAL: {msg}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str], **kw) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(cmd, check=False, text=True, capture_output=True, **kw)
|
||||||
|
|
||||||
|
|
||||||
|
def need(binary: str) -> None:
|
||||||
|
if subprocess.run(["sh", "-c", f"command -v {binary}"], capture_output=True).returncode != 0:
|
||||||
|
die(f"{binary} is not on PATH")
|
||||||
|
|
||||||
|
|
||||||
|
def write_private(path: Path, data: bytes) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
||||||
|
try:
|
||||||
|
os.fchmod(fd, 0o600) # open() mode applies at creation only
|
||||||
|
with os.fdopen(fd, "wb") as fh:
|
||||||
|
fh.write(data)
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def secret_value(name: str, key: str) -> str:
|
||||||
|
cp = run(["kubectl", "-n", NAMESPACE, "get", "secret", name, "-o", f"jsonpath={{{key}}}"])
|
||||||
|
if cp.returncode != 0:
|
||||||
|
die(f"cannot read secret {NAMESPACE}/{name}: {cp.stderr.strip()}")
|
||||||
|
raw = cp.stdout.strip()
|
||||||
|
if not raw:
|
||||||
|
die(f"secret {NAMESPACE}/{name} has no key {key}")
|
||||||
|
return base64.b64decode(raw).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def load_env_file(path: Path = REPO / ".env") -> None:
|
||||||
|
"""Load KEY=VALUE lines from .env without overriding the real environment.
|
||||||
|
|
||||||
|
The file is git-ignored and holds the bootstrap token a human pastes in.
|
||||||
|
Sourcing it in the shell would work too, but then every phase depends on the
|
||||||
|
caller remembering to do it.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for line in path.read_text().splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip('"').strip("'")
|
||||||
|
if key and key not in os.environ:
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_token() -> str | None:
|
||||||
|
"""Resolve the iam-admin PAT: environment, then cached file, then cluster.
|
||||||
|
|
||||||
|
Order matters. ZITADEL_BOOTSTRAP_TOKEN in .env is filled by a human after the
|
||||||
|
first sync and wins. The cached file under ~/.config/zitadel keeps a run
|
||||||
|
working when the API server is unreachable. The cluster Secret is the last
|
||||||
|
resort and the only source that always reflects a rotation.
|
||||||
|
"""
|
||||||
|
token = os.environ.get("ZITADEL_BOOTSTRAP_TOKEN", "").strip()
|
||||||
|
if token:
|
||||||
|
return token
|
||||||
|
cached = CONF_DIR / "iam-admin.pat"
|
||||||
|
if cached.exists():
|
||||||
|
return cached.read_text().strip()
|
||||||
|
try:
|
||||||
|
return secret_value("iam-admin-pat", ".data.pat").strip()
|
||||||
|
except SystemExit:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def curl_json(method: str, url: str, token: str | None = None, body: dict | None = None,
|
||||||
|
timeout: int = 20) -> tuple[int, str]:
|
||||||
|
"""Call the API without putting a secret in the process argv.
|
||||||
|
|
||||||
|
`curl --config -` reads the options from stdin, so neither the bearer token
|
||||||
|
nor the password hash of a user import shows up in `ps`.
|
||||||
|
"""
|
||||||
|
def esc(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||||
|
|
||||||
|
cfg = [f'url = "{esc(url)}"', f'request = "{method}"', "silent", "show-error",
|
||||||
|
f"max-time = {timeout}", 'write-out = "\\n%{http_code}"',
|
||||||
|
'header = "Content-Type: application/json"']
|
||||||
|
if token:
|
||||||
|
cfg.append(f'header = "Authorization: Bearer {esc(token)}"')
|
||||||
|
if body is not None:
|
||||||
|
cfg.append(f'data = "{esc(json.dumps(body))}"')
|
||||||
|
cp = run(["curl", "--config", "-"], input="\n".join(cfg) + "\n")
|
||||||
|
if cp.returncode != 0:
|
||||||
|
die(f"{method} {url} failed: {cp.stderr.strip()}")
|
||||||
|
payload, _, code = cp.stdout.rpartition("\n")
|
||||||
|
return int(code.strip() or 0), payload
|
||||||
|
|
||||||
|
|
||||||
|
# ── phase: keys ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def phase_keys(_args: argparse.Namespace) -> int:
|
||||||
|
write_private(CONF_DIR / "iam-admin.json", secret_value("iam-admin", ".data.iam-admin\\.json").encode())
|
||||||
|
write_private(CONF_DIR / "iam-admin.pat", secret_value("iam-admin-pat", ".data.pat").strip().encode())
|
||||||
|
print(f"machine key -> {CONF_DIR / 'iam-admin.json'} (0600)")
|
||||||
|
print(f"PAT -> {CONF_DIR / 'iam-admin.pat'} (0600)")
|
||||||
|
print("both files stay outside the repo; never commit them")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── phase: apply ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def phase_apply(args: argparse.Namespace) -> int:
|
||||||
|
need("terraform")
|
||||||
|
if not args.org_id:
|
||||||
|
die("apply requires --org-id (ZITADEL console -> Organization settings -> Resource ID)")
|
||||||
|
key = CONF_DIR / "iam-admin.json"
|
||||||
|
if not key.exists():
|
||||||
|
die(f"{key} missing — run the keys phase first")
|
||||||
|
env = dict(os.environ, ZITADEL_JWT_PROFILE_FILE=str(key))
|
||||||
|
|
||||||
|
for cmd in (["init", "-input=false"], ["fmt", "-check", "-recursive"]):
|
||||||
|
cp = run(["terraform", f"-chdir={TF_DIR}", *cmd], env=env)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
die(f"terraform {' '.join(cmd)} failed: {cp.stderr.strip() or cp.stdout.strip()}")
|
||||||
|
|
||||||
|
cp = run(["terraform", f"-chdir={TF_DIR}", "apply", "-input=false", "-auto-approve",
|
||||||
|
f"-var=org_id={args.org_id}"], env=env)
|
||||||
|
sys.stdout.write(cp.stdout)
|
||||||
|
sys.stderr.write(cp.stderr)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
die("terraform apply failed — no SOPS value was written. Objects created before the "
|
||||||
|
"failure stay in ZITADEL; re-run apply once the cause is fixed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── phase: secrets ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# client -> [(sops file, Secret name inside the file, key, kind)]
|
||||||
|
# Only secrets with a git-tracked SOPS file appear here. Every other client is
|
||||||
|
# printed as manual: guessing a path writes a credential into the wrong Secret.
|
||||||
|
SECRET_MAP = {
|
||||||
|
"grafana": [("grafana-oidc-secrets.enc.yaml", "grafana-oidc",
|
||||||
|
"GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET", "secret")],
|
||||||
|
"minio": [("minio-secrets.enc.yaml", "minio-oidc",
|
||||||
|
"MINIO_IDENTITY_OPENID_CLIENT_SECRET", "secret")],
|
||||||
|
"paperless-ai-agent": [("paperless-ai-secrets.enc.yaml", "paperless-ai-config",
|
||||||
|
"LLM_AUTH_CLIENT_SECRET", "secret")],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clients whose client_id lives in a plain (unencrypted) manifest. They need the
|
||||||
|
# id update as much as the secret: ZITADEL generates a new client_id for every
|
||||||
|
# application, so a secret-only re-point yields invalid_client.
|
||||||
|
CLIENT_ID_MAP = {
|
||||||
|
"grafana": ("k8s/infra/logging/grafana-values.yaml",
|
||||||
|
r"(?m)^(\s*client_id:\s*)\S+\s*$", r"\g<1>{value}"),
|
||||||
|
"minio": ("k8s/infra/minio/minio-tenant.yaml",
|
||||||
|
r"(?m)^(\s*value:\s*)\"minio\"\s*$", r'\g<1>"{value}"'),
|
||||||
|
"paperless-ai-agent": ("k8s/apps/paperless/paperless-ai.yaml",
|
||||||
|
r"client_id=paperless-ai-agent", "client_id={value}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Secrets that a live workload mounts but no SOPS file in git defines. The phase
|
||||||
|
# cannot re-point them safely, so it names them.
|
||||||
|
MANUAL_ONLY = {
|
||||||
|
"homarr": "Secret homarr-oidc (keys client-id, client-secret) has no SOPS file in git — "
|
||||||
|
"adopt it first, then add it to SECRET_MAP. The repo also carries an orphan "
|
||||||
|
"homarr-auth-oidc.enc.yaml that nothing consumes.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def terraform_outputs() -> dict:
|
||||||
|
need("terraform")
|
||||||
|
key = CONF_DIR / "iam-admin.json"
|
||||||
|
if not key.exists():
|
||||||
|
die(f"{key} missing — run the keys phase first")
|
||||||
|
env = dict(os.environ, ZITADEL_JWT_PROFILE_FILE=str(key))
|
||||||
|
cp = run(["terraform", f"-chdir={TF_DIR}", "output", "-json"], env=env)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
die(f"terraform output failed: {cp.stderr.strip()}")
|
||||||
|
return json.loads(cp.stdout or "{}")
|
||||||
|
|
||||||
|
|
||||||
|
def sops_env() -> dict:
|
||||||
|
env = dict(os.environ)
|
||||||
|
env.setdefault("SOPS_AGE_KEY_FILE", str(Path.home() / ".sops" / "key.txt"))
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def write_secret(rel_path: str, secret_name: str, key: str, value: str,
|
||||||
|
dry_run: bool) -> None:
|
||||||
|
path = SOPS_DIR / rel_path
|
||||||
|
if not path.exists():
|
||||||
|
die(f"{path} does not exist — the mapping and the repo disagree")
|
||||||
|
env = sops_env()
|
||||||
|
dec = run(["sops", "--config", str(SOPS_CONFIG), "decrypt", str(path)], env=env)
|
||||||
|
if dec.returncode != 0:
|
||||||
|
die(f"cannot decrypt {rel_path}: {dec.stderr.strip()}")
|
||||||
|
docs = [d for d in yaml.safe_load_all(dec.stdout) if d]
|
||||||
|
matching = [d for d in docs if (d.get("metadata") or {}).get("name") == secret_name]
|
||||||
|
if len(matching) != 1:
|
||||||
|
die(f"{rel_path}: expected exactly one document named {secret_name}, found {len(matching)}")
|
||||||
|
|
||||||
|
current = (matching[0].get("stringData") or {}).get(key)
|
||||||
|
if current == value:
|
||||||
|
print(f" unchanged {rel_path}:{secret_name}:{key}")
|
||||||
|
return
|
||||||
|
if dry_run:
|
||||||
|
print(f" would set {rel_path}:{secret_name}:{key} (len {len(value)})")
|
||||||
|
return
|
||||||
|
|
||||||
|
if len(docs) == 1:
|
||||||
|
cp = run(["sops", "--config", str(SOPS_CONFIG), "set", "--value-stdin", "--idempotent",
|
||||||
|
str(path), f'["stringData"]["{key}"]'], env=env, input=json.dumps(value))
|
||||||
|
if cp.returncode != 0:
|
||||||
|
die(f"sops set failed on {rel_path}: {cp.stderr.strip()}")
|
||||||
|
print(f" wrote {rel_path}:{secret_name}:{key}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Multi-document file. `sops set` cannot address one document, so the whole
|
||||||
|
# file is rewritten. sops encrypt reads the creation rule from the FILENAME,
|
||||||
|
# so the temp path is overridden with the repo-relative path; without that
|
||||||
|
# override sops finds no matching rule and refuses.
|
||||||
|
matching[0].setdefault("stringData", {})[key] = value
|
||||||
|
plain = "---\n".join(yaml.safe_dump(d, sort_keys=False) for d in docs)
|
||||||
|
fd, tmp = tempfile.mkstemp(suffix=".yaml", dir=tempfile.gettempdir())
|
||||||
|
_TEMP_FILES.add(tmp)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w") as fh:
|
||||||
|
fh.write(plain)
|
||||||
|
enc = run(["sops", "--config", str(SOPS_CONFIG), "encrypt",
|
||||||
|
"--filename-override", str(path), tmp], env=env)
|
||||||
|
if enc.returncode != 0:
|
||||||
|
die(f"sops encrypt failed for {rel_path}: {enc.stderr.strip()}")
|
||||||
|
mode = stat.S_IMODE(path.stat().st_mode)
|
||||||
|
fd2, staged = tempfile.mkstemp(suffix=".enc.yaml", dir=str(path.parent))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd2, "w") as fh:
|
||||||
|
fh.write(enc.stdout)
|
||||||
|
os.chmod(staged, mode)
|
||||||
|
os.replace(staged, path) # same directory: atomic, never EXDEV
|
||||||
|
finally:
|
||||||
|
if os.path.exists(staged):
|
||||||
|
os.unlink(staged)
|
||||||
|
print(f" wrote {rel_path}:{secret_name}:{key} (multi-document rewrite)")
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
_TEMP_FILES.discard(tmp)
|
||||||
|
|
||||||
|
|
||||||
|
def update_client_id(client: str, value: str, dry_run: bool, apply_config: bool) -> None:
|
||||||
|
spec = CLIENT_ID_MAP.get(client)
|
||||||
|
if not spec:
|
||||||
|
return
|
||||||
|
rel_path, pattern, repl = spec
|
||||||
|
path = REPO / rel_path
|
||||||
|
if not path.exists():
|
||||||
|
die(f"{path} does not exist — the client-id mapping is stale")
|
||||||
|
text = path.read_text()
|
||||||
|
matches = list(re.finditer(pattern, text))
|
||||||
|
if len(matches) != 1:
|
||||||
|
die(f"{rel_path}: expected 1 match for the {client} client id, found {len(matches)}")
|
||||||
|
new_line = matches[0].expand(repl.format(value=value))
|
||||||
|
if matches[0].group(0) == new_line:
|
||||||
|
print(f" unchanged {rel_path}: {client} client id")
|
||||||
|
return
|
||||||
|
if dry_run or not apply_config:
|
||||||
|
print(f" would change {rel_path}: {matches[0].group(0).strip()} -> {new_line.strip()}")
|
||||||
|
if not apply_config:
|
||||||
|
print(" re-run with --apply-config to write this plaintext manifest edit")
|
||||||
|
return
|
||||||
|
path.write_text(text[:matches[0].start()] + new_line + text[matches[0].end():])
|
||||||
|
print(f" wrote {rel_path}: {client} client id")
|
||||||
|
|
||||||
|
|
||||||
|
def phase_secrets(args: argparse.Namespace) -> int:
|
||||||
|
need("sops")
|
||||||
|
out = terraform_outputs()
|
||||||
|
ids = out.get("client_ids", {}).get("value", {})
|
||||||
|
secrets = out.get("client_secrets", {}).get("value", {})
|
||||||
|
machine_keys = out.get("machine_keys", {}).get("value", {})
|
||||||
|
|
||||||
|
if not ids and not machine_keys:
|
||||||
|
die("terraform has no ZITADEL outputs yet — run the apply phase first")
|
||||||
|
|
||||||
|
print("[1/3] client secrets into SOPS")
|
||||||
|
for client, entries in SECRET_MAP.items():
|
||||||
|
for rel_path, secret_name, key, kind in entries:
|
||||||
|
value = ids.get(client) if kind == "id" else secrets.get(client)
|
||||||
|
if value:
|
||||||
|
write_secret(rel_path, secret_name, key, value, args.dry_run)
|
||||||
|
|
||||||
|
print("[2/3] client ids in plain manifests")
|
||||||
|
for client in list(CLIENT_ID_MAP) + list(SECRET_MAP):
|
||||||
|
if ids.get(client):
|
||||||
|
update_client_id(client, ids[client], args.dry_run, args.apply_config)
|
||||||
|
|
||||||
|
print("[3/3] machine keys")
|
||||||
|
key_dir = CONF_DIR / "machine-keys"
|
||||||
|
for name, key_json in machine_keys.items():
|
||||||
|
if args.dry_run:
|
||||||
|
print(f" would write {key_dir / (name + '.json')}")
|
||||||
|
else:
|
||||||
|
write_private(key_dir / f"{name}.json", key_json.encode())
|
||||||
|
print(f" wrote {key_dir / (name + '.json')} (0600)")
|
||||||
|
|
||||||
|
unhandled = sorted(set(ids) - set(SECRET_MAP) - set(CLIENT_ID_MAP) - set(MANUAL_ONLY))
|
||||||
|
if MANUAL_ONLY or unhandled:
|
||||||
|
print("\n MANUAL — no safe write path exists for these clients:")
|
||||||
|
for client, why in MANUAL_ONLY.items():
|
||||||
|
print(f" {client}: {why}")
|
||||||
|
for client in unhandled:
|
||||||
|
print(f" {client}: set its client_id and secret in its own manifest or Secret file")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── phase: users ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
HASH_RE = re.compile(r"^pbkdf2_sha256\$(?P<rounds>\d+)\$(?P<salt>[^$]+)\$(?P<hash>[^$]+)$")
|
||||||
|
|
||||||
|
|
||||||
|
def convert_hash(django_hash: str) -> str:
|
||||||
|
"""Django pbkdf2_sha256 -> passwap MCF $pbkdf2-sha256$...
|
||||||
|
|
||||||
|
ZITADEL imports one MCF string via hashedPassword.hash (max 200 chars) and
|
||||||
|
pins passwap, whose pbkdf2 format is $pbkdf2-sha256$<rounds>$<salt>$<hash>
|
||||||
|
with alt-base64 (standard base64, + -> ., no padding). Rounds, salt and hash
|
||||||
|
bytes are unchanged, so the password still verifies.
|
||||||
|
"""
|
||||||
|
m = HASH_RE.match(django_hash.strip())
|
||||||
|
if not m:
|
||||||
|
die("unsupported hash format — only pbkdf2_sha256 is convertible")
|
||||||
|
alt = m.group("hash").rstrip("=").replace("+", ".")
|
||||||
|
mcf = f"$pbkdf2-sha256${m.group('rounds')}${m.group('salt')}${alt}"
|
||||||
|
if len(mcf) > 200:
|
||||||
|
die(f"converted hash is {len(mcf)} chars, ZITADEL accepts 200")
|
||||||
|
return mcf
|
||||||
|
|
||||||
|
|
||||||
|
def phase_users(args: argparse.Namespace) -> int:
|
||||||
|
token = bootstrap_token()
|
||||||
|
if not token:
|
||||||
|
die("no bootstrap token — set ZITADEL_BOOTSTRAP_TOKEN in .env, or run the keys phase")
|
||||||
|
if not args.django_hash:
|
||||||
|
die("pass --django-hash with the pbkdf2_sha256 value exported from Authentik")
|
||||||
|
body = {
|
||||||
|
"username": args.username,
|
||||||
|
"profile": {"givenName": args.given_name, "familyName": args.family_name},
|
||||||
|
"email": {"email": args.email, "isVerified": True},
|
||||||
|
"hashedPassword": {"hash": convert_hash(args.django_hash)},
|
||||||
|
"passwordChangeRequired": False,
|
||||||
|
}
|
||||||
|
code, payload = curl_json("POST", f"{BASE_URL}/v2/users/human", token=token, body=body)
|
||||||
|
if code in (200, 201):
|
||||||
|
print(f" {args.username}: created")
|
||||||
|
return 0
|
||||||
|
if code == 409:
|
||||||
|
print(f" {args.username}: already exists — the password was NOT updated")
|
||||||
|
return 1
|
||||||
|
die(f"create {args.username} -> {code} {payload[:300]}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── phase: verify ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def phase_verify(_args: argparse.Namespace) -> int:
|
||||||
|
code, _ = curl_json("GET", f"{BASE_URL}/.well-known/openid-configuration", timeout=10)
|
||||||
|
print(f"discovery : {code}")
|
||||||
|
if code != 200:
|
||||||
|
die("discovery endpoint is not 200 — the instance or the ingress is not ready")
|
||||||
|
|
||||||
|
out = terraform_outputs()
|
||||||
|
ids = out.get("client_ids", {}).get("value", {})
|
||||||
|
print(f"client apps : {len(ids)}")
|
||||||
|
enabled = bool(out.get("groups_claim_enabled", {}).get("value"))
|
||||||
|
print(f"groups action : {enabled}")
|
||||||
|
if not enabled:
|
||||||
|
print("BLOCKED for cutover: the groups claim action is disabled. Every consumer")
|
||||||
|
print("that reads `groups` (kube-apiserver, MinIO, Grafana) sees no groups.")
|
||||||
|
print("Enable it only after the live gate passes, with the script path set.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
PHASES = {"keys": phase_keys, "apply": phase_apply, "secrets": phase_secrets,
|
||||||
|
"users": phase_users, "verify": phase_verify}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Provision ZITADEL end to end.")
|
||||||
|
parser.add_argument("phase", choices=[*PHASES, "all"])
|
||||||
|
parser.add_argument("--org-id", help="ZITADEL org id (required for apply)")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="print actions, write nothing")
|
||||||
|
parser.add_argument("--apply-config", action="store_true",
|
||||||
|
help="also rewrite client ids inside plain manifests")
|
||||||
|
parser.add_argument("--username", default="rock")
|
||||||
|
parser.add_argument("--email", default="[email protected]")
|
||||||
|
parser.add_argument("--given-name", default="Rock")
|
||||||
|
parser.add_argument("--family-name", default="Liang")
|
||||||
|
parser.add_argument("--django-hash", help="pbkdf2_sha256 value exported from Authentik")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
load_env_file()
|
||||||
|
need("kubectl")
|
||||||
|
if args.phase == "all":
|
||||||
|
if not args.org_id:
|
||||||
|
die("all requires --org-id")
|
||||||
|
for name in ("keys", "apply"):
|
||||||
|
rc = PHASES[name](args)
|
||||||
|
if rc:
|
||||||
|
return rc
|
||||||
|
rc = phase_secrets(args)
|
||||||
|
if rc:
|
||||||
|
return rc
|
||||||
|
return phase_verify(args)
|
||||||
|
return PHASES[args.phase](args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
# via nginx (not the backend service) is critical: a direct rewrite bypasses
|
# via nginx (not the backend service) is critical: a direct rewrite bypasses
|
||||||
# nginx TLS and exposes each app's own self-signed cert, trusted by nothing.
|
# nginx TLS and exposes each app's own self-signed cert, trusted by nothing.
|
||||||
rewrite name authentik.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
rewrite name authentik.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||||
|
rewrite name zitadel.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||||
rewrite name grafana.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
rewrite name grafana.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||||
rewrite name minio.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
rewrite name minio.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||||
rewrite name minio-api.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
rewrite name minio-api.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ controlplane_configs = {
|
|||||||
"talos-cp-1" = {
|
"talos-cp-1" = {
|
||||||
hostname = "talos-cp-1"
|
hostname = "talos-cp-1"
|
||||||
lan_ip = "192.168.1.166"
|
lan_ip = "192.168.1.166"
|
||||||
|
public_ip = "https://k8s-api.riotpiao.com"
|
||||||
lan_subnet = "192.168.1.0/24"
|
lan_subnet = "192.168.1.0/24"
|
||||||
lan_gateway = "192.168.1.254"
|
lan_gateway = "192.168.1.254"
|
||||||
install_disk = "/dev/nvme0n1"
|
install_disk = "/dev/nvme0n1"
|
||||||
@@ -32,6 +33,7 @@ controlplane_configs = {
|
|||||||
"talos-cp-2" = {
|
"talos-cp-2" = {
|
||||||
hostname = "talos-cp-2"
|
hostname = "talos-cp-2"
|
||||||
lan_ip = "192.168.1.214"
|
lan_ip = "192.168.1.214"
|
||||||
|
public_ip = "https://k8s-api.riotpiao.com"
|
||||||
lan_subnet = "192.168.1.0/24"
|
lan_subnet = "192.168.1.0/24"
|
||||||
lan_gateway = "192.168.1.254"
|
lan_gateway = "192.168.1.254"
|
||||||
install_disk = "/dev/disk/by-id/wwn-0x644a842029bd3f002720989b07d7143d"
|
install_disk = "/dev/disk/by-id/wwn-0x644a842029bd3f002720989b07d7143d"
|
||||||
@@ -39,7 +41,11 @@ controlplane_configs = {
|
|||||||
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b60f4375d97062", mountpoint = "/var/lib/longhorn-disk1" },
|
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b60f4375d97062", mountpoint = "/var/lib/longhorn-disk1" },
|
||||||
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b5ffeb8bb8b47f", mountpoint = "/var/lib/longhorn-disk2" },
|
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b5ffeb8bb8b47f", mountpoint = "/var/lib/longhorn-disk2" },
|
||||||
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b6000c8dabb266", mountpoint = "/var/lib/longhorn-disk3" },
|
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b6000c8dabb266", mountpoint = "/var/lib/longhorn-disk3" },
|
||||||
{ device = "/dev/disk/by-id/wwn-0x6b083fe0c5782700321370dc23fd765b", mountpoint = "/var/lib/longhorn-disk4" },
|
# /var/lib/longhorn-disk4 (wwn-0x6b083fe0c5782700321370dc23fd765b) was
|
||||||
|
# retired 2026-10-12: the 2TB PERC virtual disk failed with continuous
|
||||||
|
# SCSI I/O errors, hung its XFS filesystem, and wedged Longhorn replica
|
||||||
|
# rebuilds on talos-cp-2 with unkillable D-state processes. Do not
|
||||||
|
# re-add it.
|
||||||
]
|
]
|
||||||
zone = "az-b"
|
zone = "az-b"
|
||||||
allow_scheduling = true
|
allow_scheduling = true
|
||||||
@@ -49,6 +55,7 @@ controlplane_configs = {
|
|||||||
"talos-cp-3" = {
|
"talos-cp-3" = {
|
||||||
hostname = "talos-cp-3"
|
hostname = "talos-cp-3"
|
||||||
lan_ip = "192.168.1.162"
|
lan_ip = "192.168.1.162"
|
||||||
|
public_ip = "https://k8s-api.riotpiao.com"
|
||||||
lan_subnet = "192.168.1.0/24"
|
lan_subnet = "192.168.1.0/24"
|
||||||
lan_gateway = "192.168.1.254"
|
lan_gateway = "192.168.1.254"
|
||||||
install_disk = "/dev/nvme0n1"
|
install_disk = "/dev/nvme0n1"
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# The groups claim — the single load-bearing decision of the migration.
|
||||||
|
#
|
||||||
|
# ZITADEL does not issue a `groups` claim. Authentik did, and these consumers
|
||||||
|
# read it: kube-apiserver RBAC (`--oidc-groups-claim=groups` plus 11 RoleBindings
|
||||||
|
# on oidc:<group>), MinIO (`MINIO_IDENTITY_OPENID_CLAIM_NAME=groups`), Grafana
|
||||||
|
# role mapping, and every service that filters on a group name.
|
||||||
|
#
|
||||||
|
# Plan §1.3 Option A keeps those consumers unchanged by synthesizing the claim.
|
||||||
|
# Option B refactors all of them. This module takes responsibility only for the
|
||||||
|
# ZITADEL side of Option A: the action resource and its trigger.
|
||||||
|
#
|
||||||
|
# The script body is NOT shipped by default. The go/no-go gate (§1.3, §1.5 task
|
||||||
|
# 1.3) is: prove on the live instance that the action runtime can read project
|
||||||
|
# roles plus user metadata and write both `groups` and the per-agent claims,
|
||||||
|
# for a human token AND a machine-user token. Until that proof exists, enabling
|
||||||
|
# this resource would move every consumer onto an unverified claim source:
|
||||||
|
#
|
||||||
|
# terraform apply -var enable_groups_claim_action=true \
|
||||||
|
# -var groups_claim_script=./groups-claim.js
|
||||||
|
#
|
||||||
|
# After it applies, verify with a real token exchange before moving a consumer:
|
||||||
|
#
|
||||||
|
# curl -s -X POST https://zitadel.riotpiao.com/oauth/v2/token \
|
||||||
|
# -d grant_type=refresh_token -d refresh_token=<t> \
|
||||||
|
# -d client_id=kubernetes | jq -R 'split(".")[1] | @base64d | fromjson'
|
||||||
|
#
|
||||||
|
# The decoded access token must contain `"groups":["homelab-admins", ...]`.
|
||||||
|
|
||||||
|
resource "zitadel_action" "groups_claim" {
|
||||||
|
count = var.enable_groups_claim_action ? 1 : 0
|
||||||
|
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
name = "homelab-groups-claim"
|
||||||
|
script = file(var.groups_claim_script)
|
||||||
|
timeout = "5s"
|
||||||
|
allowed_to_fail = false
|
||||||
|
}
|
||||||
|
|
||||||
|
# Both triggers are needed. The access-token trigger fills the JWT that
|
||||||
|
# Kubernetes and MinIO read. The userinfo trigger fills the response that
|
||||||
|
# Grafana, paperless and Forgejo read.
|
||||||
|
resource "zitadel_trigger_actions" "groups_claim_access_token" {
|
||||||
|
count = var.enable_groups_claim_action ? 1 : 0
|
||||||
|
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
flow_type = "FLOW_TYPE_CUSTOMISE_TOKEN"
|
||||||
|
trigger_type = "TRIGGER_TYPE_PRE_ACCESS_TOKEN_CREATION"
|
||||||
|
action_ids = [zitadel_action.groups_claim[0].id]
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "zitadel_trigger_actions" "groups_claim_userinfo" {
|
||||||
|
count = var.enable_groups_claim_action ? 1 : 0
|
||||||
|
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
flow_type = "FLOW_TYPE_CUSTOMISE_TOKEN"
|
||||||
|
trigger_type = "TRIGGER_TYPE_PRE_USERINFO_CREATION"
|
||||||
|
action_ids = [zitadel_action.groups_claim[0].id]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# API applications — the machine-token surface.
|
||||||
|
#
|
||||||
|
# ZITADEL keeps two different objects apart: an OIDC application is for a
|
||||||
|
# browser flow, and an API application is what a client_credentials / JWT-profile
|
||||||
|
# caller presents. Authentik modelled both with one provider, so the plan §1.1
|
||||||
|
# row 1 ("OIDC app + machine user") needs one of each here.
|
||||||
|
#
|
||||||
|
# The gateway signs its token request with the machine key from machines.tf, so
|
||||||
|
# the auth method is PRIVATE_KEY_JWT. No secret is stored for it.
|
||||||
|
|
||||||
|
resource "zitadel_application_api" "machine" {
|
||||||
|
for_each = { for name, spec in local.machine_users : name => spec if contains(keys(var.api_app_consumers), name) }
|
||||||
|
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
project_id = zitadel_project_v2.homelab.id
|
||||||
|
name = "${each.key}-machine"
|
||||||
|
auth_method_type = "API_AUTH_METHOD_TYPE_PRIVATE_KEY_JWT"
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# OIDC applications — one per consumer that logs a browser in.
|
||||||
|
#
|
||||||
|
# Client IDs: the plan §3.2 wants every client_id byte-identical after the move,
|
||||||
|
# so that a consumer change is only the issuer URL plus the secret.
|
||||||
|
#
|
||||||
|
# THAT GOAL IS NOT ACHIEVABLE HERE, verified twice on 2026-10-24:
|
||||||
|
# - ZITADEL v4.19.2 CreateOIDCApplicationRequest has no client_id field, and
|
||||||
|
# CreateOIDCApplicationResponse documents the value as "generated"
|
||||||
|
# (proto/zitadel/app/v2beta/app_service.proto:540-650).
|
||||||
|
# - zitadel provider 3.8.7 exposes no settable application id: `id` on
|
||||||
|
# `zitadel_application_v2` is not a valid argument (terraform validate
|
||||||
|
# rejects it), and `client_id` on `zitadel_application_oidc` is computed.
|
||||||
|
# Consequence: every consumer takes a NEW client_id as well as a new secret.
|
||||||
|
# Budget the extra config change per consumer — this is not the one-line
|
||||||
|
# re-point the plan assumed.
|
||||||
|
#
|
||||||
|
# The only way to keep the old value is to create each app through the v2 REST
|
||||||
|
# API with an explicit body id, then import it:
|
||||||
|
# POST /v2/projects/<project_id>/apps
|
||||||
|
# {"id":"<old-client-id>","name":"<old-client-id>","oidcRequest":{...}}
|
||||||
|
# Not implemented here: it would put app creation outside Terraform state.
|
||||||
|
#
|
||||||
|
# Secrets are generated by ZITADEL, never written here. `terraform output -json
|
||||||
|
# client_ids` and `client_secrets` feed the SOPS re-point (§3.2, §6.9). Do not
|
||||||
|
# commit the tfstate: it carries those values.
|
||||||
|
|
||||||
|
locals {
|
||||||
|
oidc_clients = {
|
||||||
|
# Gateway. Browser sessions for the console, plus a machine path
|
||||||
|
# (machines.tf) for callers that need client_credentials.
|
||||||
|
"api-gw" = {
|
||||||
|
redirect_uris = ["http://localhost:3000/callback", "https://api.riotpiao.com/callback"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
"argocd" = {
|
||||||
|
redirect_uris = ["http://localhost:8080/auth/callback", "https://argocd.riotpiao.com/auth/callback"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
"forgejo" = {
|
||||||
|
redirect_uris = ["http://localhost:3000/user/oauth2/authentik/callback", "https://forgejo.riotpiao.com/user/oauth2/authentik/callback"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
"grafana" = {
|
||||||
|
redirect_uris = ["http://localhost:3000/login/generic_oauth", "https://grafana.riotpiao.com/login/generic_oauth"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
"homarr" = {
|
||||||
|
redirect_uris = ["http://localhost:7575/auth/callback", "https://homarr.riotpiao.com/auth/callback"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
"immich" = {
|
||||||
|
redirect_uris = ["app.immich:///oauth-callback", "https://img.riotpiao.com/auth/login", "https://img.riotpiao.com/user/oauth2/callback"]
|
||||||
|
app_type = "NATIVE"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
"kubernetes" = {
|
||||||
|
redirect_uris = ["http://localhost:8000", "http://localhost:18000"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "NONE"
|
||||||
|
dev_mode = true
|
||||||
|
}
|
||||||
|
"minio" = {
|
||||||
|
redirect_uris = ["http://localhost:9000/auth/sso/oauth2/code", "https://minio.riotpiao.com/auth/sso/oauth2/code"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
"paperless" = {
|
||||||
|
redirect_uris = ["http://localhost:8000/accounts/oidc/authentik/login/callback/", "https://paperless.riotpiao.com/accounts/oidc/authentik/login/callback/"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
"poimen" = {
|
||||||
|
redirect_uris = ["http://localhost:3000/callback", "https://poimen.riotpiao.com/callback"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
"queue-sqs" = {
|
||||||
|
redirect_uris = ["http://localhost:8080/callback", "https://queue.riotpiao.com/callback"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
"vault" = {
|
||||||
|
redirect_uris = ["http://localhost:8200/ui/vault/auth/oidc/oidc/callback", "https://vault.riotpiao.com/ui/vault/auth/oidc/oidc/callback"]
|
||||||
|
app_type = "WEB"
|
||||||
|
auth_method = "BASIC"
|
||||||
|
dev_mode = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "zitadel_application_v2" "oidc" {
|
||||||
|
for_each = local.oidc_clients
|
||||||
|
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
project_id = zitadel_project_v2.homelab.id
|
||||||
|
name = each.key
|
||||||
|
|
||||||
|
oidc {
|
||||||
|
redirect_uris = each.value.redirect_uris
|
||||||
|
response_types = ["OIDC_RESPONSE_TYPE_CODE"]
|
||||||
|
grant_types = [
|
||||||
|
"OIDC_GRANT_TYPE_AUTHORIZATION_CODE",
|
||||||
|
"OIDC_GRANT_TYPE_REFRESH_TOKEN",
|
||||||
|
]
|
||||||
|
app_type = "OIDC_APP_TYPE_${each.value.app_type}"
|
||||||
|
auth_method_type = "OIDC_AUTH_METHOD_TYPE_${each.value.auth_method}"
|
||||||
|
version = "OIDC_VERSION_1_0"
|
||||||
|
|
||||||
|
# JWT access tokens: Kubernetes RBAC, MinIO policy mapping and the Go
|
||||||
|
# gateway all read claims, and an opaque bearer token carries none.
|
||||||
|
access_token_type = "OIDC_TOKEN_TYPE_JWT"
|
||||||
|
|
||||||
|
# Assert roles without the client having to request the scope. Every
|
||||||
|
# consumer here maps roles or groups to local permissions.
|
||||||
|
access_token_role_assertion = true
|
||||||
|
id_token_role_assertion = true
|
||||||
|
id_token_userinfo_assertion = true
|
||||||
|
|
||||||
|
dev_mode = each.value.dev_mode
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Machine users and their project grants (plan §1.4, §5.3).
|
||||||
|
#
|
||||||
|
# Authentik gave each agent an app password or a client_credentials grant built
|
||||||
|
# on a user password. ZITADEL has no user password on a machine user: it issues
|
||||||
|
# a JWT profile key (`KEY_TYPE_JSON`) that the SDK signs its token request with.
|
||||||
|
# That removes the password path entirely, which is the point — see §5.3.
|
||||||
|
#
|
||||||
|
# Per-agent claim values are copied from `SERVICE_ACCOUNTS` in
|
||||||
|
# scripts/iam/provision-rbac.py. They live in user metadata as JSON strings,
|
||||||
|
# because the groups-claim action (actions.tf) is the only thing that turns them
|
||||||
|
# into token claims. Without that action a consumer sees roles but not
|
||||||
|
# `minio_buckets`, `authorized_models`, `sqs_queues`, `memory_projects`,
|
||||||
|
# `memory_visibility` or `paperless_doctypes`.
|
||||||
|
#
|
||||||
|
# Keys are generated per machine user and land in `terraform output -json
|
||||||
|
# machine_keys` (sensitive). Feed each one to the owning workload as a mounted
|
||||||
|
# file, never as an environment variable dumps-able through `ps`.
|
||||||
|
|
||||||
|
locals {
|
||||||
|
machine_users = {
|
||||||
|
"memory-agent" = {
|
||||||
|
description = "Memory service agent"
|
||||||
|
project_roles = ["poimen-admins"]
|
||||||
|
authenticated_by = "poimen memory (Rust)"
|
||||||
|
metadata = {
|
||||||
|
roles = jsonencode(["llm:inference", "memory:read", "memory:write", "notification:write", "sessions:read", "sessions:write"])
|
||||||
|
memory_projects = jsonencode(["*"])
|
||||||
|
memory_visibility = "private"
|
||||||
|
authorized_models = jsonencode(["reasoning", "ornith:35b", "qwen2.5:3b"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"paperless-ai-agent" = {
|
||||||
|
description = "Paperless AI plugin (auto-tagging, entity extraction)"
|
||||||
|
project_roles = ["poimen-devs"]
|
||||||
|
authenticated_by = "paperless-ai"
|
||||||
|
metadata = {
|
||||||
|
roles = jsonencode(["llm:inference", "memory:write", "paperless:admin", "notification:write"])
|
||||||
|
minio_buckets = jsonencode(["paperless"])
|
||||||
|
paperless_doctypes = jsonencode(["*"])
|
||||||
|
memory_projects = jsonencode(["*"])
|
||||||
|
authorized_models = jsonencode(["reasoning", "qwen2.5:3b"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"portfolio-agent" = {
|
||||||
|
description = "Portfolio service agent"
|
||||||
|
project_roles = ["poimen-prod-readonly"]
|
||||||
|
authenticated_by = "portfolio service"
|
||||||
|
metadata = {
|
||||||
|
roles = jsonencode(["llm:inference", "memory:read"])
|
||||||
|
memory_projects = jsonencode(["homelab", "portfolio"])
|
||||||
|
memory_visibility = "public"
|
||||||
|
authorized_models = jsonencode(["ornith:35b"])
|
||||||
|
minio_buckets = jsonencode(["backups"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"temporal-worker-agent" = {
|
||||||
|
description = "Temporal workflow worker"
|
||||||
|
project_roles = ["poimen-admins"]
|
||||||
|
authenticated_by = "Temporal worker"
|
||||||
|
metadata = {
|
||||||
|
roles = jsonencode(["llm:inference", "workflow:execute", "memory:read", "memory:write", "queue:send", "notification:write"])
|
||||||
|
memory_projects = jsonencode(["*"])
|
||||||
|
sqs_queues = jsonencode(["*"])
|
||||||
|
authorized_models = jsonencode(["reasoning", "ornith:35b", "qwen2.5:3b"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# The gateway is the one consumer that used client_credentials on a machine
|
||||||
|
# token, so it needs both the OIDC app (clients.tf) and a machine user
|
||||||
|
# (here) plus its API application (api_apps.tf). Its capability check reads
|
||||||
|
# the `roles` claim, which arrives as metadata.
|
||||||
|
"api-gw" = {
|
||||||
|
description = "Go LLM gateway machine identity"
|
||||||
|
project_roles = []
|
||||||
|
authenticated_by = "api-gw"
|
||||||
|
metadata = {
|
||||||
|
roles = jsonencode(["llm:inference"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
# pi-worker has no anchor in git (plan §1.1: "not in git") and no group in
|
||||||
|
# the Authentik provisioning script. It gets a machine user and no project
|
||||||
|
# role until the owner names the consumer configuration that holds its old
|
||||||
|
# credentials. Creating a role grant blind would widen access, not preserve it.
|
||||||
|
"pi-worker" = {
|
||||||
|
description = "pi sandbox worker (no git anchor for its consumer config)"
|
||||||
|
project_roles = []
|
||||||
|
authenticated_by = "pi sandbox worker"
|
||||||
|
metadata = {
|
||||||
|
roles = jsonencode([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
machine_metadata = merge([
|
||||||
|
for user, spec in local.machine_users : {
|
||||||
|
for key, value in spec.metadata : "${user}/${key}" => {
|
||||||
|
user = user
|
||||||
|
key = key
|
||||||
|
value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "zitadel_machine_user" "agent" {
|
||||||
|
for_each = local.machine_users
|
||||||
|
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
user_name = each.key
|
||||||
|
name = each.key
|
||||||
|
description = "${each.value.description} — consumer: ${each.value.authenticated_by}"
|
||||||
|
with_secret = false
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "zitadel_machine_key" "agent" {
|
||||||
|
for_each = local.machine_users
|
||||||
|
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
user_id = zitadel_machine_user.agent[each.key].id
|
||||||
|
key_type = "KEY_TYPE_JSON"
|
||||||
|
expiration_date = var.machine_key_expiry
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "zitadel_project_member" "agent" {
|
||||||
|
for_each = { for user, spec in local.machine_users : user => spec if length(spec.project_roles) > 0 }
|
||||||
|
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
project_id = zitadel_project_v2.homelab.id
|
||||||
|
user_id = zitadel_machine_user.agent[each.key].id
|
||||||
|
roles = each.value.project_roles
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "zitadel_user_metadata" "agent_claim" {
|
||||||
|
for_each = local.machine_metadata
|
||||||
|
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
user_id = zitadel_machine_user.agent[each.value.user].id
|
||||||
|
key = each.value.key
|
||||||
|
value = each.value.value
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Outputs feed two things: the SOPS re-point of consumer secrets (§3.2, §6.9)
|
||||||
|
# and the machine-key handoff to each agent workload (§5.3). Every value that can
|
||||||
|
# authenticate a client is marked sensitive, so `terraform output` prints a
|
||||||
|
# placeholder until you ask for it explicitly.
|
||||||
|
|
||||||
|
output "org_id" {
|
||||||
|
description = "Organization that owns the project, roles and applications."
|
||||||
|
value = data.zitadel_org.default.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "project_id" {
|
||||||
|
description = "Project ID, which ZITADEL puts in the access-token `aud` claim."
|
||||||
|
value = zitadel_project_v2.homelab.id
|
||||||
|
}
|
||||||
|
|
||||||
|
output "client_ids" {
|
||||||
|
description = "client_id per consumer. Compare against the Authentik values: any difference means the consumer config must change too."
|
||||||
|
value = { for name, app in zitadel_application_v2.oidc : name => app.oidc[0].client_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
output "client_secrets" {
|
||||||
|
description = "Generated client secret per consumer. Write these into the existing SOPS files; never into git."
|
||||||
|
value = { for name, app in zitadel_application_v2.oidc : name => app.oidc[0].client_secret }
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
output "api_app_client_ids" {
|
||||||
|
description = "client_id of each API application (machine-token surface)."
|
||||||
|
value = { for name, app in zitadel_application_api.machine : name => app.client_id }
|
||||||
|
}
|
||||||
|
|
||||||
|
output "machine_user_ids" {
|
||||||
|
description = "ZITADEL user ID per machine user."
|
||||||
|
value = { for name, user in zitadel_machine_user.agent : name => user.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
output "machine_keys" {
|
||||||
|
description = "JWT profile key JSON per machine user. Mount as a file; do not pass through the environment."
|
||||||
|
value = { for name, key in zitadel_machine_key.agent : name => key.key_details }
|
||||||
|
sensitive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
output "groups_claim_enabled" {
|
||||||
|
description = "Whether the groups-claim action and its triggers exist. False means Option A is not yet in place and no consumer may move."
|
||||||
|
value = var.enable_groups_claim_action
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Project, roles and default-org lookup.
|
||||||
|
#
|
||||||
|
# ZITADEL has no group object. The plan §1.3 maps every Authentik group to a
|
||||||
|
# project role, and the groups-claim action (actions.tf) reconstructs the
|
||||||
|
# `groups` claim from those role keys. Role keys therefore EQUAL the existing
|
||||||
|
# Authentik group names, including the hyphens, so that the resulting claim is
|
||||||
|
# byte-identical and no consumer or RBAC file changes. The v4.19.2 API allows
|
||||||
|
# role keys of 1-200 characters with no character-set restriction (proto
|
||||||
|
# zitadel/project/v2/project_service.proto, AddProjectRole.role_key).
|
||||||
|
|
||||||
|
data "zitadel_org" "default" {
|
||||||
|
id = var.org_id
|
||||||
|
}
|
||||||
|
|
||||||
|
locals {
|
||||||
|
# Single source: the 19 groups of scripts/iam/provision-rbac.py GROUPS.
|
||||||
|
# Descriptions are carried over so the console reads the same as before.
|
||||||
|
roles = {
|
||||||
|
"homelab-admins" = "Cluster administrators with full access"
|
||||||
|
"minio-admins" = "MinIO administrators"
|
||||||
|
"minio-photos" = "Photos bucket (Immich) access"
|
||||||
|
"minio-documents" = "Documents bucket (Paperless) access"
|
||||||
|
"minio-backups" = "Backups bucket read-only access"
|
||||||
|
"poimen-admins" = "Poimen memory administrators"
|
||||||
|
"poimen-devs" = "Dev and staging projects access"
|
||||||
|
"poimen-prod-readonly" = "Production projects read-only access"
|
||||||
|
"paperless-admins" = "Paperless administrators"
|
||||||
|
"paperless-finance" = "Finance documents"
|
||||||
|
"paperless-legal" = "Legal documents"
|
||||||
|
"paperless-hr" = "HR documents"
|
||||||
|
"grafana-admins" = "Grafana administrators"
|
||||||
|
"grafana-editors" = "Grafana dashboard editors"
|
||||||
|
"grafana-viewers" = "Grafana dashboard viewers"
|
||||||
|
"sqs-users" = "SQS/Temporal queue read access"
|
||||||
|
"sqs-writers" = "SQS/Temporal queue read/write access"
|
||||||
|
"s3-users" = "S3 read access"
|
||||||
|
"s3-writers" = "S3 read/write access"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "zitadel_project_v2" "homelab" {
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
name = var.project_name
|
||||||
|
project_id = var.project_id
|
||||||
|
project_role_check = false
|
||||||
|
|
||||||
|
# Roles must reach the tokens. Every consumer authorizes on them:
|
||||||
|
# kube-apiserver RBAC reads `groups`, MinIO reads bucket policies, Grafana
|
||||||
|
# reads grafana_org_role. Role assertion on the ID token is what most OIDC
|
||||||
|
# clients inspect.
|
||||||
|
project_role_assertion = true
|
||||||
|
}
|
||||||
|
|
||||||
|
resource "zitadel_project_role" "groups" {
|
||||||
|
for_each = local.roles
|
||||||
|
|
||||||
|
org_id = data.zitadel_org.default.id
|
||||||
|
project_id = zitadel_project_v2.homelab.id
|
||||||
|
role_key = each.key
|
||||||
|
display_name = each.key
|
||||||
|
group = "homelab"
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# terraform/zitadel — identity configuration for the ZITADEL instance
|
||||||
|
#
|
||||||
|
# Scope: this root module declares the objects INSIDE ZITADEL (org, project,
|
||||||
|
# roles, OIDC applications, machine users, grants). It does NOT deploy ZITADEL
|
||||||
|
# itself — that is ArgoCD's job (`k8s/argocd/apps/30-security.yaml`, values in
|
||||||
|
# `k8s/infra/iam/zitadel-values.yaml`).
|
||||||
|
#
|
||||||
|
# Migration plan: ~/workplace/homelab-doc/ZITADEL_MIGRATION.html (outside git).
|
||||||
|
# This file implements §1.3 (roles), §1.4 (machine users), §2.4 (grants) and
|
||||||
|
# §6.2 (Terraform provider route). The claim-injection action of §1.3 lives in
|
||||||
|
# `actions.tf` and stays disabled until the live go/no-go gate proves it.
|
||||||
|
#
|
||||||
|
# Authentication to ZITADEL uses the `iam-admin` machine key that the chart's
|
||||||
|
# setup job writes to the `iam-admin` Secret in namespace iam:
|
||||||
|
#
|
||||||
|
# kubectl -n iam get secret iam-admin -o jsonpath='{.data.iam-admin\.json}' \
|
||||||
|
# | base64 -d > ~/.config/zitadel/iam-admin.json
|
||||||
|
# chmod 600 ~/.config/zitadel/iam-admin.json
|
||||||
|
#
|
||||||
|
# export ZITADEL_JWT_PROFILE_FILE=$HOME/.config/zitadel/iam-admin.json
|
||||||
|
# terraform -chdir=terraform/zitadel init
|
||||||
|
# terraform -chdir=terraform/zitadel plan
|
||||||
|
#
|
||||||
|
# State is LOCAL and git-ignored (`terraform/*.tfstate` in .gitignore). That is
|
||||||
|
# a deliberate first step, not the final answer: §8 risk 8 of the plan names
|
||||||
|
# state drift as a blast-radius item. Move the state to MinIO before a second
|
||||||
|
# operator ever runs `apply`. No secret is written to state except the generated
|
||||||
|
# client secrets and machine keys, which are marked sensitive — they still land
|
||||||
|
# in the state file, so treat that file as a secret.
|
||||||
|
terraform {
|
||||||
|
required_version = ">= 1.6.0"
|
||||||
|
|
||||||
|
required_providers {
|
||||||
|
zitadel = {
|
||||||
|
source = "zitadel/zitadel"
|
||||||
|
version = "3.8.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "zitadel" {
|
||||||
|
domain = var.zitadel_domain
|
||||||
|
port = var.zitadel_port
|
||||||
|
|
||||||
|
# JWT profile of the iam-admin machine user. Preferred over a PAT: it is
|
||||||
|
# short-lived per call and never sits in the environment as a bearer token.
|
||||||
|
# Set the path through ZITADEL_JWT_PROFILE_FILE.
|
||||||
|
jwt_profile_file = var.jwt_profile_file
|
||||||
|
|
||||||
|
# The instance serves plain HTTP behind ingress-nginx, which terminates TLS.
|
||||||
|
# Turn this on only if the provider must call the pod directly.
|
||||||
|
insecure = var.zitadel_insecure
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
variable "zitadel_domain" {
|
||||||
|
description = "External domain of the ZITADEL instance, without scheme."
|
||||||
|
type = string
|
||||||
|
default = "zitadel.riotpiao.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "zitadel_port" {
|
||||||
|
description = "Port for the ZITADEL API. 443 behind ingress, 8080 for a direct pod port-forward."
|
||||||
|
type = string
|
||||||
|
default = "443"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "zitadel_insecure" {
|
||||||
|
description = "Call the API over plain HTTP (set true only when port-forwarding the pod)."
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "jwt_profile_file" {
|
||||||
|
description = "Path to the iam-admin machine key JSON extracted from the iam-admin Secret."
|
||||||
|
type = string
|
||||||
|
default = null
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "org_id" {
|
||||||
|
description = <<-EOT
|
||||||
|
ID of the ZITADEL organization that holds the project. Read it from the
|
||||||
|
console: https://zitadel.riotpiao.com -> Organization settings -> Resource
|
||||||
|
ID. The chart's setup job creates the default org; this module never creates
|
||||||
|
one, because creating a second org would split the role and grant space.
|
||||||
|
EOT
|
||||||
|
type = string
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_id" {
|
||||||
|
description = <<-EOT
|
||||||
|
Stable project ID. ZITADEL puts the project ID in the access token `aud`
|
||||||
|
claim, so pinning it keeps audience checks readable across a rebuild.
|
||||||
|
Every consumer that validates `aud` against a client ID must be re-pointed
|
||||||
|
to this value — see the plan §5.2.
|
||||||
|
EOT
|
||||||
|
type = string
|
||||||
|
default = "homelab"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "project_name" {
|
||||||
|
description = "Display name of the project that carries every role and application."
|
||||||
|
type = string
|
||||||
|
default = "homelab"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "machine_key_expiry" {
|
||||||
|
description = "Expiry of every machine key, RFC3339. Matches the chart's default iam-admin key window; rotate by changing this value and re-applying."
|
||||||
|
type = string
|
||||||
|
default = "2029-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "api_app_consumers" {
|
||||||
|
description = <<-EOT
|
||||||
|
Machine users that also need an API application (the client_credentials
|
||||||
|
replacement). Only the gateway does: every other agent signs a JWT with its
|
||||||
|
machine key and does not need a client_credentials grant.
|
||||||
|
EOT
|
||||||
|
type = map(string)
|
||||||
|
default = { api-gw = "Go LLM gateway" }
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "enable_groups_claim_action" {
|
||||||
|
description = <<-EOT
|
||||||
|
Create the Actions v1 script and its trigger that synthesizes the `groups`
|
||||||
|
claim. Keep this false until the live go/no-go gate passes: the claim is
|
||||||
|
load-bearing for kube-apiserver RBAC, MinIO policies, Grafana role mapping
|
||||||
|
and every service that reads `groups`. Turning it on with an unverified
|
||||||
|
script can break every consumer at once.
|
||||||
|
EOT
|
||||||
|
type = bool
|
||||||
|
default = false
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "groups_claim_script" {
|
||||||
|
description = <<-EOT
|
||||||
|
Path to the JavaScript body for the groups-claim action, read only when
|
||||||
|
enable_groups_claim_action is true. The body is deliberately not shipped
|
||||||
|
blind: the ZITADEL v1 Actions runtime API must be confirmed against the live
|
||||||
|
instance before a script is committed, because a wrong call silently emits
|
||||||
|
no claim instead of failing loudly.
|
||||||
|
EOT
|
||||||
|
type = string
|
||||||
|
default = null
|
||||||
|
|
||||||
|
validation {
|
||||||
|
# try() is required: HCL does not short-circuit `||`, so fileexists(null)
|
||||||
|
# would fail the whole plan even with the action disabled.
|
||||||
|
condition = try(fileexists(var.groups_claim_script), true)
|
||||||
|
error_message = "groups_claim_script must name an existing file when it is set."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enabling the action without a script would reach file(null) inside actions.tf.
|
||||||
|
validation {
|
||||||
|
condition = !var.enable_groups_claim_action || var.groups_claim_script != null
|
||||||
|
error_message = "enable_groups_claim_action requires groups_claim_script."
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user