Compare commits

..
142 changed files with 2536 additions and 6697 deletions
-11
View File
@@ -31,17 +31,6 @@ AUTHENTIK_BOOTSTRAP_TOKEN=
# openssl rand -base64 24
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_ROOT_USER=
MINIO_ROOT_PASSWORD=
-20
View File
@@ -47,15 +47,6 @@ k8s/storage/test/test
terraform/.terraform/
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/edge/*.tfstate
terraform/edge/*.tfstate.*
terraform/edge/*.tfvars
terraform/edge/terraform.tfvars.local
terraform.tfvars.local
skills-lock.json
secrets-plaintext.yaml
@@ -78,14 +69,3 @@ k8s/**/*-secret.yaml
# IAM provisioning scripts contain credential references — never commit
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
# Never commit backup copies of plaintext tfvars (they carry cluster PKI)
terraform/*.bak
terraform/edge/.secrets/
@@ -1,22 +0,0 @@
{
"nextId": 2,
"loops": [
{
"id": "1",
"prompt": "Check Longhorn and CNPG recovery progress in the homelab cluster and report terse. Run: kubectl -n longhorn-system get volumes.longhorn.io -o json (count by state/robustness), kubectl -n longhorn-system get replicas.longhorn.io -o json (count running/stopped/failed), kubectl get clusters.postgresql.cnpg.io -A (ready instances per cluster), kubectl -n cicd get pods,pvc | grep forgejo-db. Report: volumes healthy vs degraded, replicas running vs stopped, and each CNPG cluster ready count. If every volume is attached/healthy and every CNPG cluster shows 3/3 ready, say RECOVERED, list any leftover anomalies, and stop this loop with LoopDelete. Otherwise name what is still rebuilding and keep going.",
"trigger": {
"type": "cron",
"schedule": "*/5 * * * *"
},
"status": "expired",
"recurring": true,
"createdAt": 1792898370946,
"updatedAt": 1792901371403,
"expiresAt": 1793503170946,
"source": "tool",
"readOnly": true,
"maxFires": 6,
"fireCount": 6
}
]
}
@@ -1,37 +0,0 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "Poimen Memory — Schema Relations",
"quality_profile": "showcase",
"viewBox": [900, 480]
},
"components": [
{ "id": "node", "type": "database", "label": "memory_node", "sublabel": "Episodes (raw messages)", "pos": [40, 60], "size": [150, 55], "tag": "BIGSERIAL" },
{ "id": "entity", "type": "database", "label": "memory_entity", "sublabel": "Extracted entities", "pos": [260, 60], "size": [150, 55], "tag": "UUID" },
{ "id": "edge", "type": "database", "label": "memory_edge", "sublabel": "Facts / relationships", "pos": [480, 60], "size": [150, 55], "tag": "UUID" },
{ "id": "community", "type": "database", "label": "memory_community", "sublabel": "Entity clusters", "pos": [700, 60], "size": [150, 55], "tag": "UUID" },
{ "id": "pgvector", "type": "cloud", "label": "pgvector HNSW", "sublabel": "768-dim cosine search", "pos": [140, 260], "size": [160, 55], "tag": "nomic-embed-v2" },
{ "id": "tsvector", "type": "cloud", "label": "pg ts_rank", "sublabel": "Full-text search", "pos": [400, 260], "size": [160, 55], "tag": "GIN index" },
{ "id": "tenant", "type": "security", "label": "tenant_id", "sublabel": "Multi-tenant isolation", "pos": [660, 260], "size": [140, 55], "tag": "W7 / mig 014" }
],
"boundaries": [
{ "kind": "region", "label": "Temporal Knowledge Graph (Zep §2)", "wraps": ["node", "entity", "edge", "community"] },
{ "kind": "security-group", "label": "Search & Isolation", "wraps": ["pgvector", "tsvector", "tenant"] }
],
"connections": [
{ "id": "node-entity", "from": "node", "to": "entity", "variant": "emphasis" },
{ "id": "entity-edge", "from": "entity", "to": "edge", "variant": "emphasis" },
{ "id": "entity-community", "from": "community", "to": "entity", "variant": "dashed" },
{ "id": "edge-node", "from": "edge", "to": "node", "variant": "dashed" },
{ "id": "entity-vec", "from": "entity", "to": "pgvector", "variant": "default" },
{ "id": "entity-fts", "from": "entity", "to": "tsvector", "variant": "dashed" },
{ "id": "edge-fts", "from": "edge", "to": "tsvector", "variant": "dashed" },
{ "id": "tenant-all", "from": "tenant", "to": "entity", "variant": "security" }
],
"cards": [
{ "dot": "emerald", "title": "Three-Tier Graph", "items": ["memory_node → episode subgraph", "memory_entity → entity subgraph", "memory_edge → fact relationships", "memory_community → clusters (Phase 4)"] },
{ "dot": "rose", "title": "Bi-Temporal", "items": ["t_valid / t_invalid: event time", "t_created / t_expired: transaction time", "Contradiction: active → candidate → confirmed"] },
{ "dot": "cyan", "title": "Search", "items": ["HNSW: name, summary, fact embeddings", "ts_rank: BM25 fallback (always available)", "tenant_id: per-table isolation (mig 014)"] }
]
}
@@ -1,158 +0,0 @@
{
"schema_version": 1,
"diagram_type": "dataflow",
"meta": {
"title": "Poimen Memory \u2014 Schema Relations",
"viewBox": [
960,
480
],
"quality_profile": "showcase"
},
"stages": [
{
"label": "Episodes"
},
{
"label": "Entities"
},
{
"label": "Relationships"
},
{
"label": "Indexes"
}
],
"nodes": [
{
"id": "node",
"type": "database",
"label": "memory_node",
"sublabel": "Raw messages",
"stage": 0,
"row": 0,
"tag": "BIGSERIAL"
},
{
"id": "entity",
"type": "database",
"label": "memory_entity",
"sublabel": "Named entities",
"stage": 1,
"row": 0,
"tag": "UUID + tenant_id"
},
{
"id": "community",
"type": "database",
"label": "memory_community",
"sublabel": "Clusters (Phase 4)",
"stage": 1,
"row": 1,
"tag": "UUID"
},
{
"id": "edge",
"type": "database",
"label": "memory_edge",
"sublabel": "Facts + bi-temporal",
"stage": 2,
"row": 0,
"tag": "UUID + tenant_id"
},
{
"id": "hnsw",
"type": "cloud",
"label": "HNSW Index",
"sublabel": "768-dim cosine",
"stage": 3,
"row": 0,
"tag": "pgvector"
},
{
"id": "fts",
"type": "cloud",
"label": "GIN Index",
"sublabel": "ts_rank BM25",
"stage": 3,
"row": 1,
"tag": "full-text"
}
],
"flows": [
{
"from": "node",
"to": "entity",
"label": "LLM extraction",
"classification": "ingest",
"variant": "emphasis",
"route": "straight"
},
{
"from": "entity",
"to": "edge",
"label": "source + target FK",
"classification": "foreign key",
"variant": "emphasis",
"route": "straight"
},
{
"from": "entity",
"to": "community",
"label": "community_id",
"classification": "membership",
"variant": "dashed",
"route": "straight",
"labelDy": 30
},
{
"from": "edge",
"to": "hnsw",
"classification": "vector",
"variant": "default",
"route": "straight",
"label": "embedding",
"labelDy": -30
},
{
"from": "edge",
"to": "fts",
"label": "fact text",
"classification": "text",
"variant": "dashed",
"route": "vertical-channel",
"labelDy": 30
}
],
"cards": [
{
"dot": "emerald",
"title": "Three-Tier Graph",
"items": [
"memory_node \u2192 episodes (raw messages)",
"memory_entity \u2192 extracted entities",
"memory_edge \u2192 facts between entities",
"memory_community \u2192 clusters (Phase 4)"
]
},
{
"dot": "rose",
"title": "Bi-Temporal + Tenant",
"items": [
"t_valid/t_invalid: when true in reality",
"t_created/t_expired: system transaction time",
"tenant_id: row-level multi-tenant isolation",
"Contradiction: active \u2192 candidate \u2192 confirmed"
]
},
{
"dot": "cyan",
"title": "Search Indexes",
"items": [
"HNSW: name, summary, fact embeddings",
"GIN: ts_rank on name+summary and fact",
"RRF fusion: sem_weight + lex_weight"
]
}
]
}
@@ -1,63 +0,0 @@
{
"schema_version": 1,
"diagram_type": "sequence",
"meta": {
"title": "Service Acting on Behalf of User",
"viewBox": [1000, 600],
"column_fit": "spread",
"quality_profile": "showcase"
},
"participants": [
{ "id": "user", "type": "external", "label": "User", "sublabel": "browser" },
{ "id": "app", "type": "frontend", "label": "Web App", "sublabel": "poimen UI" },
{ "id": "authentik", "type": "security", "label": "Authentik", "sublabel": "OIDC" },
{ "id": "worker", "type": "backend", "label": "Backend Worker", "sublabel": "temporal/queue" },
{ "id": "memory", "type": "backend", "label": "Memory API", "sublabel": "poimen-memory" },
{ "id": "jwks", "type": "security", "label": "JWKS", "sublabel": "RS256" }
],
"segments": [
{ "from": 150, "to": 260, "label": "User Auth" },
{ "from": 270, "to": 370, "label": "Delegation" },
{ "from": 380, "to": 510, "label": "Downstream" }
],
"messages": [
{ "id": "login", "from": "user", "to": "app", "y": 165, "label": "login (OIDC)", "variant": "default" },
{ "id": "oidc-flow", "from": "app", "to": "authentik", "y": 200, "label": "authorization_code flow", "variant": "emphasis" },
{ "id": "user-jwt", "from": "authentik", "to": "app", "y": 235, "label": "user JWT {groups,roles}", "variant": "return" },
{ "id": "trigger-job", "from": "user", "to": "app", "y": 285, "label": "trigger async job", "variant": "default" },
{ "id": "queue-job", "from": "app", "to": "worker", "y": 320, "label": "enqueue job + user JWT", "variant": "emphasis", "note": "JWT passed as job metadata" },
{ "id": "job-ack", "from": "worker", "to": "app", "y": 355, "label": "job queued", "variant": "return" },
{ "id": "call-memory", "from": "worker", "to": "memory", "y": 400, "label": "POST /memory/ingest", "variant": "emphasis", "note": "Bearer <user JWT>" },
{ "id": "fetch-jwks", "from": "memory", "to": "jwks", "y": 435, "label": "GET /jwks", "variant": "dashed" },
{ "id": "jwks-keys", "from": "jwks", "to": "memory", "y": 470, "label": "RSA pubkey", "variant": "return" },
{ "id": "ingest-ok", "from": "memory", "to": "worker", "y": 505, "label": "202 Accepted", "variant": "return", "note": "uses USER's groups/roles" }
],
"activations": [
{ "participant": "user", "from": 160, "to": 291, "type": "external" },
{ "participant": "app", "from": 165, "to": 361, "type": "frontend" },
{ "participant": "authentik", "from": 195, "to": 241, "type": "security" },
{ "participant": "worker", "from": 315, "to": 511, "type": "backend" },
{ "participant": "memory", "from": 395, "to": 511, "type": "backend" },
{ "participant": "jwks", "from": 430, "to": 476, "type": "security" }
],
"cards": [
{
"dot": "emerald",
"title": "Delegation Pattern",
"items": [
"User JWT travels with the job payload",
"Worker uses user's token for downstream",
"Memory sees USER as the principal"
]
},
{
"dot": "rose",
"title": "Security Model",
"items": [
"No service-account escalation",
"User's permissions bound the action",
"Audit trail traces to user.sub"
]
}
]
}
@@ -1,42 +0,0 @@
{
"schema_version": 1,
"diagram_type": "architecture",
"meta": {
"title": "Memory Service Fine-Grained RBAC",
"quality_profile": "showcase",
"viewBox": [900, 520]
},
"components": [
{ "id": "jwt", "type": "security", "label": "JWT Token", "sublabel": "groups[] + roles[]", "pos": [40, 60], "size": [140, 60], "tag": "Authentik" },
{ "id": "cap-check", "type": "backend", "label": "Capability Check", "sublabel": "has_capability()", "pos": [260, 60], "size": [160, 60], "tag": "Phase 1" },
{ "id": "scope-checker", "type": "backend", "label": "Scope Checker", "sublabel": "filter_resources()", "pos": [500, 60], "size": [160, 60], "tag": "Phase 2" },
{ "id": "decision", "type": "backend", "label": "AccessDecision", "sublabel": "Allow / Deny", "pos": [740, 60], "size": [130, 60] },
{ "id": "project-scope", "type": "database", "label": "ProjectScope", "sublabel": "dev/staging/prod/*", "pos": [40, 200], "size": [140, 55] },
{ "id": "visibility-scope", "type": "database", "label": "VisibilityScope", "sublabel": "public/private", "pos": [220, 200], "size": [140, 55] },
{ "id": "owner-scope", "type": "database", "label": "OwnerScope", "sublabel": "self/any/user", "pos": [400, 200], "size": [140, 55] },
{ "id": "group-scope", "type": "database", "label": "GroupScope", "sublabel": "in_any_group()", "pos": [580, 200], "size": [140, 55] },
{ "id": "entities", "type": "database", "label": "Entities", "sublabel": "memory_entity", "pos": [40, 340], "size": [120, 50] },
{ "id": "edges", "type": "database", "label": "Edges", "sublabel": "memory_edge", "pos": [200, 340], "size": [120, 50] },
{ "id": "projects", "type": "database", "label": "Projects", "sublabel": "project_id", "pos": [360, 340], "size": [120, 50] },
{ "id": "convos", "type": "database", "label": "Conversations", "sublabel": "owner_id", "pos": [520, 340], "size": [120, 50] }
],
"boundaries": [
{ "kind": "security-group", "label": "Access Control Pipeline", "wraps": ["cap-check", "scope-checker", "decision"] },
{ "kind": "region", "label": "Scope Checkers", "wraps": ["project-scope", "visibility-scope", "owner-scope", "group-scope"] },
{ "kind": "region", "label": "Protected Resources", "wraps": ["entities", "edges", "projects", "convos"] }
],
"connections": [
{ "id": "jwt-to-cap", "from": "jwt", "to": "cap-check", "label": "claims", "variant": "emphasis" },
{ "id": "cap-to-scope", "from": "cap-check", "to": "scope-checker", "variant": "emphasis" },
{ "id": "scope-to-decision", "from": "scope-checker", "to": "decision", "variant": "emphasis" },
{ "id": "scope-to-proj", "from": "scope-checker", "to": "project-scope", "variant": "dashed", "fromSide": "bottom", "toSide": "top" },
{ "id": "scope-to-vis", "from": "scope-checker", "to": "visibility-scope", "variant": "dashed", "fromSide": "bottom", "toSide": "top" },
{ "id": "scope-to-owner", "from": "scope-checker", "to": "owner-scope", "variant": "dashed", "fromSide": "bottom", "toSide": "top" },
{ "id": "scope-to-group", "from": "scope-checker", "to": "group-scope", "variant": "dashed", "fromSide": "bottom", "toSide": "top" }
],
"cards": [
{ "dot": "emerald", "title": "Phase 1: Capability", "items": ["has_capability(memory:read)", "Checks roles[] claim", "Binary allow/deny"] },
{ "dot": "rose", "title": "Phase 2: Scope", "items": ["Project: dev/staging/prod", "Visibility: public/private", "Owner: self-owned only", "Group: poimen-admins/devs"] },
{ "dot": "cyan", "title": "JWT Claims", "items": ["groups: user.groups", "roles: attributes.roles", "permissions: group names"] }
]
}
-54
View File
@@ -1,54 +0,0 @@
{
"schema_version": 1,
"diagram_type": "sequence",
"meta": {
"title": "Service-to-Service (M2M) Authentication",
"viewBox": [1080, 520],
"column_fit": "spread",
"quality_profile": "showcase"
},
"participants": [
{ "id": "worker", "type": "backend", "label": "Ingest Worker", "sublabel": "temporal/cron" },
{ "id": "authentik", "type": "security", "label": "Authentik", "sublabel": "OIDC provider" },
{ "id": "memory", "type": "backend", "label": "Memory Service", "sublabel": "poimen-memory" },
{ "id": "jwks", "type": "security", "label": "JWKS", "sublabel": "RS256 keys" }
],
"segments": [
{ "from": 150, "to": 230, "label": "Token Mint" },
{ "from": 240, "to": 380, "label": "API + RBAC" }
],
"messages": [
{ "id": "token-req", "from": "worker", "to": "authentik", "y": 165, "label": "POST /token grant_type=password", "variant": "emphasis", "note": "client_id + username + app_password + scope" },
{ "id": "jwt-resp", "from": "authentik", "to": "worker", "y": 205, "label": "JWT {groups,roles,aud,iss}", "variant": "return", "note": "groups=[poimen-admins] roles=[memory:write]" },
{ "id": "api-call", "from": "worker", "to": "memory", "y": 260, "label": "POST /memory/ingest", "variant": "emphasis", "note": "Authorization: Bearer <JWT>" },
{ "id": "fetch-jwks", "from": "memory", "to": "jwks", "y": 295, "label": "GET /jwks (cached)", "variant": "dashed" },
{ "id": "jwks-resp", "from": "jwks", "to": "memory", "y": 330, "label": "RSA pubkey (kid)", "variant": "return" },
{ "id": "verify-ok", "from": "memory", "to": "worker", "y": 365, "label": "202 Accepted", "variant": "return", "note": "sig ✓ iss ✓ aud ✓ has_capability ✓" }
],
"activations": [
{ "participant": "worker", "from": 160, "to": 371, "type": "backend" },
{ "participant": "authentik", "from": 165, "to": 211, "type": "security" },
{ "participant": "memory", "from": 255, "to": 371, "type": "backend" },
{ "participant": "jwks", "from": 290, "to": 336, "type": "security" }
],
"cards": [
{
"dot": "emerald",
"title": "M2M Pattern",
"items": [
"grant_type=password (Authentik aliases client_credentials)",
"App-password token authenticates the service account",
"NOT client_secret — that creates nameless SA"
]
},
{
"dot": "rose",
"title": "JWT Claims",
"items": [
"groups: from user.groups (poimen-admins)",
"roles: from user.attributes.roles",
"iss/aud: per-provider issuer"
]
}
]
}
-61
View File
@@ -1,61 +0,0 @@
{
"schema_version": 1,
"diagram_type": "sequence",
"meta": {
"title": "User Direct Access with JWT",
"viewBox": [1000, 640],
"column_fit": "spread",
"quality_profile": "showcase"
},
"participants": [
{ "id": "user", "type": "external", "label": "User", "sublabel": "browser" },
{ "id": "app", "type": "frontend", "label": "Web App", "sublabel": "poimen UI" },
{ "id": "authentik", "type": "security", "label": "Authentik", "sublabel": "OIDC provider" },
{ "id": "memory", "type": "backend", "label": "Memory API", "sublabel": "poimen-memory" },
{ "id": "jwks", "type": "security", "label": "JWKS", "sublabel": "RS256 keys" }
],
"segments": [
{ "from": 150, "to": 370, "label": "OIDC Login" },
{ "from": 380, "to": 540, "label": "API + RBAC" }
],
"messages": [
{ "id": "click-login", "from": "user", "to": "app", "y": 165, "label": "click Login", "variant": "default" },
{ "id": "redirect-auth", "from": "app", "to": "authentik", "y": 200, "label": "redirect /authorize", "variant": "emphasis", "note": "scope=openid groups roles" },
{ "id": "show-login", "from": "authentik", "to": "user", "y": 235, "label": "login form", "variant": "default" },
{ "id": "submit-creds", "from": "user", "to": "authentik", "y": 270, "label": "username + password", "variant": "security" },
{ "id": "auth-code", "from": "authentik", "to": "app", "y": 305, "label": "redirect ?code=xxx", "variant": "return" },
{ "id": "exchange-token", "from": "app", "to": "authentik", "y": 340, "label": "POST /token code=xxx", "variant": "emphasis" },
{ "id": "jwt-resp", "from": "authentik", "to": "app", "y": 375, "label": "JWT {groups,roles,sub}", "variant": "return" },
{ "id": "query-api", "from": "app", "to": "memory", "y": 420, "label": "GET /memory/query", "variant": "emphasis", "note": "Authorization: Bearer <JWT>" },
{ "id": "fetch-jwks", "from": "memory", "to": "jwks", "y": 455, "label": "GET /jwks (cached)", "variant": "dashed" },
{ "id": "jwks-keys", "from": "jwks", "to": "memory", "y": 490, "label": "RSA pubkey", "variant": "return" },
{ "id": "query-resp", "from": "memory", "to": "app", "y": 525, "label": "200 JSON results", "variant": "return", "note": "has_capability(memory:read) ✓" }
],
"activations": [
{ "participant": "user", "from": 160, "to": 276, "type": "external" },
{ "participant": "app", "from": 165, "to": 531, "type": "frontend" },
{ "participant": "authentik", "from": 195, "to": 381, "type": "security" },
{ "participant": "memory", "from": 415, "to": 531, "type": "backend" },
{ "participant": "jwks", "from": 450, "to": 496, "type": "security" }
],
"cards": [
{
"dot": "emerald",
"title": "Authorization Code Flow",
"items": [
"User authenticates directly with Authentik",
"App exchanges code for JWT",
"Scopes: openid groups roles permissions"
]
},
{
"dot": "rose",
"title": "JWT Contents",
"items": [
"iss: .../application/o/poimen/",
"aud: poimen (provider client_id)",
"groups: user's group memberships"
]
}
]
}
-56
View File
@@ -1,56 +0,0 @@
# cloudflared ingress configuration.
#
# This list is the tunnel's routing table: for each incoming hostname the
# connector dials the matching origin. The final rule is a catch-all, so a
# hostname absent from this list gets 404 from the connector — DNS alone does
# not route a request into a service.
#
# Management model: this file makes the tunnel LOCALLY managed. cloudflared is
# given a credentials file instead of a token, so Cloudflare stops pushing the
# remotely-managed ingess list and this ConfigMap wins. Revert the Deployment to
# `run --token` to return to the dashboard-managed list.
#
# Origin services self-reference the public hostname. That resolves because
# CoreDNS rewrites each of these names to ingress-nginx
# (terraform/files/coredns/Corefile), so the connector reaches the in-cluster
# ingress instead of looping back out through Cloudflare.
apiVersion: v1
kind: ConfigMap
metadata:
name: cloudflared-config
namespace: cloudflared
data:
config.yml: |
tunnel: 32d0bf93-e1bd-45b8-93a9-71ff9f83de80
credentials-file: /etc/cloudflared/creds/credentials.json
# WARP-to-Tunnel routing. The remotely-managed config had this enabled;
# dropping the key would break WARP access to private addresses. cloudflared
# 2026.x has no `enabled` field — the key's presence is what enables it, and
# `enabled: true` here makes the binary warn about an unused key.
warp-routing: {}
ingress:
- hostname: paperless.riotpiao.com
service: https://paperless.riotpiao.com
- hostname: img.riotpiao.com
service: https://img.riotpiao.com
- hostname: riotpiao.com
service: https://riotpiao.com
- hostname: grafana.riotpiao.com
service: https://grafana.riotpiao.com
- hostname: gotify.riotpiao.com
service: https://gotify.riotpiao.com
- hostname: igotify.riotpiao.com
service: https://igotify.riotpiao.com
- hostname: poimen.riotpiao.com
service: https://poimen.riotpiao.com
- hostname: comfyui.riotpiao.com
service: https://comfyui.riotpiao.com
- hostname: forgejo.riotpiao.com
service: https://forgejo.riotpiao.com
# ZITADEL. Without this rule the connector answers 404 for the hostname
# even though the DNS CNAME points at the tunnel.
- hostname: zitadel.riotpiao.com
service: https://zitadel.riotpiao.com
- service: http_status:404
+7 -49
View File
@@ -13,63 +13,21 @@ spec:
labels:
app: cloudflared
spec:
# Ingress-nginx forces HTTPS (force-ssl-redirect), so cloudflared must
# dial https://<hostname> and trust homelab-ca or the TLS handshake fails
# with "certificate signed by unknown authority". Same pattern as
# iam values: merge the system bundle with homelab-ca.crt.
initContainers:
- name: merge-ca-certs
image: debian:bookworm
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
command:
- sh
- -c
- (cat /etc/ssl/certs/ca-certificates.crt 2>/dev/null; cat /homelab-ca/ca.crt) > /merged/ca-bundle.crt
volumeMounts:
- name: homelab-ca
mountPath: /homelab-ca
readOnly: true
- name: merged-ca
mountPath: /merged
volumes:
- name: config
configMap:
name: cloudflared-config
- name: credentials
secret:
secretName: cloudflared-credentials
- name: homelab-ca
configMap:
name: homelab-ca
- name: merged-ca
emptyDir: {}
containers:
- name: cloudflared
image: cloudflare/cloudflared:latest
args:
- tunnel
- --no-autoupdate
- --protocol
- http2
- --config
- /etc/cloudflared/config.yml
- run
- --token
- $(TUNNEL_TOKEN)
env:
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
volumeMounts:
- name: config
mountPath: /etc/cloudflared/config.yml
subPath: config.yml
readOnly: true
- name: credentials
mountPath: /etc/cloudflared/creds
readOnly: true
- name: merged-ca
mountPath: /merged
readOnly: true
- name: TUNNEL_TOKEN
valueFrom:
secretKeyRef:
name: cloudflared-token
key: token
resources:
requests:
cpu: 10m
-1
View File
@@ -3,4 +3,3 @@ kind: Kustomization
namespace: cloudflared
resources:
- deployment.yaml
- configmap.yaml
+8 -13
View File
@@ -4,27 +4,22 @@ metadata:
name: comfyui
namespace: comfyui
annotations:
# 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).
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
# WebSocket support for ComfyUI's live preview
nginx.ingress.kubernetes.io/proxy-http-version: "1.1"
nginx.ingress.kubernetes.io/upstream-hash-by: "$remote_addr"
# Single sign-on. ComfyUI has no auth of its own, so every request is handed to
# oauth2-proxy for an allow or deny decision.
nginx.ingress.kubernetes.io/auth-url: "http://comfyui-oauth2-proxy.comfyui.svc.cluster.local:4180/oauth2/auth"
nginx.ingress.kubernetes.io/auth-signin: "https://comfyui.riotpiao.com/oauth2/start?rd=$scheme://$host$request_uri"
nginx.ingress.kubernetes.io/auth-response-headers: "x-auth-request-user,x-auth-request-email"
# No configuration-snippet. The controller rejects that annotation as risky
# ("annotation group ConfigurationSnippet contains risky annotation"), and a
# rejected annotation drops the WHOLE ingress from nginx.conf: the host then
# answers 404 with no entry in the access log. ingress-nginx sets the Upgrade
# and Connection headers itself for a websocket upgrade.
nginx.ingress.kubernetes.io/configuration-snippet: |
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
spec:
ingressClassName: nginx
tls:
- secretName: comfyui-tls
hosts:
- comfyui.riotpiao.com
rules:
- host: comfyui.riotpiao.com
http:
-1
View File
@@ -4,5 +4,4 @@ kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- oauth2-proxy.yaml
- ingress.yaml
-105
View File
@@ -1,105 +0,0 @@
# ComfyUI has no authentication of its own: comfyui.riotpiao.com is open to anyone
# who can resolve the host. It also has no OIDC support, so ZITADEL cannot be a
# login method inside it. oauth2-proxy is the front door instead: nginx asks it for
# a decision on every request and it answers from a ZITADEL session.
#
# No API client calls this host today, so the whole host is gated. If a client ever
# needs direct access, add a skip rule for the exact paths it uses rather than
# opening the whole host.
apiVersion: apps/v1
kind: Deployment
metadata:
name: comfyui-oauth2-proxy
namespace: comfyui
labels:
app: comfyui-oauth2-proxy
spec:
replicas: 1
selector:
matchLabels:
app: comfyui-oauth2-proxy
template:
metadata:
labels:
app: comfyui-oauth2-proxy
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile: {type: RuntimeDefault}
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
args:
- --provider=oidc
- --oidc-issuer-url=https://zitadel.riotpiao.com
- --email-domain=*
- --upstream=static://202
- --reverse-proxy=true
- --set-xauthrequest=true
- --cookie-secure=true
- --cookie-expire=12h
- --cookie-refresh=1h
- --skip-provider-button=true
- --redirect-url=https://comfyui.riotpiao.com/oauth2/callback
- --http-address=0.0.0.0:4180
env:
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom: {secretKeyRef: {name: comfyui-oauth2-proxy, key: client-id}}
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom: {secretKeyRef: {name: comfyui-oauth2-proxy, key: client-secret}}
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom: {secretKeyRef: {name: comfyui-oauth2-proxy, key: cookie-secret}}
ports:
- {name: http, containerPort: 4180}
readinessProbe:
httpGet: {path: /ping, port: http}
initialDelaySeconds: 5
livenessProbe:
httpGet: {path: /ping, port: http}
initialDelaySeconds: 15
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"]}
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {cpu: 200m, memory: 128Mi}
---
apiVersion: v1
kind: Service
metadata:
name: comfyui-oauth2-proxy
namespace: comfyui
labels:
app: comfyui-oauth2-proxy
spec:
type: ClusterIP
selector:
app: comfyui-oauth2-proxy
ports:
- {name: http, port: 4180, targetPort: http}
---
# The login path must NOT be gated: a request to /oauth2/start that itself demands a
# login redirects to /oauth2/start forever. ingress-nginx applies the annotations of
# an ingress to the locations it defines, so a separate object without auth-url keeps
# this path open. The earlier failure of this shape was not the split — it was the
# configuration-snippet on the app ingress, which made the controller drop the whole
# object and left only this one alive.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: comfyui-oauth2
namespace: comfyui
spec:
ingressClassName: nginx
rules:
- host: comfyui.riotpiao.com
http:
paths:
- path: /oauth2
pathType: Prefix
backend:
service:
name: comfyui-oauth2-proxy
port:
number: 4180
-110
View File
@@ -1,110 +0,0 @@
# The bridge script is mounted as a ConfigMap so no image build is needed.
# Regenerate this block from grafana_gotify_bridge.py when the script changes.
apiVersion: v1
kind: ConfigMap
metadata:
name: grafana-gotify-bridge
namespace: notifications
data:
grafana_gotify_bridge.py: |
#!/usr/bin/env python3
"""Translate a Grafana alert webhook into a Gotify message.
Grafana sends its own payload shape:
{"receiver": "...", "status": "firing", "alerts": [{"labels": {...}, "annotations": {...}}]}
Gotify requires:
{"title": "...", "message": "...", "priority": 0..10}
Gotify answers 400 "Field 'message' is required" to a Grafana payload, so a
conversion step is mandatory. This service is that step and nothing else.
Standard library only, so it needs no image build and no dependencies.
"""
import json
import os
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
GOTIFY_BASE = os.environ.get("GOTIFY_URL", "http://gotify.notifications.svc.cluster.local")
GOTIFY_TOKEN = os.environ["GOTIFY_TOKEN"]
LISTEN_PORT = int(os.environ.get("PORT", "8080"))
# Firing is louder than resolved. Gotify priority runs 0 to 10.
PRIORITY = {"firing": 8, "resolved": 3}
def to_gotify(payload):
alerts = payload.get("alerts") or []
status = (payload.get("status") or "firing").lower()
priority = PRIORITY.get(status, 5)
if not alerts:
title = payload.get("title") or "Grafana notification"
return title, json.dumps(payload)[:2000], priority
first = alerts[0]
labels = first.get("labels") or {}
annotations = first.get("annotations") or {}
name = labels.get("alertname", "Grafana alert")
title = "[%s] %s" % (status.upper(), name)
lines = []
summary = annotations.get("summary") or annotations.get("description")
if summary:
lines.append(summary)
for key in ("severity", "namespace", "pod", "instance", "job"):
if labels.get(key):
lines.append("%s: %s" % (key, labels[key]))
if len(alerts) > 1:
lines.append("+%d more alert(s) in this notification" % (len(alerts) - 1))
return title, "\n".join(lines) or name, priority
class Handler(BaseHTTPRequestHandler):
def _reply(self, code, body=b"ok"):
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._reply(200)
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8", "replace") or "{}")
except ValueError:
payload = {}
title, message, priority = to_gotify(payload)
body = json.dumps({
"title": title[:200],
"message": message[:4000],
"priority": priority,
}).encode()
request = urllib.request.Request(
GOTIFY_BASE.rstrip("/") + "/message",
data=body,
headers={"Content-Type": "application/json", "X-Gotify-Key": GOTIFY_TOKEN},
)
try:
urllib.request.urlopen(request, timeout=10).read()
except Exception as exc: # noqa: BLE001 - log and report to Grafana
print("forward to gotify failed: %s" % exc, flush=True)
self._reply(502, b"gotify rejected the message")
return
print("forwarded: %s" % title, flush=True)
self._reply(200)
def log_message(self, fmt, *args): # keep the log to one line per delivery
print("%s - %s" % (self.address_string(), fmt % args), flush=True)
if __name__ == "__main__":
print("grafana-gotify bridge listening on %d" % LISTEN_PORT, flush=True)
HTTPServer(("0.0.0.0", LISTEN_PORT), Handler).serve_forever()
-80
View File
@@ -1,80 +0,0 @@
# Grafana sends its own JSON and Gotify rejects it, so this Deployment is the
# translation step between them. Grafana posts to the Service, the bridge posts
# to /message with the app token from a SOPS secret.
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana-gotify-bridge
namespace: notifications
labels:
app: grafana-gotify-bridge
spec:
replicas: 1
selector:
matchLabels:
app: grafana-gotify-bridge
template:
metadata:
labels:
app: grafana-gotify-bridge
spec:
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: bridge
image: python:3.12-alpine
command: ["python3", "/app/grafana_gotify_bridge.py"]
env:
- name: GOTIFY_URL
value: "http://gotify.notifications.svc.cluster.local"
- name: PORT
value: "8080"
- name: GOTIFY_TOKEN
valueFrom:
secretKeyRef: {name: grafana-gotify-bridge, key: app-token}
ports:
- name: http
containerPort: 8080
readinessProbe:
httpGet: {path: /, port: http}
initialDelaySeconds: 3
periodSeconds: 10
livenessProbe:
httpGet: {path: /, port: http}
initialDelaySeconds: 10
periodSeconds: 20
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: script
mountPath: /app
readOnly: true
resources:
requests: {cpu: 10m, memory: 32Mi}
limits: {cpu: 200m, memory: 96Mi}
volumes:
- name: script
configMap:
name: grafana-gotify-bridge
---
apiVersion: v1
kind: Service
metadata:
name: grafana-gotify-bridge
namespace: notifications
labels:
app: grafana-gotify-bridge
spec:
type: ClusterIP
selector:
app: grafana-gotify-bridge
ports:
- name: http
port: 8080
targetPort: http
@@ -1,101 +0,0 @@
#!/usr/bin/env python3
"""Translate a Grafana alert webhook into a Gotify message.
Grafana sends its own payload shape:
{"receiver": "...", "status": "firing", "alerts": [{"labels": {...}, "annotations": {...}}]}
Gotify requires:
{"title": "...", "message": "...", "priority": 0..10}
Gotify answers 400 "Field 'message' is required" to a Grafana payload, so a
conversion step is mandatory. This service is that step and nothing else.
Standard library only, so it needs no image build and no dependencies.
"""
import json
import os
import urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
GOTIFY_BASE = os.environ.get("GOTIFY_URL", "http://gotify.notifications.svc.cluster.local")
GOTIFY_TOKEN = os.environ["GOTIFY_TOKEN"]
LISTEN_PORT = int(os.environ.get("PORT", "8080"))
# Firing is louder than resolved. Gotify priority runs 0 to 10.
PRIORITY = {"firing": 8, "resolved": 3}
def to_gotify(payload):
alerts = payload.get("alerts") or []
status = (payload.get("status") or "firing").lower()
priority = PRIORITY.get(status, 5)
if not alerts:
title = payload.get("title") or "Grafana notification"
return title, json.dumps(payload)[:2000], priority
first = alerts[0]
labels = first.get("labels") or {}
annotations = first.get("annotations") or {}
name = labels.get("alertname", "Grafana alert")
title = "[%s] %s" % (status.upper(), name)
lines = []
summary = annotations.get("summary") or annotations.get("description")
if summary:
lines.append(summary)
for key in ("severity", "namespace", "pod", "instance", "job"):
if labels.get(key):
lines.append("%s: %s" % (key, labels[key]))
if len(alerts) > 1:
lines.append("+%d more alert(s) in this notification" % (len(alerts) - 1))
return title, "\n".join(lines) or name, priority
class Handler(BaseHTTPRequestHandler):
def _reply(self, code, body=b"ok"):
self.send_response(code)
self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
self._reply(200)
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(length) if length else b"{}"
try:
payload = json.loads(raw.decode("utf-8", "replace") or "{}")
except ValueError:
payload = {}
title, message, priority = to_gotify(payload)
body = json.dumps({
"title": title[:200],
"message": message[:4000],
"priority": priority,
}).encode()
request = urllib.request.Request(
GOTIFY_BASE.rstrip("/") + "/message",
data=body,
headers={"Content-Type": "application/json", "X-Gotify-Key": GOTIFY_TOKEN},
)
try:
urllib.request.urlopen(request, timeout=10).read()
except Exception as exc: # noqa: BLE001 - log and report to Grafana
print("forward to gotify failed: %s" % exc, flush=True)
self._reply(502, b"gotify rejected the message")
return
print("forwarded: %s" % title, flush=True)
self._reply(200)
def log_message(self, fmt, *args): # keep the log to one line per delivery
print("%s - %s" % (self.address_string(), fmt % args), flush=True)
if __name__ == "__main__":
print("grafana-gotify bridge listening on %d" % LISTEN_PORT, flush=True)
HTTPServer(("0.0.0.0", LISTEN_PORT), Handler).serve_forever()
+3 -11
View File
@@ -28,17 +28,9 @@ spec:
value: "true"
- name: ENABLE_SCALAR_UI
value: "true"
# The bridge authenticates with a Gotify client token, not with
# ZITADEL. Gotify has no way to accept a forwarded identity, so the
# token is the only credential that works server-side. The token
# belongs to user `rock`, never expires, and is revoked by deleting
# the client row in Gotify.
- name: GOTIFY_URLS
value: "https://gotify.riotpiao.com"
- name: GOTIFY_CLIENT_TOKENS
valueFrom:
secretKeyRef: {name: igotify-client-token, key: client-token}
# SECNTFY_TOKENS intentionally left unset: secntfy is not deployed.
# GOTIFY_URLS / GOTIFY_CLIENT_TOKENS / SECNTFY_TOKENS intentionally
# left unset — not required when Gotify is reachable via public domain
# (gotify.riotpiao.com). iGotify iOS app registers itself at runtime.
volumeMounts:
- name: data
mountPath: /app/data
-2
View File
@@ -6,8 +6,6 @@ resources:
- deployment.yaml
- service.yaml
- ingress.yaml
- bridge/configmap.yaml
- bridge/deployment.yaml
- igotify-pvc.yaml
- igotify-deployment.yaml
- igotify-service.yaml
+78
View File
@@ -0,0 +1,78 @@
# Homarr landing page with Authentik SSO
# Probe tuning (chart DOES expose these — the old PostSync patch-job was
# unnecessary and fragile: it only patched one Deployment revision, so any later
# rollout reverted to the chart's aggressive defaults). Homarr's first-boot icon
# updater blocks the event loop for ~50s ("icons updater took 49553ms"), during
# which /api/health/live can't answer within the default 10s×3 window → kubelet
# SIGTERMs the pod → CrashLoopBackOff (247 restarts, 503 at the ingress). Give
# liveness a wide window so the icon import can finish without a kill.
livenessProbe:
initialDelaySeconds: 60
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 10
readinessProbe:
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
failureThreshold: 6
image:
repository: ghcr.io/homarr-labs/homarr
tag: "latest"
pullPolicy: Always
replicaCount: 1
# Configure SSO via environment variables
# Chart supports these via top-level env dict (not array)
env:
AUTH_PROVIDERS: "oidc,credentials"
AUTH_OIDC_ISSUER: "https://authentik.riotpiao.com/application/o/homarr/"
# AUTH_OIDC_URI (authorize endpoint) is REQUIRED in addition to ISSUER — homarr
# hides the "Sign in with Authentik" button entirely when it's absent (per the
# authentik Homarr integration + homarr SSO docs). This was the missing var.
AUTH_OIDC_URI: "https://authentik.riotpiao.com/application/o/authorize/"
AUTH_OIDC_CLIENT_NAME: "Authentik"
AUTH_OIDC_GROUPS_ATTRIBUTE: "groups"
AUTH_OIDC_SCOPE_OVERWRITE: "openid email profile groups"
AUTH_OIDC_AUTO_LOGIN: "false"
# Link the OIDC identity to an existing homarr account with the same email.
OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING: "true"
# The analytics cron blocked the (single-threaded) Next.js event loop for ~16s
# per run ("callback took longer than expected"), compounding CPU pressure.
DISABLE_ANALYTICS: "true"
BASE_URL: "https://homarr.riotpiao.com"
NEXTAUTH_URL: "https://homarr.riotpiao.com"
# Client credentials from homarr-oidc secret
# Chart doesn't support envFrom, so we add via extraEnv
extraEnv:
- name: AUTH_OIDC_CLIENT_ID
valueFrom:
secretKeyRef:
name: homarr-oidc
key: client-id
- name: AUTH_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: homarr-oidc
key: client-secret
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
resources:
requests:
cpu: 250m
memory: 384Mi
limits:
# Next.js 16 + bundled redis + the icon-updater (28k icons) saturated the old
# 500m limit; CPU throttling made Next.js abort with exit 134 (SIGABRT) and
# self-restart in a loop, so nginx saw no upstream and returned 502. Give it
# real CPU headroom.
cpu: "2"
memory: 1Gi
+7
View File
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: dashboard
# Probes are now tuned via homarr-values.yaml (chart-native); the old
# fix-probes-job PostSync hook is removed. homarr-secrets/auth-oidc/db-encryption
# Secrets are delivered by the sops-secrets (ksops) Application.
resources: []
@@ -1,50 +0,0 @@
# Credential rotation
One `KeyRotation` object describes one credential. The controller creates the value,
writes the Secret, proves the new value works, then retires the old one.
## Why the state order is the safety property
```
Prepare -> Publish -> Verify -> Retire
```
`Retire` runs only for a provider whose old credential still works after the publish
step, and only after `Verify` passes. A failure before that point leaves the working
credential untouched. The hand-run rotation that broke Grafana did the opposite: it
regenerated the secret and never re-pointed the consumer.
## Two strategies, decided by the provider, not by preference
| Provider | Overlap in ZITADEL | Strategy | Retire |
|---|---|---|---|
| `zitadel-machine-key` | many keys per service user | `overlap` | after the new key obtains a token |
| `zitadel-oidc-client` | one secret per app | `atomic` | nothing to retire |
## The single master credential
The controller authenticates with one machine key, mounted from Secret
`iam/key-rotation-controller`. That key is the only credential the cluster cannot
rotate by itself, so it is the only one that stays encrypted in git.
Every other credential is created by the controller, which means the 34 `.enc.yaml`
files under `k8s/argocd/secrets/` are migration debt, not a design. The retirement
target is one root-of-trust file per thing that cannot rotate itself:
| Survives (root of trust) | Why it cannot rotate |
|---|---|
| ZITADEL instance masterkey | encrypts the whole ZITADEL database |
| Vault unseal keys | unsealing needs a human-held key |
| Talos repair config | recovery path, must work when the cluster does not |
| homelab CA | signs the wildcard certificate |
| controller bootstrap key | chicken and egg: it creates every other key |
| etcd defrag credentials | needed by a job that runs outside the controller |
Everything else — Grafana, Argo CD, Forgejo, MinIO, Paperless, Immich, ComfyUI,
api-gw, Gotify — is a KeyRotation object, and its secret leaves git.
## Status
The CRD is installed. Eight `KeyRotation` objects exist for the consumers above and
all are `suspend: true`: the controller image does not exist yet, so no process acts
on them. poimen-memory is excluded from this migration on purpose.
@@ -1,254 +0,0 @@
// Package api declares the KeyRotation custom resource.
//
// One resource describes one credential and how to rotate it. The controller
// never stores the credential in git: it writes a Kubernetes Secret and records
// only the credential's identifier in status.
package api
import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// Group is the API group for homelab platform resources.
const Group = "iam.homelab.riotpiao.com"
// Version is the served version.
const Version = "v1"
// Provider names. Each provider uses one external API to create and retire
// credentials, and each declares whether a rotation can overlap.
const (
// ProviderZitadelMachineKey creates a machine key while the previous key
// stays valid. True zero downtime.
ProviderZitadelMachineKey = "zitadel-machine-key"
// ProviderZitadelOIDCClient regenerates an app client secret. ZITADEL keeps
// one secret per app, so the old one dies the moment the new one exists.
ProviderZitadelOIDCClient = "zitadel-oidc-client"
)
// Strategy names.
const (
// StrategyOverlap keeps the old credential valid until the new one is proven.
StrategyOverlap = "overlap"
// StrategyAtomic replaces the credential in place. A short window exists.
StrategyAtomic = "atomic"
)
// Reload names tell the controller how the consumer picks up a new value.
const (
// ReloadHot means the consumer watches the mounted file.
ReloadHot = "hot"
// ReloadRestart means the consumer reads the value at start, so it must roll.
ReloadRestart = "restart"
)
// Phase names for status.
const (
PhasePending = "Pending"
PhasePreparing = "Preparing"
PhasePropagating = "Propagating"
PhaseVerifying = "Verifying"
PhaseCompleting = "Completing"
PhaseSucceeded = "Succeeded"
PhaseFailed = "Failed"
PhaseSuspended = "Suspended"
)
// ConsumerRef names a workload that reads the rotated credential.
type ConsumerRef struct {
// Kind is Deployment or StatefulSet.
Kind string `json:"kind"`
// Name is the workload name.
Name string `json:"name"`
// Namespace is where the workload runs.
Namespace string `json:"namespace"`
}
// RotationTarget is the credential's source and destination.
type RotationTarget struct {
// ZitadelProjectID is the project that owns the app or service user.
ZitadelProjectID string `json:"zitadelProjectID,omitempty"`
// ZitadelAppID is the OIDC app id, for the client-secret provider.
ZitadelAppID string `json:"zitadelAppID,omitempty"`
// MachineUserID is the service user id, for the machine-key provider.
MachineUserID string `json:"machineUserID,omitempty"`
// SecretName is the Secret the controller writes.
SecretName string `json:"secretName"`
// SecretNamespace is where that Secret lives.
SecretNamespace string `json:"secretNamespace"`
// SecretKeys maps a logical field name to the Secret key that holds it.
// For a machine key: key -> key, key-id -> key-id.
SecretKeys map[string]string `json:"secretKeys,omitempty"`
// Consumers are the workloads that must pick the new value up.
Consumers []ConsumerRef `json:"consumers,omitempty"`
// Reload is hot or restart. Empty means restart, the safe default.
Reload string `json:"reload,omitempty"`
}
// VerifySpec is the proof the controller demands before it retires the old
// credential.
type VerifySpec struct {
// Introspect checks client_id and client_secret against the introspection
// endpoint.
Introspect bool `json:"introspect,omitempty"`
// TokenExchange exchanges a token with the new machine key.
TokenExchange bool `json:"tokenExchange,omitempty"`
// AudienceProject, when set, is added to the token request as
// urn:zitadel:iam:org:project:id:<id>:aud.
AudienceProject string `json:"audienceProject,omitempty"`
}
// KeyRotationSpec is the desired state.
type KeyRotationSpec struct {
// Provider selects the external API.
Provider string `json:"provider"`
// Strategy is overlap or atomic.
Strategy string `json:"strategy"`
// IntervalHours is how often to rotate. Minimum 24.
IntervalHours int32 `json:"intervalHours"`
// Suspend stops automatic rotation without deleting the resource.
Suspend bool `json:"suspend,omitempty"`
// Target names the credential and its consumers.
Target RotationTarget `json:"target"`
// Verify names the checks that must pass.
Verify VerifySpec `json:"verify,omitempty"`
}
// KeyRotationStatus is the observed state.
type KeyRotationStatus struct {
Phase string `json:"phase,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
LastRotation *metav1.Time `json:"lastRotation,omitempty"`
NextRotation *metav1.Time `json:"nextRotation,omitempty"`
ActiveCredentialID string `json:"activeCredentialID,omitempty"`
PreviousCredentialID string `json:"previousCredentialID,omitempty"`
LastError string `json:"lastError,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// KeyRotation is one credential under management.
type KeyRotation struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec KeyRotationSpec `json:"spec,omitempty"`
Status KeyRotationStatus `json:"status,omitempty"`
}
// KeyRotationList is a list of KeyRotation.
type KeyRotationList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []KeyRotation `json:"items"`
}
// SchemeGroupVersion is the group and version for this type.
var SchemeGroupVersion = schema.GroupVersion{Group: Group, Version: Version}
// SchemeBuilder registers the types with a scheme.
var SchemeBuilder = runtime.NewSchemeBuilder(func(s *runtime.Scheme) error {
s.AddKnownTypes(SchemeGroupVersion, &KeyRotation{}, &KeyRotationList{})
metav1.AddToGroupVersion(s, SchemeGroupVersion)
return nil
})
// AddToScheme registers this package's types.
var AddToScheme = SchemeBuilder.AddToScheme
// DeepCopyInto copies the receiver into out.
func (in *KeyRotation) DeepCopyInto(out *KeyRotation) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
// DeepCopy makes a deep copy.
func (in *KeyRotation) DeepCopy() *KeyRotation {
if in == nil {
return nil
}
out := new(KeyRotation)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject satisfies runtime.Object.
func (in *KeyRotation) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
// DeepCopyInto copies the receiver into out.
func (in *KeyRotationSpec) DeepCopyInto(out *KeyRotationSpec) {
*out = *in
in.Target.DeepCopyInto(&out.Target)
}
// DeepCopyInto copies the receiver into out.
func (in *RotationTarget) DeepCopyInto(out *RotationTarget) {
*out = *in
if in.SecretKeys != nil {
out.SecretKeys = make(map[string]string, len(in.SecretKeys))
for k, v := range in.SecretKeys {
out.SecretKeys[k] = v
}
}
if in.Consumers != nil {
out.Consumers = make([]ConsumerRef, len(in.Consumers))
copy(out.Consumers, in.Consumers)
}
}
// DeepCopyInto copies the receiver into out.
func (in *KeyRotationStatus) DeepCopyInto(out *KeyRotationStatus) {
*out = *in
if in.LastRotation != nil {
t := *in.LastRotation
out.LastRotation = &t
}
if in.NextRotation != nil {
t := *in.NextRotation
out.NextRotation = &t
}
if in.Conditions != nil {
out.Conditions = make([]metav1.Condition, len(in.Conditions))
copy(out.Conditions, in.Conditions)
}
}
// DeepCopyInto copies the receiver into out.
func (in *KeyRotationList) DeepCopyInto(out *KeyRotationList) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ListMeta.DeepCopyInto(&out.ListMeta)
if in.Items != nil {
out.Items = make([]KeyRotation, len(in.Items))
for i := range in.Items {
in.Items[i].DeepCopyInto(&out.Items[i])
}
}
}
// DeepCopy makes a deep copy.
func (in *KeyRotationList) DeepCopy() *KeyRotationList {
if in == nil {
return nil
}
out := new(KeyRotationList)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject satisfies runtime.Object.
func (in *KeyRotationList) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
@@ -1,71 +0,0 @@
module github.com/riotpiao/key-rotation-controller
go 1.26.0
require (
k8s.io/api v0.37.1
k8s.io/apimachinery v0.37.1
k8s.io/client-go v0.37.0
sigs.k8s.io/controller-runtime v0.25.2
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
github.com/evanphx/json-patch/v5 v5.9.11 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-openapi/jsonpointer v1.0.0 // indirect
github.com/go-openapi/jsonreference v1.0.0 // indirect
github.com/go-openapi/swag v0.27.1 // indirect
github.com/go-openapi/swag/cmdutils v0.27.1 // indirect
github.com/go-openapi/swag/conv v0.27.1 // indirect
github.com/go-openapi/swag/fileutils v0.27.1 // indirect
github.com/go-openapi/swag/jsonutils v0.27.1 // indirect
github.com/go-openapi/swag/loading v0.27.1 // indirect
github.com/go-openapi/swag/mangling v0.27.1 // indirect
github.com/go-openapi/swag/netutils v0.27.1 // indirect
github.com/go-openapi/swag/pools v0.27.1 // indirect
github.com/go-openapi/swag/stringutils v0.27.1 // indirect
github.com/go-openapi/swag/typeutils v0.27.1 // indirect
github.com/go-openapi/swag/yamlutils v0.27.1 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.24.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.70.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/term v0.45.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.15.0 // indirect
gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
k8s.io/apiextensions-apiserver v0.37.0 // indirect
k8s.io/klog/v2 v2.140.0 // indirect
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad // indirect
k8s.io/utils v0.0.0-20260626114624-be93311217bd // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect
sigs.k8s.io/yaml v1.6.0 // indirect
)
@@ -1,133 +0,0 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ=
github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg=
github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s=
github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y=
github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY=
github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0=
github.com/go-openapi/swag v0.27.1 h1:VotvOLWW8q/EAxB0YdsBBGC8XYyeL1YwBj2ungAGPNg=
github.com/go-openapi/swag v0.27.1/go.mod h1:GTkJPwHfhJp6MWr4/rCh64HVI3Ofu+tcsbfjfHmTxpE=
github.com/go-openapi/swag/cmdutils v0.27.1 h1:I7sYqaWVl5mq0NEmNQkAmFDyNin9ufvMX/p2zwtQaOE=
github.com/go-openapi/swag/cmdutils v0.27.1/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM=
github.com/go-openapi/swag/conv v0.27.1 h1:8wi9ZG+olmY1wXphl93EWniPtbSPkXM/feH7FgjsvrU=
github.com/go-openapi/swag/conv v0.27.1/go.mod h1:QbqMivkpKhC3g1B1GGGOJ6ANewI3S62dbzYu3Duowqs=
github.com/go-openapi/swag/fileutils v0.27.1 h1:QQqBSoi5mW4XpU85nS0mLcA+zAE6vLzrb0QkmLKf9oM=
github.com/go-openapi/swag/fileutils v0.27.1/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8=
github.com/go-openapi/swag/jsonutils v0.27.1 h1:SVgK3i4USzCU5mibOOS/l4ea2h9UQXy7J7RNLTjuXjU=
github.com/go-openapi/swag/jsonutils v0.27.1/go.mod h1:tdlEpZqdcQ17uj6J4YdK9vd8It5qWMwjWXOs0tjpRlk=
github.com/go-openapi/swag/loading v0.27.1 h1:/DxUgDXKbBX4bcn7r9uEXfJyzN5XpiJmZplzQTjrRCY=
github.com/go-openapi/swag/loading v0.27.1/go.mod h1:jvGh3iA2+zyUUycB5fgJWzeHnhrpvGnJJM0RVE9ZShE=
github.com/go-openapi/swag/mangling v0.27.1 h1:yC9D0HyUE8gbP+BfmGx9+AA89ikwZTMjESK3OnnoaqA=
github.com/go-openapi/swag/mangling v0.27.1/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w=
github.com/go-openapi/swag/netutils v0.27.1 h1:mICMFoS82F5TZ4Zy3cqmcQk+BFeCp3Uyq3Np7GI0/qU=
github.com/go-openapi/swag/netutils v0.27.1/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ=
github.com/go-openapi/swag/pools v0.27.1 h1:9LeadcMyb2GJCbXX5hVQDbZ2Lq9TL4dCs/nx1j5DO0E=
github.com/go-openapi/swag/pools v0.27.1/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE=
github.com/go-openapi/swag/stringutils v0.27.1 h1:ZXePZ0r2p1qSjo8tD3Un4vFj8+FqlCkczxDrJIhYUp8=
github.com/go-openapi/swag/stringutils v0.27.1/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM=
github.com/go-openapi/swag/typeutils v0.27.1 h1:KSTdFlfnse4r6dP9IrEnwMldjE+zs71UeEB3//PtVXc=
github.com/go-openapi/swag/typeutils v0.27.1/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ=
github.com/go-openapi/swag/yamlutils v0.27.1 h1:ftxv6xvXb1E3zohUc+okZ9nSqNb9StQX/FXnKZ98sQA=
github.com/go-openapi/swag/yamlutils v0.27.1/go.mod h1:bnxFIB1qewGRiZHypXGZ3fNgf13/0HfRgnS/iZBDrOo=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.0 h1:5XStIklKuAtJSNpdD3s8XJj/Yv78IQmE1kbNk87JrAI=
github.com/prometheus/client_golang v1.24.0/go.mod h1:QcsNdotprC2nS4BTM2ucbcqxd2CeXTEa9jW7zHO9iDE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI=
github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw=
gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
k8s.io/api v0.37.1 h1:l6N77U7tjwB5L056bgrBTJIEdevac/naBZ3iSvDNfpM=
k8s.io/api v0.37.1/go.mod h1:zSlbB1YpJ1YQlFVQy20UYll81UJSJJUMLhkhvg6Z78M=
k8s.io/apiextensions-apiserver v0.37.0 h1:zRMQ3+/LIE5oZ0tVvXwYHC+dIkSP5cjNWju7AZU1LOI=
k8s.io/apiextensions-apiserver v0.37.0/go.mod h1:HU0PfSBwchHL5iDau6jjt9zU6ryWkDDlaVUiq91NK80=
k8s.io/apimachinery v0.37.1 h1:hGCYyvKHCwtwMitj2vU4vYx0Z16N9GyZk9BBnz0wDAE=
k8s.io/apimachinery v0.37.1/go.mod h1:jF84AyUi/IRIXRot5f+lm6MpxoWI+F1XgjaMmwCdTFw=
k8s.io/client-go v0.37.0 h1:nsN31fy8wBySuZ+QRnKmrjRSQLOG2rvoGN0tKd12zhQ=
k8s.io/client-go v0.37.0/go.mod h1:FcGqw+Ll/gNQiq+nPGY1Oyt9y7SgDh1d3MW3RFDEbn0=
k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc=
k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad h1:oXImqH8mQNk7PmvzKhmN3ddJoY6OnyM225MXwGHPm0A=
k8s.io/kube-openapi v0.0.0-20260721132016-d427ff9ee9ad/go.mod h1:0/mqHCVhlumdJ3BhCfnjSZQE037nAhNodh1/hK0T8/I=
k8s.io/utils v0.0.0-20260626114624-be93311217bd h1:Ea7fgQ5we8Y9T0OX5o0dAHzQOBRI07D/dEYRaB9ZZEs=
k8s.io/utils v0.0.0-20260626114624-be93311217bd/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
sigs.k8s.io/controller-runtime v0.25.2 h1:bEkK3PVOIVK9X8QWLGVhJgmFc++47vfT6wakSzAOLsQ=
sigs.k8s.io/controller-runtime v0.25.2/go.mod h1:4QqLdT6z/L6Olj8JJCtvztid4/fnIiYsfaTFScegctc=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2 h1:qdOxHwrl2Kaag1aQEarlYcOA9vSyGCp3CIki3aW8c4Q=
sigs.k8s.io/structured-merge-diff/v6 v6.4.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
@@ -1,168 +0,0 @@
package reconcile
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
iamv1 "github.com/riotpiao/key-rotation-controller/api"
"github.com/riotpiao/key-rotation-controller/internal/zitadel"
)
// MachineKeyProvider rotates a service user's private key.
//
// ZITADEL keeps many keys per service user, and each assertion names its key with
// `kid`. A new key can be created while the deployed key stays valid, so this
// provider retires the old key only after the new one proves itself. Nothing in
// the window is invalid.
type MachineKeyProvider struct {
// Client talks to the instance that owns the service user.
Client *zitadel.Client
// AuthClientID is the API app the controller authenticates as.
AuthClientID string
// KeyIDFile and KeyFile name the Secret keys, defaulting to key-id and key.
KeyIDFile string
KeyFile string
}
// Name implements Provider.
func (p *MachineKeyProvider) Name() string { return iamv1.ProviderZitadelMachineKey }
// Strategy implements Provider. Machine keys overlap.
func (p *MachineKeyProvider) Strategy() string { return iamv1.StrategyOverlap }
// Prepare creates a new key. The currently deployed key remains valid.
func (p *MachineKeyProvider) Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) {
userID := kr.Spec.Target.MachineUserID
if userID == "" {
return nil, fmt.Errorf("spec.target.machineUserID is required")
}
key, err := p.Client.CreateMachineKey(userID)
if err != nil {
return nil, fmt.Errorf("creating a machine key: %w", err)
}
fields := map[string][]byte{
p.keyField(kr): []byte(key.Key),
p.keyIDField(kr): []byte(key.KeyID),
}
return &Credential{ID: key.KeyID, Fields: fields}, nil
}
// Verify exchanges a token with the new key. A key that cannot obtain a token is
// not deployed as a working credential, whatever the API said.
func (p *MachineKeyProvider) Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error {
keyPEM := cred.Fields[p.keyField(kr)]
keyID := string(cred.Fields[p.keyIDField(kr)])
return p.Client.ExchangeMachineKey(
p.AuthClientID,
kr.Spec.Target.MachineUserID,
keyID,
keyPEM,
kr.Spec.Verify.AudienceProject,
)
}
// Retire deletes the superseded key.
func (p *MachineKeyProvider) Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error {
return p.Client.DeleteMachineKey(kr.Spec.Target.MachineUserID, credentialID)
}
func (p *MachineKeyProvider) keyField(kr *iamv1.KeyRotation) string {
if key, ok := kr.Spec.Target.SecretKeys["key"]; ok && key != "" {
return key
}
return "key"
}
func (p *MachineKeyProvider) keyIDField(kr *iamv1.KeyRotation) string {
if key, ok := kr.Spec.Target.SecretKeys["key-id"]; ok && key != "" {
return key
}
return "key-id"
}
// OIDCClientProvider rotates an app's client secret.
//
// ZITADEL stores exactly one secret per app, so creating the new secret kills the
// old one. The strategy is atomic: publish immediately, roll the consumer, verify,
// and if verification fails, put the previous value back.
type OIDCClientProvider struct {
Client *zitadel.Client
// ClientIDFunc maps a KeyRotation to the app's client id, which the
// introspection endpoint needs.
ClientIDFunc func(kr *iamv1.KeyRotation) string
// SecretKey names the Secret key that holds the secret.
SecretKey string
}
// Name implements Provider.
func (p *OIDCClientProvider) Name() string { return iamv1.ProviderZitadelOIDCClient }
// Strategy implements Provider. Client secrets cannot overlap.
func (p *OIDCClientProvider) Strategy() string { return iamv1.StrategyAtomic }
// Prepare regenerates the client secret.
func (p *OIDCClientProvider) Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) {
projectID := kr.Spec.Target.ZitadelProjectID
appID := kr.Spec.Target.ZitadelAppID
if projectID == "" || appID == "" {
return nil, fmt.Errorf("spec.target.zitadelProjectID and zitadelAppID are required")
}
secret, err := p.Client.GenerateClientSecret(projectID, appID)
if err != nil {
return nil, fmt.Errorf("generating a client secret: %w", err)
}
field := p.secretField(kr)
// The credential id is the generation marker, not a ZITADEL identifier: the
// API returns only the value.
return &Credential{
ID: "secret-" + shortHash(secret),
Fields: map[string][]byte{field: []byte(secret)},
}, nil
}
// Verify checks the credential against the introspection endpoint.
func (p *OIDCClientProvider) Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error {
if !kr.Spec.Verify.Introspect {
return nil
}
clientID := ""
if p.ClientIDFunc != nil {
clientID = p.ClientIDFunc(kr)
}
if clientID == "" {
return fmt.Errorf("no client id configured for introspection")
}
return p.Client.Introspect(clientID, string(cred.Fields[p.secretField(kr)]))
}
// Retire is a no-op: an atomic provider has nothing to retire, because the
// provider already invalidated the previous value.
func (p *OIDCClientProvider) Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error {
return nil
}
func (p *OIDCClientProvider) secretField(kr *iamv1.KeyRotation) string {
if p.SecretKey != "" {
return p.SecretKey
}
if key, ok := kr.Spec.Target.SecretKeys["secret"]; ok && key != "" {
return key
}
return "secret"
}
// shortHash gives a stable, non-reversible marker for a secret so status can
// record which generation is live without storing the value.
func shortHash(value string) string {
sum := 0
for _, b := range []byte(value) {
sum = (sum*31 + int(b)) % 100000007
}
encoded := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d", sum)))
return encoded
}
// unusedJSON keeps the import set honest if a provider needs raw JSON later.
var _ = json.Marshal
@@ -1,391 +0,0 @@
// Package reconcile turns a KeyRotation object into a rotated credential.
//
// The state machine is identical for every provider. Only three calls differ:
// create, verify, retire. That is what makes one controller cover machine keys,
// client secrets and, later, the token-style providers.
//
// Order is the safety property:
//
// Prepare create the new credential
// Publish write the Secret, roll the consumer when it cannot reload
// Verify prove the new credential works against the provider
// Retire delete the old credential, only for overlapping providers
//
// A failure before Retire leaves the old credential in place, so a broken
// rotation never removes the thing that currently works.
package reconcile
import (
"context"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
iamv1 "github.com/riotpiao/key-rotation-controller/api"
)
// Credential is fresh credential material, before it is written to a Secret.
type Credential struct {
// ID identifies the credential at the provider, so it can be retired later.
ID string
// Fields maps a Secret key to its value.
Fields map[string][]byte
}
// Provider creates, verifies and retires one class of credential.
type Provider interface {
// Name is the provider name from the spec.
Name() string
// Strategy is overlap or atomic. Atomic providers replace in place.
Strategy() string
// Prepare creates a new credential at the provider.
Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error)
// Verify proves the credential works, using the provider's own check.
Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error
// Retire deletes a superseded credential. It must be safe to call twice.
Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error
}
// Reconciler rotates credentials for KeyRotation objects.
type Reconciler struct {
client.Client
// Providers keyed by spec.provider.
Providers map[string]Provider
// Now is injectable so scheduling is testable.
Now func() time.Time
// RolloutTimeout bounds the wait for a consumer to become available.
RolloutTimeout time.Duration
}
// previousKeySuffix keeps the superseded value beside the current one, so an
// atomic provider can be rolled back without storing a secret in the object.
const previousKeySuffix = ".previous"
// RequeueAfter is how long to wait before checking a resource again.
func (r *Reconciler) RequeueAfter(kr *iamv1.KeyRotation) time.Duration {
interval := time.Duration(kr.Spec.IntervalHours) * time.Hour
if interval <= 0 {
interval = 30 * 24 * time.Hour
}
if kr.Status.LastRotation == nil || kr.Status.ActiveCredentialID == "" {
return 0 // rotate now
}
due := kr.Status.LastRotation.Add(interval)
wait := due.Sub(r.now())
if wait < 0 {
return 0
}
return wait
}
// Reconcile performs one rotation when the resource is due.
func (r *Reconciler) Reconcile(ctx context.Context, req types.NamespacedName) (time.Duration, error) {
logger := log.FromContext(ctx).WithValues("keyrotation", req.String())
var kr iamv1.KeyRotation
if err := r.Get(ctx, req, &kr); err != nil {
if apierrors.IsNotFound(err) {
return 0, nil
}
return 0, err
}
if kr.Spec.Suspend {
return 0, r.setPhase(ctx, &kr, iamv1.PhaseSuspended, "")
}
provider := r.Providers[kr.Spec.Provider]
if provider == nil {
return 0, r.setPhase(ctx, &kr, iamv1.PhaseFailed,
fmt.Sprintf("no provider named %q", kr.Spec.Provider))
}
wait := r.RequeueAfter(&kr)
if wait > 0 {
return wait, nil
}
logger.Info("rotating credential", "provider", kr.Spec.Provider, "strategy", kr.Spec.Strategy)
previousID := kr.Status.ActiveCredentialID
previousFields := r.readCurrentSecret(ctx, &kr)
// Prepare
if err := r.setPhase(ctx, &kr, iamv1.PhasePreparing, ""); err != nil {
return 0, err
}
cred, err := provider.Prepare(ctx, &kr)
if err != nil {
return r.fail(ctx, &kr, iamv1.PhasePreparing, err)
}
// Publish. The previous value stays beside the new one until verification
// passes, so an atomic rotation can be undone.
if err := r.setPhase(ctx, &kr, iamv1.PhasePropagating, ""); err != nil {
return 0, err
}
if err := r.writeSecret(ctx, &kr, cred, previousFields); err != nil {
return r.fail(ctx, &kr, iamv1.PhasePropagating, err)
}
if kr.Spec.Target.Reload != iamv1.ReloadHot {
if err := r.rollConsumers(ctx, &kr, cred.ID); err != nil {
return r.fail(ctx, &kr, iamv1.PhasePropagating, err)
}
}
// Verify
if err := r.setPhase(ctx, &kr, iamv1.PhaseVerifying, ""); err != nil {
return 0, err
}
if err := provider.Verify(ctx, &kr, cred); err != nil {
// Put the previous value back and roll again, then report the failure.
r.restorePrevious(ctx, &kr, previousFields)
return r.fail(ctx, &kr, iamv1.PhaseVerifying, err)
}
// Complete. Retire only when the provider can overlap, because retiring an
// atomic credential would delete the one now in use.
if err := r.setPhase(ctx, &kr, iamv1.PhaseCompleting, ""); err != nil {
return 0, err
}
if provider.Strategy() == iamv1.StrategyOverlap && previousID != "" && previousID != cred.ID {
if err := provider.Retire(ctx, &kr, previousID); err != nil {
logger.Error(err, "retiring the previous credential failed; it stays valid", "credential", previousID)
}
}
r.clearPrevious(ctx, &kr)
now := metav1.NewTime(r.now())
kr.Status.LastRotation = &now
kr.Status.ActiveCredentialID = cred.ID
kr.Status.PreviousCredentialID = previousID
kr.Status.LastError = ""
kr.Status.ObservedGeneration = kr.Generation
if err := r.Status().Update(ctx, &kr); err != nil {
return 0, err
}
if err := r.setPhase(ctx, &kr, iamv1.PhaseSucceeded, ""); err != nil {
return 0, err
}
logger.Info("rotation complete", "credential", cred.ID, "previous", previousID)
return r.RequeueAfter(&kr), nil
}
func (r *Reconciler) now() time.Time {
if r.Now != nil {
return r.Now()
}
return time.Now()
}
func (r *Reconciler) setPhase(ctx context.Context, kr *iamv1.KeyRotation, phase, message string) error {
kr.Status.Phase = phase
kr.Status.LastError = message
kr.Status.ObservedGeneration = kr.Generation
if err := r.Status().Update(ctx, kr); err != nil && !apierrors.IsConflict(err) {
return err
}
return nil
}
func (r *Reconciler) fail(ctx context.Context, kr *iamv1.KeyRotation, phase string, cause error) (time.Duration, error) {
kr.Status.Phase = iamv1.PhaseFailed
kr.Status.LastError = fmt.Sprintf("%s: %v", phase, cause)
_ = r.Status().Update(ctx, kr)
// Retry soon, but not in a tight loop.
return 5 * time.Minute, nil
}
// secretData assembles the Secret payload: the new values, plus the superseded
// values under a shadow key so an atomic rotation can be undone.
func secretData(kr *iamv1.KeyRotation, cred *Credential, previous map[string][]byte) map[string][]byte {
data := make(map[string][]byte, len(cred.Fields)*2)
for logical, value := range cred.Fields {
key := secretKeyFor(kr, logical)
data[key] = value
if old, ok := previous[key]; ok && len(old) > 0 {
data[key+previousKeySuffix] = old
}
}
return data
}
// secretKeyFor maps a logical field name to the Secret key, defaulting to the
// same name so a simple credential needs no mapping.
func secretKeyFor(kr *iamv1.KeyRotation, logical string) string {
if key, ok := kr.Spec.Target.SecretKeys[logical]; ok && key != "" {
return key
}
return logical
}
func (r *Reconciler) readCurrentSecret(ctx context.Context, kr *iamv1.KeyRotation) map[string][]byte {
var secret corev1.Secret
err := r.Get(ctx, types.NamespacedName{
Namespace: kr.Spec.Target.SecretNamespace,
Name: kr.Spec.Target.SecretName,
}, &secret)
if err != nil {
return nil
}
return secret.Data
}
func (r *Reconciler) writeSecret(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential, previous map[string][]byte) error {
data := secretData(kr, cred, previous)
var secret corev1.Secret
key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName}
err := r.Get(ctx, key, &secret)
if apierrors.IsNotFound(err) {
secret = corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: key.Name,
Namespace: key.Namespace,
Labels: map[string]string{"app.kubernetes.io/managed-by": "key-rotation-controller"},
},
Type: corev1.SecretTypeOpaque,
Data: data,
}
return r.Create(ctx, &secret)
}
if err != nil {
return err
}
secret.Data = data
return r.Update(ctx, &secret)
}
// restorePrevious puts the shadow values back in place after a failed check.
func (r *Reconciler) restorePrevious(ctx context.Context, kr *iamv1.KeyRotation, previous map[string][]byte) {
if len(previous) == 0 {
return
}
var secret corev1.Secret
key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName}
if err := r.Get(ctx, key, &secret); err != nil {
return
}
// Restore every shadowed value over the current one.
for k, v := range secret.Data {
base := k
if len(k) > len(previousKeySuffix) && k[len(k)-len(previousKeySuffix):] == previousKeySuffix {
base = k[:len(k)-len(previousKeySuffix)]
secret.Data[base] = v
}
}
_ = r.Update(ctx, &secret)
_ = r.rollConsumers(ctx, kr, "rollback")
}
// clearPrevious removes the shadow values once verification passed.
func (r *Reconciler) clearPrevious(ctx context.Context, kr *iamv1.KeyRotation) {
var secret corev1.Secret
key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName}
if err := r.Get(ctx, key, &secret); err != nil {
return
}
changed := false
for k := range secret.Data {
if len(k) > len(previousKeySuffix) && k[len(k)-len(previousKeySuffix):] == previousKeySuffix {
delete(secret.Data, k)
changed = true
}
}
if changed {
_ = r.Update(ctx, &secret)
}
}
// rollConsumers restarts each consumer so it reads the new value.
func (r *Reconciler) rollConsumers(ctx context.Context, kr *iamv1.KeyRotation, revision string) error {
for _, consumer := range kr.Spec.Target.Consumers {
ns := consumer.Namespace
if ns == "" {
ns = kr.Namespace
}
switch consumer.Kind {
case "Deployment":
var dep appsv1.Deployment
if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &dep); err != nil {
return fmt.Errorf("consumer %s/%s: %w", ns, consumer.Name, err)
}
base := dep.DeepCopy()
if dep.Spec.Template.Annotations == nil {
dep.Spec.Template.Annotations = map[string]string{}
}
dep.Spec.Template.Annotations["iam.homelab.riotpiao.com/credential-revision"] = revision
if err := r.Patch(ctx, &dep, client.MergeFrom(base)); err != nil {
return fmt.Errorf("rolling %s/%s: %w", ns, consumer.Name, err)
}
case "StatefulSet":
var sts appsv1.StatefulSet
if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &sts); err != nil {
return fmt.Errorf("consumer %s/%s: %w", ns, consumer.Name, err)
}
base := sts.DeepCopy()
if sts.Spec.Template.Annotations == nil {
sts.Spec.Template.Annotations = map[string]string{}
}
sts.Spec.Template.Annotations["iam.homelab.riotpiao.com/credential-revision"] = revision
if err := r.Patch(ctx, &sts, client.MergeFrom(base)); err != nil {
return fmt.Errorf("rolling %s/%s: %w", ns, consumer.Name, err)
}
default:
return fmt.Errorf("consumer kind %q is not supported", consumer.Kind)
}
}
return r.waitForConsumers(ctx, kr)
}
// waitForConsumers blocks until every consumer reports all replicas available.
func (r *Reconciler) waitForConsumers(ctx context.Context, kr *iamv1.KeyRotation) error {
deadline := r.now().Add(r.rolloutTimeout())
for {
ready := true
for _, consumer := range kr.Spec.Target.Consumers {
ns := consumer.Namespace
if ns == "" {
ns = kr.Namespace
}
var dep appsv1.Deployment
if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &dep); err != nil {
continue
}
desired := int32(1)
if dep.Spec.Replicas != nil {
desired = *dep.Spec.Replicas
}
if dep.Status.UpdatedReplicas < desired || dep.Status.AvailableReplicas < desired {
ready = false
break
}
}
if ready {
return nil
}
if r.now().After(deadline) {
return fmt.Errorf("consumer did not become available within %s", r.rolloutTimeout())
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(2 * time.Second):
}
}
}
func (r *Reconciler) rolloutTimeout() time.Duration {
if r.RolloutTimeout > 0 {
return r.RolloutTimeout
}
return 3 * time.Minute
}
@@ -1,155 +0,0 @@
package reconcile
import (
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
iamv1 "github.com/riotpiao/key-rotation-controller/api"
)
func rotation(last *metav1.Time, activeID string, hours int32) *iamv1.KeyRotation {
return &iamv1.KeyRotation{
Spec: iamv1.KeyRotationSpec{
Provider: iamv1.ProviderZitadelMachineKey,
Strategy: iamv1.StrategyOverlap,
IntervalHours: hours,
},
Status: iamv1.KeyRotationStatus{
LastRotation: last,
ActiveCredentialID: activeID,
},
}
}
func TestRequeueAfterRotatesImmediatelyWhenNeverRotated(t *testing.T) {
r := &Reconciler{Now: func() time.Time { return time.Unix(1_700_000_000, 0) }}
if got := r.RequeueAfter(rotation(nil, "", 24)); got != 0 {
t.Fatalf("a resource with no rotation history must rotate now, got %s", got)
}
if got := r.RequeueAfter(rotation(nil, "key-1", 24)); got != 0 {
t.Fatalf("a resource with no lastRotation must rotate now, got %s", got)
}
}
func TestRequeueAfterWaitsForTheInterval(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
last := metav1.NewTime(now.Add(-6 * time.Hour))
r := &Reconciler{Now: func() time.Time { return now }}
got := r.RequeueAfter(rotation(&last, "key-1", 24))
want := 18 * time.Hour
if got != want {
t.Fatalf("expected %s until the next rotation, got %s", want, got)
}
}
func TestRequeueAfterRotatesWhenOverdue(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
last := metav1.NewTime(now.Add(-30 * time.Hour))
r := &Reconciler{Now: func() time.Time { return now }}
if got := r.RequeueAfter(rotation(&last, "key-1", 24)); got != 0 {
t.Fatalf("an overdue resource must rotate now, got %s", got)
}
}
func TestRequeueAfterFallsBackWhenNoInterval(t *testing.T) {
now := time.Unix(1_700_000_000, 0)
last := metav1.NewTime(now)
r := &Reconciler{Now: func() time.Time { return now }}
got := r.RequeueAfter(rotation(&last, "key-1", 0))
if got <= 0 {
t.Fatalf("a zero interval must fall back to a default, got %s", got)
}
}
func TestSecretDataKeepsThePreviousValueUnderAShadowKey(t *testing.T) {
kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{
Target: iamv1.RotationTarget{
SecretKeys: map[string]string{"key": "jwt-key", "key-id": "jwt-key-id"},
},
}}
cred := &Credential{ID: "k2", Fields: map[string][]byte{
"key": []byte("PEM-2"),
"key-id": []byte("k2"),
}}
previous := map[string][]byte{
"jwt-key": []byte("PEM-1"),
"jwt-key-id": []byte("k1"),
}
data := secretData(kr, cred, previous)
if string(data["jwt-key"]) != "PEM-2" {
t.Fatalf("current value missing, got %q", data["jwt-key"])
}
if string(data["jwt-key"+previousKeySuffix]) != "PEM-1" {
t.Fatalf("previous value must be kept for rollback, got %q", data["jwt-key"+previousKeySuffix])
}
if string(data["jwt-key-id"+previousKeySuffix]) != "k1" {
t.Fatalf("previous key id must be kept for rollback")
}
}
func TestSecretDataDefaultsToLogicalNames(t *testing.T) {
kr := &iamv1.KeyRotation{}
cred := &Credential{ID: "k1", Fields: map[string][]byte{"secret": []byte("s3cret")}}
data := secretData(kr, cred, nil)
if string(data["secret"]) != "s3cret" {
t.Fatalf("an unmapped field must keep its logical name, got %v", data)
}
if _, shadow := data["secret"+previousKeySuffix]; shadow {
t.Fatalf("no previous value existed, so no shadow key may be written")
}
}
func TestSecretKeyForPrefersTheMapping(t *testing.T) {
kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{
Target: iamv1.RotationTarget{
SecretKeys: map[string]string{"secret": "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET"},
},
}}
if got := secretKeyFor(kr, "secret"); got != "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET" {
t.Fatalf("mapping not honoured, got %q", got)
}
if got := secretKeyFor(kr, "key-id"); got != "key-id" {
t.Fatalf("unmapped field must default to its own name, got %q", got)
}
}
func TestMachineKeyProviderFieldsRespectTheMapping(t *testing.T) {
p := &MachineKeyProvider{}
kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{
Target: iamv1.RotationTarget{
SecretKeys: map[string]string{"key": "private-key", "key-id": "kid"},
},
}}
if p.keyField(kr) != "private-key" || p.keyIDField(kr) != "kid" {
t.Fatalf("provider must read the mapped Secret keys")
}
if p.Strategy() != iamv1.StrategyOverlap {
t.Fatalf("machine keys must be an overlapping provider")
}
if (&OIDCClientProvider{}).Strategy() != iamv1.StrategyAtomic {
t.Fatalf("client secrets cannot overlap and must be atomic")
}
}
func TestShortHashIsStableAndDoesNotLeakTheValue(t *testing.T) {
a := shortHash("super-secret-value")
b := shortHash("super-secret-value")
if a != b {
t.Fatalf("the marker must be stable for the same value")
}
if a == "super-secret-value" {
t.Fatalf("the marker must not be the value itself")
}
if shortHash("other") == a {
t.Fatalf("different values must produce different markers")
}
}
@@ -1,308 +0,0 @@
package zitadel
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// Config holds everything the controller needs to call ZITADEL.
type Config struct {
// Issuer is the instance URL, for example https://zitadel.riotpiao.com.
Issuer string
// ClientID is the API app that this controller authenticates as.
ClientID string
// UserID is the machine user the assertion acts for. Empty means ClientID.
UserID string
// KeyID and PrivateKeyPEM identify the controller's own machine key.
KeyID string
PrivateKeyPEM []byte
// HTTPClient is optional; a sane default is used when nil.
HTTPClient *http.Client
}
// Client is a thin ZITADEL management client.
type Client struct {
cfg Config
http *http.Client
mu sync.Mutex
token string
until time.Time
}
// New builds a client.
func New(cfg Config) *Client {
client := cfg.HTTPClient
if client == nil {
client = &http.Client{Timeout: 20 * time.Second}
}
return &Client{cfg: cfg, http: client}
}
// AccessToken returns a cached access token, minting one when needed.
func (c *Client) AccessToken() (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token != "" && time.Now().Before(c.until) {
return c.token, nil
}
assertion, err := SignAssertion(c.cfg.PrivateKeyPEM, c.cfg.KeyID, c.cfg.ClientID, c.cfg.UserID, c.cfg.Issuer, time.Now())
if err != nil {
return "", err
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
form.Set("scope", "openid")
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/token", strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return "", fmt.Errorf("token request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var parsed struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &parsed); err != nil {
return "", fmt.Errorf("token response did not parse: %w", err)
}
if parsed.AccessToken == "" {
return "", fmt.Errorf("token response carried no access_token")
}
lifetime := parsed.ExpiresIn
if lifetime <= 0 {
lifetime = 300
}
c.token = parsed.AccessToken
// Refresh at 80 % of the lifetime so a slow call never presents an expired token.
c.until = time.Now().Add(time.Duration(lifetime) * time.Second * 4 / 5)
return c.token, nil
}
// projectAppPath builds the management path for one OIDC app under a project.
func (c *Client) projectAppPath(projectID, appID string) string {
return fmt.Sprintf("/management/v1/projects/%s/apps/%s", projectID, appID)
}
// MachineKey is one registered key for a service user.
type MachineKey struct {
KeyID string `json:"keyId"`
Expiration string `json:"expirationDate,omitempty"`
// Details holds the PEM bundle when the key was just created.
Details string `json:"keyDetails,omitempty"`
}
// NewMachineKeyJSON is the JSON document ZITADEL returns for a new JSON key.
type NewMachineKeyJSON struct {
Type string `json:"type"`
KeyID string `json:"keyId"`
Key string `json:"key"`
UserID string `json:"userId"`
ExpireAt string `json:"expirationDate,omitempty"`
}
// CreateMachineKey adds a key to a service user and returns its material.
//
// ZITADEL keeps many keys per user, so this never invalidates the key that is
// currently deployed. That is the whole reason the machine-key provider can
// rotate with no gap.
func (c *Client) CreateMachineKey(userID string) (*NewMachineKeyJSON, error) {
payload := map[string]any{"type": "KEY_TYPE_JSON"}
var out struct {
KeyID string `json:"keyId"`
KeyDetails string `json:"keyDetails"`
}
if err := c.post("/management/v1/users/"+userID+"/keys", payload, &out); err != nil {
return nil, err
}
if out.KeyDetails == "" {
return nil, fmt.Errorf("create machine key returned no key material")
}
raw, err := base64Decode(out.KeyDetails)
if err != nil {
return nil, fmt.Errorf("key material is not base64: %w", err)
}
var key NewMachineKeyJSON
if err := json.Unmarshal(raw, &key); err != nil {
return nil, fmt.Errorf("key material is not the expected JSON: %w", err)
}
return &key, nil
}
// DeleteMachineKey removes one key. Called only after the new key is proven.
func (c *Client) DeleteMachineKey(userID, keyID string) error {
return c.delete("/management/v1/users/" + userID + "/keys/" + keyID)
}
// GenerateClientSecret replaces the client secret of an OIDC app.
//
// ZITADEL stores exactly one secret per app, so the previous secret stops working
// at this call. The caller must publish the new value immediately.
func (c *Client) GenerateClientSecret(projectID, appID string) (string, error) {
var out struct {
ClientSecret string `json:"clientSecret"`
}
path := c.projectAppPath(projectID, appID) + "/oidc_config/_generate_client_secret"
if err := c.post(path, map[string]any{}, &out); err != nil {
return "", err
}
if out.ClientSecret == "" {
return "", fmt.Errorf("generate client secret returned an empty value")
}
return out.ClientSecret, nil
}
// Introspect checks a client credential against the token endpoint.
//
// A successful call returns an object with `active`. The credentials are valid
// when the endpoint answers 200, whatever `active` says, because `active`
// describes the token rather than the client.
func (c *Client) Introspect(clientID, clientSecret string) error {
form := url.Values{}
form.Set("token", "probe")
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/introspect", strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth(clientID, clientSecret)
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("introspection request failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("introspection returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
// ExchangeMachineKey obtains a token with a specific key, proving that key works.
func (c *Client) ExchangeMachineKey(clientID, userID, keyID string, keyPEM []byte, audienceProject string) error {
assertion, err := SignAssertion(keyPEM, keyID, clientID, userID, c.cfg.Issuer, time.Now())
if err != nil {
return err
}
scope := "openid"
if audienceProject != "" {
scope += " urn:zitadel:iam:org:project:id:" + audienceProject + ":aud"
}
form := url.Values{}
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
form.Set("assertion", assertion)
form.Set("scope", scope)
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(c.cfg.Issuer, "/")+"/oauth/v2/token", strings.NewReader(form.Encode()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("key exchange failed: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("key exchange returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
func (c *Client) do(method, path string, payload any, out any) error {
token, err := c.AccessToken()
if err != nil {
return err
}
var body io.Reader
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
body = bytes.NewReader(encoded)
}
req, err := http.NewRequest(method, strings.TrimRight(c.cfg.Issuer, "/")+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("%s %s returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw)))
}
if out != nil && len(raw) > 0 {
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("%s %s response did not parse: %w", method, path, err)
}
}
return nil
}
func (c *Client) post(path string, payload, out any) error {
return c.do(http.MethodPost, path, payload, out)
}
func (c *Client) delete(path string) error {
return c.do(http.MethodDelete, path, nil, nil)
}
func base64Decode(s string) ([]byte, error) {
if raw, err := base64StdDecode(s); err == nil {
return raw, nil
}
return base64URLDecode(s)
}
func base64StdDecode(s string) ([]byte, error) {
return base64.StdEncoding.DecodeString(s)
}
func base64URLDecode(s string) ([]byte, error) {
return base64.RawURLEncoding.DecodeString(s)
}
@@ -1,89 +0,0 @@
// Package zitadel talks to the ZITADEL management API with the controller's own
// machine key.
//
// The controller needs a credential to rotate credentials. It signs a short
// assertion with its private key, exchanges it for an access token, and reuses
// that token until shortly before it expires.
package zitadel
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"time"
)
// assertionLifetime is deliberately short. The assertion proves one request.
const assertionLifetime = 60 * time.Second
// newAssertionClaims builds the claim set for a machine-key assertion.
//
// ZITADEL expects `iss` to name the caller, `sub` to name the user it acts as,
// and `aud` to equal the issuer of the instance.
func newAssertionClaims(clientID, userID, issuer string, now time.Time) map[string]any {
if userID == "" {
userID = clientID
}
return map[string]any{
"iss": clientID,
"sub": userID,
"aud": issuer,
"iat": now.Unix(),
"exp": now.Add(assertionLifetime).Unix(),
}
}
func base64URL(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// SignAssertion produces a signed RS256 JWT for the jwt-bearer grant.
//
// The header carries `kid`, so ZITADEL selects the matching registered key. That
// is what makes overlapping keys possible: two keys can be valid at once and the
// assertion says which one it used.
func SignAssertion(privateKeyPEM []byte, keyID, clientID, userID, issuer string, now time.Time) (string, error) {
block, _ := pem.Decode(privateKeyPEM)
if block == nil {
return "", fmt.Errorf("private key is not PEM encoded")
}
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
// ZITADEL exports PKCS#1 keys as well.
if rsaKey, rsaErr := x509.ParsePKCS1PrivateKey(block.Bytes); rsaErr == nil {
key = rsaKey
} else {
return "", fmt.Errorf("private key does not parse: %w", err)
}
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not RSA")
}
header := map[string]any{"alg": "RS256", "typ": "JWT", "kid": keyID}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", err
}
claimsJSON, err := json.Marshal(newAssertionClaims(clientID, userID, issuer, now))
if err != nil {
return "", err
}
signingInput := base64URL(headerJSON) + "." + base64URL(claimsJSON)
digest := sha256.Sum256([]byte(signingInput))
signature, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, digest[:])
if err != nil {
return "", fmt.Errorf("cannot sign the assertion: %w", err)
}
return signingInput + "." + base64URL(signature), nil
}
@@ -1,90 +0,0 @@
package zitadel
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"strings"
"testing"
"time"
)
func newKey(t *testing.T) ([]byte, *rsa.PublicKey) {
t.Helper()
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatalf("cannot generate a key: %v", err)
}
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
return pemBytes, &key.PublicKey
}
func decodeSegment(t *testing.T, segment string, out any) {
t.Helper()
raw, err := base64.RawURLEncoding.DecodeString(segment)
if err != nil {
t.Fatalf("segment is not base64url: %v", err)
}
if err := json.Unmarshal(raw, out); err != nil {
t.Fatalf("segment is not JSON: %v", err)
}
}
func TestSignAssertionCarriesTheKeyIDAndIssuer(t *testing.T) {
pemBytes, _ := newKey(t)
now := time.Unix(1_700_000_000, 0)
token, err := SignAssertion(pemBytes, "key-42", "client-1", "user-9", "https://zitadel.riotpiao.com", now)
if err != nil {
t.Fatalf("signing failed: %v", err)
}
if strings.Count(token, ".") != 2 {
t.Fatalf("a JWT has three segments, got %q", token)
}
parts := strings.Split(token, ".")
var header map[string]any
decodeSegment(t, parts[0], &header)
if header["kid"] != "key-42" {
t.Fatalf("the header must name the key id so ZITADEL can pick the key, got %v", header["kid"])
}
if header["alg"] != "RS256" {
t.Fatalf("expected RS256, got %v", header["alg"])
}
var claims map[string]any
decodeSegment(t, parts[1], &claims)
if claims["iss"] != "client-1" || claims["sub"] != "user-9" {
t.Fatalf("iss and sub are wrong: %v / %v", claims["iss"], claims["sub"])
}
if claims["aud"] != "https://zitadel.riotpiao.com" {
t.Fatalf("aud must equal the issuer, got %v", claims["aud"])
}
exp := int64(claims["exp"].(float64))
iat := int64(claims["iat"].(float64))
if exp-iat != 60 {
t.Fatalf("the assertion must live 60 seconds, got %d", exp-iat)
}
}
func TestSignAssertionRejectsGarbage(t *testing.T) {
if _, err := SignAssertion([]byte("not a key"), "k", "c", "u", "https://i", time.Now()); err == nil {
t.Fatalf("a non-PEM key must be rejected")
}
}
func TestSignAssertionDefaultsSubjectToClientID(t *testing.T) {
pemBytes, _ := newKey(t)
token, err := SignAssertion(pemBytes, "k1", "client-1", "", "https://i", time.Unix(1, 0))
if err != nil {
t.Fatalf("signing failed: %v", err)
}
var claims map[string]any
decodeSegment(t, strings.Split(token, ".")[1], &claims)
if claims["sub"] != "client-1" {
t.Fatalf("an empty user id must fall back to the client id, got %v", claims["sub"])
}
}
@@ -1,152 +0,0 @@
// Command key-rotation-controller rotates credentials declared by KeyRotation
// objects.
//
// The controller authenticates to ZITADEL with its own machine key, read from a
// mounted Secret. That key is the single bootstrap credential: everything else is
// created by this process and never committed to git.
package main
import (
"context"
"flag"
"fmt"
"net/http"
"os"
"time"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller"
"sigs.k8s.io/controller-runtime/pkg/handler"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
"sigs.k8s.io/controller-runtime/pkg/source"
iamv1 "github.com/riotpiao/key-rotation-controller/api"
keyreconcile "github.com/riotpiao/key-rotation-controller/internal/reconcile"
"github.com/riotpiao/key-rotation-controller/internal/zitadel"
)
func main() {
var (
metricsAddr = flag.String("metrics-bind-address", ":8080", "address for the metrics endpoint")
healthAddr = flag.String("health-probe-bind-address", ":8081", "address for probes")
interval = flag.Duration("scan-interval", 10*time.Minute, "how often to re-check every resource")
)
flag.Parse()
ctrl.SetLogger(zap.New(zap.UseDevMode(false)))
logger := ctrl.Log.WithName("setup")
scheme := runtime.NewScheme()
if err := clientgoscheme.AddToScheme(scheme); err != nil {
logger.Error(err, "cannot register the Kubernetes types")
os.Exit(1)
}
if err := iamv1.AddToScheme(scheme); err != nil {
logger.Error(err, "cannot register the KeyRotation types")
os.Exit(1)
}
mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: *metricsAddr},
HealthProbeBindAddress: *healthAddr,
LeaderElection: true,
LeaderElectionID: "key-rotation-controller.iam.homelab.riotpiao.com",
})
if err != nil {
logger.Error(err, "cannot create the manager")
os.Exit(1)
}
zitadelClient, err := buildZitadelClient()
if err != nil {
logger.Error(err, "cannot build the ZITADEL client")
os.Exit(1)
}
reconciler := &keyreconcile.Reconciler{
Client: mgr.GetClient(),
Providers: map[string]keyreconcile.Provider{
iamv1.ProviderZitadelMachineKey: &keyreconcile.MachineKeyProvider{
Client: zitadelClient,
AuthClientID: os.Getenv("ZITADEL_CLIENT_ID"),
},
iamv1.ProviderZitadelOIDCClient: &keyreconcile.OIDCClientProvider{
Client: zitadelClient,
},
},
}
c, err := controller.New("keyrotation", mgr, controller.Options{
Reconciler: ctrlreconcile.Func(func(ctx context.Context, req ctrlreconcile.Request) (ctrlreconcile.Result, error) {
wait, err := reconciler.Reconcile(ctx, types.NamespacedName(req.NamespacedName))
if err != nil {
return ctrlreconcile.Result{}, err
}
if wait <= 0 {
wait = *interval
}
return ctrlreconcile.Result{RequeueAfter: wait}, nil
}),
})
if err != nil {
logger.Error(err, "cannot create the controller")
os.Exit(1)
}
if err := c.Watch(source.Kind(mgr.GetCache(), client.Object(&iamv1.KeyRotation{}), &handler.EnqueueRequestForObject{})); err != nil {
logger.Error(err, "cannot watch KeyRotation objects")
os.Exit(1)
}
if err := mgr.AddHealthzCheck("healthz", func(*http.Request) error { return nil }); err != nil {
logger.Error(err, "cannot add the health check")
os.Exit(1)
}
logger.Info("starting the key rotation controller")
if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
logger.Error(err, "manager stopped")
os.Exit(1)
}
}
// buildZitadelClient reads the controller's own credential from the environment.
//
// These are the only values the controller cannot create for itself. They arrive
// from one Secret, created once when the cluster is bootstrapped.
func buildZitadelClient() (*zitadel.Client, error) {
issuer := os.Getenv("ZITADEL_ISSUER")
clientID := os.Getenv("ZITADEL_CLIENT_ID")
userID := os.Getenv("ZITADEL_USER_ID")
keyID := os.Getenv("ZITADEL_KEY_ID")
keyFile := envOr("ZITADEL_KEY_FILE", "/var/run/keys/controller/key")
if issuer == "" || clientID == "" || keyID == "" {
return nil, fmt.Errorf("ZITADEL_ISSUER, ZITADEL_CLIENT_ID and ZITADEL_KEY_ID are required")
}
pem, err := os.ReadFile(keyFile)
if err != nil {
return nil, fmt.Errorf("cannot read the controller key: %w", err)
}
return zitadel.New(zitadel.Config{
Issuer: issuer,
ClientID: clientID,
UserID: userID,
KeyID: keyID,
PrivateKeyPEM: pem,
}), nil
}
func envOr(name, fallback string) string {
if v := os.Getenv(name); v != "" {
return v
}
return fallback
}
-121
View File
@@ -1,121 +0,0 @@
# KeyRotation declares one credential and how to rotate it.
#
# The resource never holds a secret value. It names where the value goes and how
# to prove the new value works. The controller writes the Secret.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: keyrotations.iam.homelab.riotpiao.com
spec:
group: iam.homelab.riotpiao.com
scope: Namespaced
names:
kind: KeyRotation
plural: keyrotations
singular: keyrotation
shortNames: [kr]
versions:
- name: v1
served: true
storage: true
additionalPrinterColumns:
- {name: Provider, type: string, jsonPath: .spec.provider}
- {name: Strategy, type: string, jsonPath: .spec.strategy}
- {name: Phase, type: string, jsonPath: .status.phase}
- {name: Last, type: date, jsonPath: .status.lastRotation}
- {name: Next, type: date, jsonPath: .status.nextRotation}
subresources:
status: {}
schema:
openAPIV3Schema:
type: object
required: [spec]
properties:
spec:
type: object
required: [provider, strategy, intervalHours, target]
properties:
provider:
type: string
enum: [zitadel-machine-key, zitadel-oidc-client]
description: >-
Which external API creates the credential.
strategy:
type: string
enum: [overlap, atomic]
description: >-
overlap keeps the old credential valid until the new one is
proven. atomic replaces it in place, because the provider
allows only one value.
intervalHours:
type: integer
minimum: 1
description: How often to rotate.
suspend:
type: boolean
description: Stop automatic rotation without deleting the resource.
target:
type: object
required: [secretName, secretNamespace]
properties:
zitadelProjectID: {type: string}
zitadelAppID: {type: string}
machineUserID: {type: string}
secretName: {type: string}
secretNamespace: {type: string}
secretKeys:
type: object
additionalProperties: {type: string}
description: >-
Logical field name to Secret key. Unmapped fields keep
their logical name.
reload:
type: string
enum: [hot, restart]
description: >-
hot means the consumer watches the file. restart rolls the
consumer so it reads the new value at start.
consumers:
type: array
items:
type: object
required: [kind, name, namespace]
properties:
kind: {type: string, enum: [Deployment, StatefulSet]}
name: {type: string}
namespace: {type: string}
verify:
type: object
properties:
introspect:
type: boolean
description: Check client id and secret against introspection.
tokenExchange:
type: boolean
description: Exchange a token with the new machine key.
audienceProject:
type: string
description: Project id added to the token audience scope.
status:
type: object
properties:
phase:
type: string
enum: [Pending, Preparing, Propagating, Verifying, Completing, Succeeded, Failed, Suspended]
observedGeneration: {type: integer, format: int64}
lastRotation: {type: string, format: date-time}
nextRotation: {type: string, format: date-time}
activeCredentialID: {type: string}
previousCredentialID: {type: string}
lastError: {type: string}
conditions:
type: array
items:
type: object
required: [type, status, reason, lastTransitionTime]
properties:
type: {type: string}
status: {type: string}
reason: {type: string}
message: {type: string}
lastTransitionTime: {type: string, format: date-time}
@@ -1,66 +0,0 @@
# The controller. Its own machine key is the single bootstrap credential: it is
# created once and mounted here, and this process creates every other credential.
#
# The image must be built and pushed before this Deployment can start. Until then
# the object stays Pending, which is why no Argo CD Application is wired yet.
apiVersion: apps/v1
kind: Deployment
metadata:
name: key-rotation-controller
namespace: iam
labels:
app: key-rotation-controller
spec:
replicas: 1
selector:
matchLabels:
app: key-rotation-controller
template:
metadata:
labels:
app: key-rotation-controller
spec:
serviceAccountName: key-rotation-controller
securityContext:
runAsNonRoot: true
runAsUser: 65534
seccompProfile:
type: RuntimeDefault
containers:
- name: controller
image: forgejo.riotpiao.com/riotpiao-poimen/key-rotation-controller:0.1.0
imagePullPolicy: IfNotPresent
args:
- --scan-interval=10m
env:
- {name: ZITADEL_ISSUER, value: "https://zitadel.riotpiao.com"}
- name: ZITADEL_CLIENT_ID
valueFrom: {secretKeyRef: {name: key-rotation-controller, key: client-id}}
- name: ZITADEL_USER_ID
valueFrom: {secretKeyRef: {name: key-rotation-controller, key: user-id}}
- name: ZITADEL_KEY_ID
valueFrom: {secretKeyRef: {name: key-rotation-controller, key: key-id}}
- {name: ZITADEL_KEY_FILE, value: "/var/run/keys/controller/key"}
ports:
- {name: metrics, containerPort: 8080}
- {name: health, containerPort: 8081}
readinessProbe:
httpGet: {path: /healthz, port: health}
initialDelaySeconds: 5
livenessProbe:
httpGet: {path: /healthz, port: health}
initialDelaySeconds: 15
volumeMounts:
- {name: controller-key, mountPath: /var/run/keys/controller, readOnly: true}
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"]}
resources:
requests: {cpu: 20m, memory: 64Mi}
limits: {cpu: 500m, memory: 256Mi}
volumes:
- name: controller-key
secret:
secretName: key-rotation-controller
items:
- {key: key, path: key}
@@ -1,28 +0,0 @@
# Example: rotate a service user's machine key with no gap.
#
# Machine keys overlap in ZITADEL, so the old key stays valid until the new one has
# obtained a token. Nothing in the window fails.
#
# Apply only after the controller runs and the machine user exists.
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: example-machine-key
namespace: iam
spec:
provider: zitadel-machine-key
strategy: overlap
intervalHours: 720 # 30 days
suspend: true # flip to false once the pilot is proven
target:
machineUserID: "REPLACE_WITH_SERVICE_USER_ID"
secretName: example-jwt
secretNamespace: iam
secretKeys:
key: key
key-id: key-id
reload: hot # the consumer watches the file, so no restart
consumers: []
verify:
tokenExchange: true
audienceProject: "396757796898471937"
@@ -1,27 +0,0 @@
# Example: rotate an OIDC app's client secret.
#
# ZITADEL keeps one secret per app, so the old value dies at the generate call.
# The strategy is atomic: publish, roll the consumer, verify, and roll back if the
# introspection check fails.
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: example-grafana-client
namespace: iam
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160 # 90 days
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "REPLACE_WITH_APP_ID"
secretName: grafana-oidc
secretNamespace: logging
secretKeys:
secret: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
reload: restart # Grafana reads the value at start
consumers:
- {kind: Deployment, name: grafana, namespace: logging}
verify:
introspect: true
@@ -1,10 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# No Argo CD Application is wired to this directory yet: the controller image must
# exist first. Add the app once the image is pushed, so Argo never reports a
# Deployment that cannot pull.
resources:
- crd.yaml
- rbac.yaml
- deployment.yaml
- rotations.yaml
@@ -1,43 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: key-rotation-controller
namespace: iam
---
# Cluster-wide because a credential and its consumer live in different namespaces:
# the Secret in one, the workload in another.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: key-rotation-controller
rules:
- apiGroups: ["iam.homelab.riotpiao.com"]
resources: ["keyrotations"]
verbs: ["get", "list", "watch"]
- apiGroups: ["iam.homelab.riotpiao.com"]
resources: ["keyrotations/status"]
verbs: ["get", "update", "patch"]
# The controller writes the credential it just created.
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "create", "update", "patch"]
# Rolling a consumer that cannot hot reload.
- apiGroups: ["apps"]
resources: ["deployments", "statefulsets"]
verbs: ["get", "list", "watch", "patch"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: key-rotation-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: key-rotation-controller
subjects:
- kind: ServiceAccount
name: key-rotation-controller
namespace: iam
@@ -1,211 +0,0 @@
# Every credential this cluster owns, described as a KeyRotation.
#
# One object per consumer. The controller creates the value, writes the Secret and
# proves the new value works before it retires the old one. Nothing here holds a
# secret, so this file is safe in git.
#
# suspend: true everywhere on purpose. The controller image does not exist yet, and
# an unsuspended object would claim an intent no process is acting on. Clear the flag
# per object as its pilot passes.
#
# poimen-memory is absent deliberately: it is in use and excluded from this migration.
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: grafana
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757801965191169"
secretName: grafana-oidc
secretNamespace: logging
secretKeys:
secret: GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET
reload: restart
consumers:
- {kind: Deployment, name: grafana, namespace: logging}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: argocd
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757801210282367"
secretName: argocd-oidc
secretNamespace: argocd
secretKeys:
secret: oidc.clientSecret
reload: restart
consumers:
- {kind: Deployment, name: argocd-server, namespace: argocd}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: comfyui
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "397015744010781070"
secretName: comfyui-oauth2-proxy
secretNamespace: comfyui
secretKeys:
secret: client-secret
reload: restart
consumers:
- {kind: Deployment, name: comfyui-oauth2-proxy, namespace: comfyui}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: minio
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757803257036801"
secretName: minio-oidc
secretNamespace: minio
secretKeys:
secret: client-secret
reload: hot
consumers:
- {kind: Deployment, name: minio, namespace: minio}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: paperless
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757803559027071"
secretName: paperless-oidc
secretNamespace: paperless
secretKeys:
secret: client-secret
reload: restart
consumers:
- {kind: Deployment, name: paperless, namespace: paperless}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: immich
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757802602791295"
secretName: immich-oidc
secretNamespace: immich
secretKeys:
secret: client-secret
reload: restart
consumers:
- {kind: Deployment, name: immich-server, namespace: immich}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: forgejo
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757801579315583"
secretName: forgejo-oidc
secretNamespace: forgejo
secretKeys:
secret: key
reload: restart
consumers:
- {kind: StatefulSet, name: forgejo, namespace: forgejo}
verify:
introspect: true
---
apiVersion: iam.homelab.riotpiao.com/v1
kind: KeyRotation
metadata:
name: api-gw
namespace: iam
labels:
app.kubernetes.io/part-of: key-rotation
spec:
provider: zitadel-oidc-client
strategy: atomic
intervalHours: 2160
suspend: true
target:
zitadelProjectID: "396757796898471937"
zitadelAppID: "396757800874672511"
secretName: api-gw-oidc
secretNamespace: api-gw
secretKeys:
secret: client-secret
reload: hot
consumers:
- {kind: Deployment, name: api-gw, namespace: api-gw}
verify:
introspect: true
+107
View File
@@ -0,0 +1,107 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
annotations:
serving.kserve.io/deploymentMode: RawDeployment
# The konghq.com/{connect,read,write}-timeout annotations that used to live
# here went with Kong (retired 2026-08-19). They existed because Kong read
# its upstream timeouts off the Kubernetes Service, and its 60s default cut
# off the first request after any pod restart — a restart flushes VRAM and
# reloading ornith:35b takes longer than that. OLLAMA_KEEP_ALIVE=-1 hid the
# problem in steady state.
#
# The equivalent budget now belongs to the Go gateway's per-route timeout
# config in rock/homelab-frontend, not to an annotation on this object.
labels:
app.kubernetes.io/name: llm-ornith
app.kubernetes.io/part-of: llm-serving
name: ornith
namespace: llm-serving
spec:
predictor:
containers:
- command:
- /bin/sh
- -c
- 'set -e
ollama serve &
SERVE_PID=$!
until ollama list >/dev/null 2>&1; do sleep 2; done
ollama pull ornith:35b
ollama run ornith:35b "ok" >/dev/null 2>&1 || true
wait $SERVE_PID
'
env:
- name: OLLAMA_HOST
value: 0.0.0.0:8080
- name: OLLAMA_MODELS
value: /mnt/models/ollama
- name: OLLAMA_CONTEXT_LENGTH
value: '65536'
- name: OLLAMA_KEEP_ALIVE
value: '-1'
- name: OLLAMA_NUM_PARALLEL
value: '1'
- name: OLLAMA_MAX_LOADED_MODELS
value: '1'
image: ollama/ollama:0.32.9@sha256:1685741456770df6e3cceb2a945a5f75e020f658d1701509668d6f4688f1dd3f
name: kserve-container
ports:
- containerPort: 8080
protocol: TCP
readinessProbe:
exec:
command:
- /bin/sh
- -c
- ollama ps 2>/dev/null | grep -q ornith
periodSeconds: 10
resources:
limits:
cpu: '16'
memory: 16Gi
nvidia.com/gpu: '1'
requests:
cpu: '8'
memory: 8Gi
nvidia.com/gpu: '1'
startupProbe:
exec:
command:
- /bin/sh
- -c
- ollama ps 2>/dev/null | grep -q ornith
failureThreshold: 120
periodSeconds: 15
volumeMounts:
- mountPath: /mnt/models
name: models
podMetadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
labels:
app.kubernetes.io/name: llm-ornith
app.kubernetes.io/part-of: llm-serving
deploymentStrategy:
type: Recreate
# 1 replica -- ornith:35b only. qwen2.5:3b moved to CPU on cp-2.
# Frees 1 GPU for ComfyUI.
maxReplicas: 1
minReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
runtimeClassName: nvidia
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
+162
View File
@@ -0,0 +1,162 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
annotations:
serving.kserve.io/deploymentMode: RawDeployment
labels:
app.kubernetes.io/name: llm-reasoning
app.kubernetes.io/part-of: llm-serving
name: reasoning
namespace: llm-serving
spec:
predictor:
containers:
- args:
# bnb-4bit retired: no int4 tensor cores on sm70/V100, dequant-then-
# matmul is two slow kernel launches instead of one fused int4 GEMM,
# decode crawled at 2.5-10 tok/s regardless of TP/PP. Switched to
# JunHowie/Qwen3-32B-GPTQ-Int4 -- same dense Qwen3-32B weights, same
# hermes/qwen3 parser stack (no narration-bug risk, same as before),
# only the quant format changes. Plain (non-Marlin) GPTQ kernel is
# confirmed Volta-compatible; Marlin needs sm80+ and vLLM would try
# to auto-upgrade to it, so --quantization is pinned explicitly to
# `gptq` to force the plain kernel. Verified checkpoint size: 19.34GB
# (summed from the real safetensors index, not bits-per-param math).
# max-model-len=131072 is Qwen3-32B's real ceiling (config.json YaRN:
# factor=4.0, original_max_position_embeddings=32768) -- 200k was
# asked for but exceeds this architecturally regardless of VRAM.
# KV cache math: 256KB/token total (64 layers, 8 KV heads, 128
# head_dim, fp16), PP=2 splits both weights and KV load ~evenly, so
# each GPU carries ~9.67GB weights + ~128KB/token KV. At
# gpu-memory-utilization=0.95 (30.4GB/GPU usable), that leaves
# ~20.7GB/GPU for KV cache. NOTE this is a projection, not a
# measurement: the live reading at 0.90 is num_gpu_blocks=9527 x
# block_size=16 = 152,432 tokens (vllm:cache_config_info). Re-read that
# metric after the rollout -- max_num_batched_tokens=8192 also raises
# activation memory out of the same budget, so the post-boot
# num_gpu_blocks is the authoritative number, and --max-num-seqs=5 is
# only valid if it still covers 2x65k + 3x8k. The old
# OffloadingConnector CPU-DRAM spillover (tuned for the previous
# model's much smaller 16384 context) is no longer needed and is
# dropped. Staying on PP=2 and vLLM 0.11.0 (no version bump needed,
# this checkpoint only requires vllm>=0.9.2) -- plain GPTQ has no
# TP>1 restriction unlike bnb, so tensor-parallel-size=2 is worth
# trying later, but not risking a parallelism-strategy change in the
# same rollout as the quant+context-length change.
# This GPTQ requant's own config.json ships max_position_embeddings=
# 40960 and rope_scaling=None -- confirmed directly (curl'd the raw
# config.json), the base Qwen3-32B repo's YaRN block did NOT carry
# over during quantization. Re-applying it explicitly here restores
# the same math the base model documents (32768 * 4.0 = 131072);
# without this, --max-model-len=131072 fails ModelConfig validation
# against the checkpoint's own (unscaled) 40960 ceiling.
- --model=JunHowie/Qwen3-32B-GPTQ-Int4
- --served-model-name=reasoning
- --quantization=gptq
- --dtype=float16
- --kv-cache-dtype=auto
- --rope-scaling={"rope_type":"yarn","factor":4.0,"original_max_position_embeddings":32768}
- --tensor-parallel-size=1
- --pipeline-parallel-size=2
- --max-model-len=131072
- --gpu-memory-utilization=0.95
- --max-num-seqs=5
# Prefill scheduling -- see the measured starvation this fixes.
# V1's scheduler has no prefill/decode phase: it walks self.running and
# hands out tokens from one shared budget (vllm/v1/core/sched/scheduler.py:
# "There's no decoding phase nor prefill phase in the scheduler"). The cap
# per request is applied BEFORE the budget clamp:
# if 0 < long_prefill_token_threshold < num_new_tokens: take = threshold
# take = min(take, token_budget)
# The loop is `while ... and token_budget > 0`. With the threshold at its
# default of 0 the cap is disabled, so one long prompt takes the ENTIRE
# budget, token_budget hits 0, the loop exits and every other request is
# skipped for that step -- measured as 262 tok/s contended prefill and
# 0.0-0.1 tok/s generation for everything else, Running: 2-4 reqs.
# Hence threshold must be strictly BELOW the budget or it never binds:
# at 8192/8192 a single request is served per step (still starved), while
# 8192/2048 serves four. Verified by replaying the loop.
- --max-num-batched-tokens=8192
- --long-prefill-token-threshold=2048
- --enable-chunked-prefill
- --enable-prefix-caching
#
# NOT set, deliberately: --max-num-partial-prefills and
# --max-long-partial-prefills. Those are V0-only knobs. vLLM 0.11.0 ships
# V1 only (vllm/core/ absent), and EngineArgs._is_v1_supported_oracle
# rejects any value other than the default for either one, calling
# _raise_or_fallback("Concurrent Partial Prefill") -> "Falling back to V0"
# -> AssertionError, because there is no V0 engine to fall back to.
# The Deployment uses strategy Recreate, so setting them is an outage.
# V1 does concurrent chunked prefill natively; it needs no flag, only the
# budget/threshold ratio above.
# qwen3 is vLLM's dedicated reasoning parser for this family's <think>
# blocks.
- --reasoning-parser=qwen3
# hermes is the documented tool-call parser for general (non-Coder)
# Qwen3 models -- native chat template support, not narrated text.
- --enable-auto-tool-choice
- --tool-call-parser=hermes
- --host=0.0.0.0
- --port=8080
env:
- name: VLLM_USE_FLASHINFER_SAMPLER
value: '0'
- name: VLLM_ATTENTION_BACKEND
value: TRITON_ATTN
- name: HF_HOME
value: /mnt/models
image: vllm/vllm-openai:v0.11.0@sha256:014a95f21c9edf6abe0aea6b07353f96baa4ec291c427bb1176dc7c93a85845c
name: kserve-container
ports:
- containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
resources:
limits:
cpu: '16'
memory: 36Gi
nvidia.com/gpu: '2'
requests:
cpu: '8'
memory: 12Gi
nvidia.com/gpu: '2'
startupProbe:
failureThreshold: 80
httpGet:
path: /health
port: 8080
periodSeconds: 15
volumeMounts:
- mountPath: /mnt/models
name: models
- mountPath: /dev/shm
name: shm
podMetadata:
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
prometheus.io/path: "/metrics"
labels:
app.kubernetes.io/name: llm-reasoning
app.kubernetes.io/part-of: llm-serving
deploymentStrategy:
type: Recreate
maxReplicas: 1
minReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
runtimeClassName: nvidia
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
- emptyDir:
medium: Memory
sizeLimit: 2Gi
name: shm
@@ -6,7 +6,7 @@ metadata:
data:
KMSVC_KAFKA_BROKERS: {{ .Values.env.kafkaBrokers | quote }}
KMSVC_REDIS_ADDR: {{ .Values.env.redisAddr | quote }}
KMSVC_ZITADEL_ISSUER_URL: {{ .Values.env.zitadelIssuerURL | quote }}
KMSVC_ZITADEL_AUDIENCE: {{ .Values.env.zitadelAudience | quote }}
KMSVC_AUTHENTIK_ISSUER_URL: {{ .Values.env.authentikIssuerURL | quote }}
KMSVC_AUTHENTIK_AUDIENCE: {{ .Values.env.authentikAudience | quote }}
KMSVC_GRPC_LISTEN_ADDR: ":{{ .Values.grpcPort }}"
KMSVC_HTTP_LISTEN_ADDR: ":{{ .Values.httpPort }}"
@@ -12,8 +12,8 @@ httpPort: 8080
env:
kafkaBrokers: "kmsvc-kafka-bootstrap.sqs.svc.cluster.local:9092"
redisAddr: "kmsvc-redis-master.sqs.svc.cluster.local:6379"
zitadelIssuerURL: ""
zitadelAudience: ""
authentikIssuerURL: ""
authentikAudience: ""
resources:
requests:
@@ -12,19 +12,6 @@ rules:
- apiGroups: ["kmsvc.io"]
resources: ["queues"]
verbs: ["get", "list", "watch", "update", "patch"]
# The operator watches TemporalWorker objects and creates Deployments for
# them. Without these two rules the controller logs
# "cannot list resource temporalworkers/deployments ... at the cluster scope"
# and never reconciles.
- apiGroups: ["kmsvc.io"]
resources: ["temporalworkers"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["kmsvc.io"]
resources: ["temporalworkers/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: ["kmsvc.io"]
resources: ["queues/status"]
verbs: ["get", "update", "patch"]
+1 -2
View File
@@ -48,8 +48,7 @@ spec:
mountPath: /backup
containers:
- name: mc-mirror
# bitnamilegacy/minio-client:2025.7.21-debian-12-r3 replaces the dead quay.io/minio/mc image
image: docker.io/bitnamilegacy/minio-client:2025.7.21-debian-12-r3@sha256:73bd39f7899a0cef12b8dd5df13aa93a3ed1aaa44236542442e9ac76819ac158
image: quay.io/minio/mc:latest
env:
- name: ACCESS_KEY
valueFrom:
+2 -2
View File
@@ -49,9 +49,9 @@ spec:
echo "[init] Fetching JWT token from Authentik for paperless-ai-agent..."
# Get JWT token via client_credentials grant
TOKEN_RESPONSE=$(curl -s -X POST https://zitadel.riotpiao.com/oauth/v2/token \
TOKEN_RESPONSE=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
-d "grant_type=client_credentials" \
-d "client_id=396757803559027071" \
-d "client_id=paperless-ai-agent" \
-d "client_secret=29skAhE0tBzWn0qrPvmPTQMva0joFjcCr7msDQr_VXQ" \
-d "scope=openid llm:inference" 2>/dev/null)
+2 -10
View File
@@ -9,14 +9,6 @@ metadata:
app.kubernetes.io/name: paperless-ai
spec:
replicas: 1
# Recreate, not RollingUpdate: paperless-ai-data is a single RWO PVC. With
# RollingUpdate the new pod tries to mount the PVC while the old pod still
# holds it -> "Multi-Attach error" / stuck Init:0/1 forever, rollout wedged
# at "1 desired, 1 ready, 1 updated, 1 unavailable". Recreate terminates the
# old pod first so the PVC detaches before the new one starts. Same pattern
# as the Forgejo gitea Deployment.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: paperless-ai
@@ -51,9 +43,9 @@ spec:
# Get JWT token via client_credentials grant
# Client secret sourced from environment (injected from paperless-ai-config Secret)
TOKEN_RESPONSE=$(curl -s -X POST https://zitadel.riotpiao.com/oauth/v2/token \
TOKEN_RESPONSE=$(curl -s -X POST https://authentik.riotpiao.com/application/o/token/ \
-d "grant_type=client_credentials" \
-d "client_id=396757803559027071" \
-d "client_id=paperless-ai-agent" \
-d "client_secret=${LLM_AUTH_CLIENT_SECRET}" \
-d "scope=openid llm:inference" 2>/dev/null)
@@ -0,0 +1,144 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: secretrotations.homelab.riotpiao.com
spec:
group: homelab.riotpiao.com
names:
kind: SecretRotation
plural: secretrotations
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
metadata:
type: object
spec:
type: object
required:
- provider
- rotationInterval
properties:
# External system: authentik | forgejo | minio | vault
provider:
type: string
enum: [authentik, forgejo, minio, vault]
# How often to rotate (hours)
rotationInterval:
type: integer
minimum: 24
# Application ID in external system
appId:
type: string
# k8s Secret to update (name, namespace, key)
secretRef:
type: object
required: [name, namespace]
properties:
name:
type: string
namespace:
type: string
key:
type: string
description: "Secret key to update (e.g., MINIO_IDENTITY_OPENID_CLIENT_SECRET)"
# Path to git file that holds the secret (for .enc.yaml files)
gitPath:
type: string
description: "Path in homelab repo to .enc.yaml file"
# Ansible template values to substitute
templateValues:
type: object
additionalProperties:
type: string
status:
type: object
properties:
lastRotationTime:
type: string
format: date-time
nextRotationTime:
type: string
format: date-time
lastRotationStatus:
type: string
enum: [Success, Failed, Pending]
lastRotationError:
type: string
lastCommitHash:
type: string
---
# Example usage:
apiVersion: homelab.riotpiao.com/v1
kind: SecretRotation
metadata:
name: minio-oidc
namespace: secret-rotation
spec:
provider: authentik
rotationInterval: 2160 # 90 days in hours
appId: minio
secretRef:
name: minio-oidc
namespace: storage
key: MINIO_IDENTITY_OPENID_CLIENT_SECRET
gitPath: k8s/argocd/secrets/minio-oidc.enc.yaml
---
apiVersion: homelab.riotpiao.com/v1
kind: SecretRotation
metadata:
name: portfolio-agent-oidc
namespace: secret-rotation
spec:
provider: authentik
rotationInterval: 2160
appId: portfolio-agent
secretRef:
name: portfolio-agent-oidc
namespace: portfolio
key: CLIENT_SECRET
gitPath: k8s/argocd/secrets/portfolio-agent-oidc.enc.yaml
---
apiVersion: homelab.riotpiao.com/v1
kind: SecretRotation
metadata:
name: forgejo-registry-token
namespace: secret-rotation
spec:
provider: forgejo
rotationInterval: 2160
appId: rock/riotpiao.com
secretRef:
name: forgejo-registry-secret
namespace: kube-system
key: REGISTRY_TOKEN
gitPath: k8s/argocd/secrets/forgejo-registry-secret.enc.yaml
---
apiVersion: homelab.riotpiao.com/v1
kind: SecretRotation
metadata:
name: minio-root-credentials
namespace: secret-rotation
spec:
provider: minio
rotationInterval: 4320 # 180 days in hours
appId: root
secretRef:
name: minio-creds
namespace: storage
gitPath: k8s/argocd/secrets/minio-secrets.enc.yaml
@@ -0,0 +1,92 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: secret-rotation-controller
namespace: secret-rotation
spec:
replicas: 1
selector:
matchLabels:
app: secret-rotation-controller
template:
metadata:
labels:
app: secret-rotation-controller
spec:
serviceAccountName: secret-rotation-controller
containers:
- name: controller
image: secret-rotation-controller:latest
imagePullPolicy: IfNotPresent
env:
# SOPS reads age key from this file
- name: SOPS_AGE_KEY_FILE
value: /etc/sops/age/private-key.txt
# Vault auth (token in projected volume)
- name: VAULT_ADDR
value: http://vault.vault.svc.cluster.local:8200
- name: VAULT_TOKEN_FILE
value: /var/run/secrets/vault/token
# Authentik
- name: AUTHENTIK_URL
value: http://authentik-server.iam.svc.cluster.local
- name: AUTHENTIK_BOOTSTRAP_TOKEN
valueFrom:
secretKeyRef:
name: authentik-bootstrap
key: token
# Git
- name: GIT_REPO
value: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
- name: GIT_AUTHOR_EMAIL
value: [email protected]
- name: GIT_AUTHOR_NAME
value: Secret Rotation Controller
- name: FORGEJO_TOKEN
valueFrom:
secretKeyRef:
name: forgejo-registry-secret
key: REGISTRY_TOKEN
volumeMounts:
# Age key from ExternalSecret (synced from Vault)
- name: age-key
mountPath: /etc/sops/age
readOnly: true
# Vault auth token (projected)
- name: vault-token
mountPath: /var/run/secrets/vault
readOnly: true
# Temp working dir
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
memory: 512Mi
volumes:
- name: age-key
secret:
secretName: sops-age-key
defaultMode: 0400
- name: vault-token
projected:
sources:
- serviceAccountToken:
path: token
audience: vault
expirationSeconds: 3600
- name: tmp
emptyDir: {}
@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: secret-rotation
resources:
- rbac.yaml
- crd.yaml
- external-secret.yaml
- deployment.yaml
commonLabels:
app.kubernetes.io/name: secret-rotation-controller
app.kubernetes.io/component: automation
managed-by: argocd
@@ -0,0 +1,53 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: secret-rotation-controller
namespace: secret-rotation
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: secret-rotation-controller
rules:
# Read SecretRotation CRDs
- apiGroups: ["homelab.riotpiao.com"]
resources: ["secretrotations"]
verbs: ["get", "list", "watch"]
# Update status
- apiGroups: ["homelab.riotpiao.com"]
resources: ["secretrotations/status"]
verbs: ["get", "patch", "update"]
# Read k8s secrets that will be rotated
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list"]
# For recording events
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: secret-rotation-controller
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: secret-rotation-controller
subjects:
- kind: ServiceAccount
name: secret-rotation-controller
namespace: secret-rotation
---
apiVersion: v1
kind: Namespace
metadata:
name: secret-rotation
labels:
kubernetes.io/metadata.name: secret-rotation
+115
View File
@@ -0,0 +1,115 @@
# macOS VM (Docker-OSX) hosting the BlueBubbles server.
#
# ── Why a VM and not a container ────────────────────────────────────────────
# Containers share the host kernel. macOS binaries are Mach-O and need XNU plus
# Cocoa/IOKit, which a Linux kernel cannot provide, so no macOS container exists
# or can exist. Docker-OSX is QEMU running a macOS guest, packaged in a
# container — a VM in a box, not a macOS container.
#
# ── Why this works on worker-2 ──────────────────────────────────────────────
# Verified on the existing hardware: amd64, `vmx` (Intel VT-x) present, and
# /dev/kvm exists on Talos nodes (KVM is compiled into Talos' kernel, not a
# module). Bare metal, so no nested virtualisation needed.
#
# ── Read this before relying on it ──────────────────────────────────────────
# 1. Setup is INTERACTIVE. First boot runs the macOS installer: connect over
# VNC (:5999), erase the disk in Disk Utility, install, create a user, sign
# into iMessage, THEN install BlueBubbles inside the guest. This manifest
# only provides the machine; it does not provision macOS.
# 2. iMessage activation on non-Apple hardware is a coin flip. BlueBubbles'
# own guidance: "test sending an iMessage to yourself. If it does not
# succeed, it's likely best to restart from the beginning."
# 3. Apple's macOS licence permits virtualisation only on Apple hardware. This
# is a Hackintosh. Use a throwaway Apple ID, not a primary one.
# 4. BlueBubbles labels this path "not for beginners", "no guarantees or
# warranty".
#
# Private API (reactions, typing indicators, edit/unsend) needs SIP disabled
# inside the guest and is NOT required for plain send/receive. Skip it.
apiVersion: apps/v1
kind: Deployment
metadata:
name: macos-bluebubbles
labels:
app.kubernetes.io/name: macos-bluebubbles
app.kubernetes.io/part-of: sms
spec:
replicas: 1
# Recreate: the qcow2 disk is RWO and a second pod must never attach it
# concurrently — two QEMU processes on one image corrupts it.
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: macos-bluebubbles
template:
metadata:
labels:
app.kubernetes.io/name: macos-bluebubbles
app.kubernetes.io/part-of: sms
spec:
# Dedicated node. The taint keeps everything else off worker-2; this
# toleration is what lets the VM on. Both halves are required.
nodeSelector:
workload: imessage
tolerations:
- key: workload
operator: Equal
value: imessage
effect: NoSchedule
containers:
- name: macos
image: sickcodes/docker-osx:latest@sha256:3a3c82c79bc4e73531f819ccdfa4053b3084efd7c1f645678dbf8b4b3a24369c
# QEMU needs /dev/kvm; Talos enforces `baseline` cluster-wide, so this
# only schedules because the sms namespace is labelled privileged.
securityContext:
privileged: true
env:
# Generates a unique serial / board-serial / UUID / MAC and persists
# them to bootdisk.qcow2. This synthetic identity is what iMessage
# activates against — it must stay stable across restarts, which is
# why the PVC matters.
- name: GENERATE_UNIQUE
value: "true"
# Identity is only plausible if it matches a real product line.
- name: DEVICE_MODEL
value: "iMacPro1,1"
- name: RAM
value: "12"
- name: CORES
value: "6"
- name: EXTRA
# Expose the BlueBubbles server port from the guest to the pod.
# Guest :1234 (BlueBubbles default) -> pod :1234.
value: "-device virtio-net-pci,netdev=net0 -netdev user,id=net0,hostfwd=tcp::1234-:1234"
ports:
- name: vnc
containerPort: 5999
protocol: TCP
- name: bluebubbles
containerPort: 1234
protocol: TCP
resources:
requests:
cpu: "6"
memory: 14Gi
limits:
cpu: "12"
memory: 20Gi
volumeMounts:
- name: macos-disk
mountPath: /home/arch/OSX-KVM/disk
- name: kvm
mountPath: /dev/kvm
# No readiness probe on purpose. The guest takes many minutes to boot,
# and until macOS + BlueBubbles are installed BY HAND there is nothing
# listening on 1234. A probe here would crash-loop the pod through the
# entire interactive install.
volumes:
- name: macos-disk
persistentVolumeClaim:
claimName: macos-disk
- name: kvm
hostPath:
path: /dev/kvm
type: CharDevice
+10
View File
@@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: sms
resources:
- namespace.yaml
- storageclass.yaml
- pvc-macos.yaml
- deployment-macos.yaml
- service.yaml
- networkpolicy.yaml
+19
View File
@@ -0,0 +1,19 @@
# iMessage delivery for the cluster.
#
# BlueBubbles' server is a macOS Electron app paired with an Objective-C helper
# that hooks Messages.app private APIs — it cannot be containerised on Linux,
# because containers share the host kernel and macOS needs XNU + Cocoa. The only
# way to run it on Talos is a full macOS VM under QEMU/KVM (Docker-OSX), which
# needs a privileged pod with /dev/kvm.
#
# Hence privileged PodSecurity: the cluster default from the Talos controlplane
# is `enforce: baseline`, which forbids privileged containers and host devices.
# Scope is limited to this namespace.
apiVersion: v1
kind: Namespace
metadata:
name: sms
labels:
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/audit: privileged
pod-security.kubernetes.io/warn: privileged
+22
View File
@@ -0,0 +1,22 @@
# Default-deny. This namespace runs a privileged QEMU VM signed into an Apple
# ID and exposes an unauthenticated VNC console; nothing should reach it except
# opted-in clients.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: sms-default-deny
spec:
podSelector:
matchLabels:
app.kubernetes.io/part-of: sms
policyTypes:
- Ingress
ingress:
- from:
- namespaceSelector: {}
podSelector:
matchLabels:
sms-client: "true"
ports:
- protocol: TCP
port: 1234
+25
View File
@@ -0,0 +1,25 @@
# Persistent macOS disk image + generated hardware identity (bootdisk.qcow2).
#
# This volume is NOT disposable: it holds the VM's serial number, board serial,
# UUID and MAC, which together form the identity iMessage was activated against.
# Losing it means re-running activation, which is the least reliable step of the
# whole setup.
#
# Docker-OSX documents 128GB minimum for the guest image; 200Gi leaves room for
# the installer, the base system, and qcow2 growth.
#
# ⚠️ Single replica (see storageclass.yaml — capacity and IO both rule out 3).
# Losing worker-2's disk therefore means losing the activated identity and
# redoing iMessage activation. Once the guest is installed and activated, take
# a Longhorn snapshot/backup of this volume; that is the only redundancy here.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: macos-disk
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn-imessage-local
resources:
requests:
storage: 200Gi
+31
View File
@@ -0,0 +1,31 @@
# VNC is how you drive the interactive macOS install. Deliberately ClusterIP —
# it is an unauthenticated console onto a machine holding a live Apple ID
# session. Reach it with `kubectl port-forward`, never an Ingress.
apiVersion: v1
kind: Service
metadata:
name: macos-vnc
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: macos-bluebubbles
ports:
- name: vnc
port: 5999
targetPort: vnc
---
# The BlueBubbles REST API, once installed inside the guest. This is the stable
# name cluster services use, so callers never depend on the pod IP or on whether
# the backend is this VM or a real Mac mini later.
apiVersion: v1
kind: Service
metadata:
name: bluebubbles
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: macos-bluebubbles
ports:
- name: http
port: 1234
targetPort: bluebubbles
+32
View File
@@ -0,0 +1,32 @@
# Dedicated StorageClass for the macOS VM disk.
#
# The default `longhorn` class does not work here, for two independent reasons:
#
# 1. Replica count. Default is 3, and Longhorn schedules against
# storageMaximum - storageScheduled with over-provisioning at 100%. Free
# space is cp-1 146Gi / cp-2 8Gi / cp-3 146Gi / worker-1 292Gi, so a 200Gi
# volume has only one node that can hold even a single replica — a 3-replica
# volume fails outright with ReplicaSchedulingFailure.
# 2. Binding mode. `Immediate` provisions the volume the moment the PVC is
# created, before any pod is scheduled. Combined with strict-local that
# pins the data to an arbitrary node, not the one the VM runs on.
#
# So: one replica, kept local to the VM, bound only once the pod has a node.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: longhorn-imessage-local
provisioner: driver.longhorn.io
allowVolumeExpansion: true
reclaimPolicy: Delete
# The pod is pinned to worker-2 by nodeSelector; wait for it to be scheduled so
# the replica is placed on that node and not somewhere else.
volumeBindingMode: WaitForFirstConsumer
parameters:
# A qcow2 backing a live VM is latency-sensitive and rewritten constantly.
# Serving it over the network from another node's disk would be the single
# worst thing for guest responsiveness, so force it local.
numberOfReplicas: "1"
dataLocality: "strict-local"
staleReplicaTimeout: "30"
fsType: "ext4"
+32
View File
@@ -0,0 +1,32 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: secret-rotation
namespace: argocd
labels:
app.kubernetes.io/name: secret-rotation
spec:
project: homelab
sources:
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
path: k8s/apps/secret-rotation-controller
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: secret-rotation
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- RespectIgnoreDifferences=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
@@ -249,26 +249,3 @@ spec:
selfHeal: true
syncOptions:
- 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
+8 -66
View File
@@ -30,96 +30,38 @@ spec:
syncOptions:
- CreateNamespace=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
name: authentik
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
- repoURL: https://charts.goauthentik.io
chart: authentik
targetRevision: "*"
helm:
valueFiles:
- $values/k8s/infra/iam/zitadel-values.yaml
- $values/k8s/infra/iam/authentik-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
---
# ZITADEL core objects (ServiceAccount, Role, RoleBinding, ConfigMap).
#
# The chart declares these as pre-install/pre-upgrade Helm hooks, and ArgoCD
# prunes hook resources when a sync ends. That deleted the ServiceAccount and
# ConfigMap the ZITADEL Deployment references, so new pods failed to start with
# `serviceaccount "zitadel" not found`. This Application owns hook-free copies,
# which makes them ordinary desired state that survives every sync.
#
# Wave 2: before the zatadel chart itself, so the ServiceAccount exists by the
# time its Deployment is created.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: zitadel-core
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/infra/zitadel-core
destination:
server: https://kubernetes.default.svc
namespace: iam
syncPolicy:
automated:
prune: false
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
---
# Raw IAM support manifests: the SMTP -> Gotify notification relay that ZITADEL
# points at, so verification and password-reset codes reach the phone. Wave 3
# alongside ZITADEL itself; the Secret it reads is created by sops-secrets at
# wave 0.
# Raw IAM manifests: key-rotation cronjob + authentik migration job.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: iam-support
name: iam-jobs
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3"
+1 -1
View File
@@ -1,4 +1,4 @@
# Wave 2 — dedicated per-app CNPG Postgres clusters (zitadel-db → ns iam,
# Wave 2 — dedicated per-app CNPG Postgres clusters (authentik-db → ns iam,
# temporal-db + visibility → ns temporal). ONE App, ONE folder (k8s/infra/databases).
# CNPG operator is Phase-0 bootstrap; these Cluster CRs are GitOps — no circular
# dep (they run after ArgoCD is up, before their apps at w3/w8). CNPG generates
+65
View File
@@ -110,6 +110,37 @@ spec:
syncOptions:
- CreateNamespace=true
---
# iMessage/SMS delivery. Raw manifests: a privileged macOS VM (Docker-OSX)
# running the BlueBubbles server, plus its dedicated local StorageClass.
#
# Pinned to worker-2 via nodeSelector `workload: imessage` + a matching
# toleration for that node's taint. Until worker-2 is provisioned this app
# syncs everything except the pod, which stays Pending — that is expected.
#
# No CreateNamespace: namespace.yaml carries `pod-security: privileged`, which
# the VM needs (/dev/kvm, privileged), and an ArgoCD-created namespace would
# not have those labels.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: sms
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "8"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/apps/sms
destination:
server: https://kubernetes.default.svc
namespace: sms
syncPolicy:
automated:
prune: true
selfHeal: true
---
# Document management. Raw manifests (no Helm): postgres is the dedicated
# paperless-db CNPG cluster in k8s/infra/databases (wave 2), redis is
# in-cluster only (no PVC), media lives on the cp-3 USB HDD (see
@@ -171,6 +202,40 @@ spec:
syncOptions:
- CreateNamespace=true
---
# Consolidated: homarr + homarr-patches → homarr
# Helm chart + values + PostSync hook patch (fix-probes-job.yaml)
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: homarr
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "8"
spec:
project: homelab
sources:
- repoURL: https://homarr-labs.github.io/charts
chart: homarr
targetRevision: "*"
helm:
valueFiles:
- $values/k8s/apps/homarr/homarr-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
ref: values
- repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/apps/homarr # PostSync hook: fix-probes-job.yaml
destination:
server: https://kubernetes.default.svc
namespace: dashboard
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
---
# Portfolio site at riotpiao.com - static Next.js site from rock/riotpiao.com repo.
# Points directly to infra/portfolio/base (bypassing repo's own argocd-apps.yaml
# which has wrong URLs). Image built by Forgejo Actions on rock/portfolio repo.
-20
View File
@@ -1,20 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: talos-repair
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "6"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/infra/talos-repair
destination:
server: https://kubernetes.default.svc
namespace: cicd
syncPolicy:
automated:
prune: true
selfHeal: true
+2 -1
View File
@@ -34,9 +34,10 @@ spec:
- https://prometheus-community.github.io/helm-charts
- https://grafana.github.io/helm-charts
- https://helm.releases.hashicorp.com
- https://charts.zitadel.com
- https://charts.goauthentik.io
- https://strimzi.io/charts/
- https://charts.bitnami.com/bitnami
- https://homarr-labs.github.io/charts
- https://go.temporal.io/helm-charts
- https://portainer.github.io/k8s/
# Substrate charts (cert-manager, ingress-nginx, reloader) — app-of-apps owned
@@ -0,0 +1,25 @@
apiVersion: ENC[AES256_GCM,data:Fc0=,iv:UFqMFeZTJZtFMoNeusjAoSI60U9StZS4IXw+n8JQCRM=,tag:uUvY4+jcjiSJHs0HDS3/7A==,type:str]
kind: ENC[AES256_GCM,data:NUw86IEw,iv:gh4Vqn6uteYnYIFcXT/QVxRPKm+G+j2aIVuV71nZ4k4=,tag:nZA/x75Kx4+CoSx66BsvkQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:qex150pm0eGwYiiI05y9LUs=,iv:5Tk001T796c4HFikoh1ZVB6ierBfDqOUkFs5ilkr+V8=,tag:LiDAiCH7w6aRS41VKIlIKg==,type:str]
namespace: ENC[AES256_GCM,data:nI91,iv:c1hBWGMkVb+vg+MKNzty8CFfXxQabXEtT+EpiuQZNvo=,tag:BsGtJDbBadDdTa687+Sdag==,type:str]
type: ENC[AES256_GCM,data:3rXawlSD,iv:FUtLK4KP7v/SBF08ZcU6siIO1qfjlJirf2x5PyzAjuA=,tag:mSIQ+Y43+wDfZGRbQ6a0Og==,type:str]
stringData:
AUTHENTIK_SECRET_KEY: ENC[AES256_GCM,data:i5tVOWnN9a/cfhs6gckveRxUpiN7Ie227R/74kOIz6lQ5US2hkf03W5qSGCrzheL3DB0i8+d0iJYsApjn/wCiN7ndZZVFko3W5jDfVPybxc=,iv:djF1G9JHT5B7ziTokA/UuPpG+tG2LvFU4uMAR0x4M6o=,tag:vHOd7jLYkM8Evnr0QUVrCw==,type:str]
AUTHENTIK_BOOTSTRAP_PASSWORD: ENC[AES256_GCM,data:PjvYtH03CzQbTXEqt/Z18+u1zJdok7DLwDn51FUZXDA=,iv:pSwSm8hB0/Q8uOAcOZ1rTqq7BF782EglG19GGeghm1E=,tag:qGCQFWCrwsWBOiQ07ySMUQ==,type:str]
AUTHENTIK_BOOTSTRAP_TOKEN: ENC[AES256_GCM,data:1tnekGo0GIemJCMRenjN0yNeSiiMl/kky8ms84TsqBLaQ0lQzPxjq9qtgNJwoAsufxrznrkDVKjeOEHNYcA82A==,iv:NNFWSLMILoWccA9ihy1OArSS1X3OySB7PIHdXZdyzlg=,tag:t2w+KyReOXftTrU9pBQDnQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBSZGRKWm9PTkNSOENtREgx
M2V3Z0xxOXRhRGVRclo3SzBKRkc0VmtCY0NnClJPSGJRcUlzWmNFeTZ1bmorNEV4
U3l3TzVlMTlrWkJnQzBtUTJtcUwrM3MKLS0tIEJrMWZrbllYYUZKN1V4MVMzcXAv
eUtheHlNMHpsTEJXZ1FTMFVxbWloa0kKLRkE1Du+4gdOLerOl9y0mZw+8fqfECQY
uP64Q+9BSuPYNiTLZvYDzmdoy+KTS7i7C9rf+iU96R/crbQLamV9Ew==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T20:37:07Z"
mac: ENC[AES256_GCM,data:b3pDx340d1iZxsPPXzACckoLBrW+XjX1qlGrWEEI30j2Ao0hewTRZJyHbdI97A6b/hqzBN/4tjqqkdqm8goNhUQweMLTw86X6sALuE0aLS9wK53qjqih8fQwQBbgLDtjFXtW26h+smIm7aB5ZSDvU2le44HDXuWd83sWtCavNq0=,iv:ZMDsoKpygFJrYpXy2dA0IlxAi0jt24rxF72eUzgQs7k=,tag:WWE7G7+c9JK6fF1U9V8igg==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -1,23 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: cloudflared-credentials
namespace: cloudflared
type: Opaque
stringData:
credentials.json: ENC[AES256_GCM,data:LnJVGuxJAOwDYbLgX00OHICDDldZgwgvGafOIwKoJKnjg3FjFMQYThNCOL/H+6tctuKixJNcAckjg70tmCKWxNq9I35XX/YSqob6Qv4fywv32hsNIAe44BU035MGZ5zmc6bZZ8wAgTJZw6Th4TzY7taFBCRdj2fW4T+qVfq7strn68G4ufVMK6UXlOkEDLjR2Vxj32lqSLdbdMma96XB+265hwL6IVfaugG662mcGhPwgHk=,iv:NFmyHN5m2mJUBgn1qgmJZ2YgkFxBZyp7+gU1Ng6B/7M=,tag:/66JtHK5Yu7pRxB0b0eifQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBsaDVycUE4RFpoRjdpTlpX
dGpzN3hwRFQ2cmtXVXVzWnZJOXJEZXZRWEhNCkJwbm9QV0lGcGtNRTJqbStrc3l1
Q2M4bE1WbzJpTXZQWjJyTGpFS0JYcVUKLS0tIDBvU2U5aEtVTTZUaE85eEkyYitG
bGtSVkhKNnRuNlZWMWh6Z1M4VXM1WU0KdusvtM85V0xrO+uJFUY+UH0tuwdF0GiW
79J4Rlxk8ne25psKsvwjLYLxZ53xjFHCC4FCHTNkYwTKCDEKjw46Lg==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-26T02:45:04Z"
mac: ENC[AES256_GCM,data:KF7KwXCnNLAg1YI3u0lLI/ho4sITHAq09ac2WO4TK3TCthkuivb89YjhuuJKs0NFhjn6aRLqmZavUqcXH2HujiryqbLNVexOShQM0wSzgCzmWdbi0UzpgJ4KcQA3PufDtsQz4HHLXx396EbZrM3Ie3WSu/PvRm5iWOxzTGCGc9Q=,iv:Eo920oXXD1FLZVJXywZ6C+LRL+qZjmnZXv0+Oa/GMb8=,tag:6LuPVSet4WjvrvGUAxaJlw==,type:str]
version: 3.13.2
@@ -1,30 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: comfyui-oauth2-proxy
namespace: comfyui
type: Opaque
data:
client-id: ENC[AES256_GCM,data:jFLgcrh1tiAU0pijGxy+/mFsFHzI5zSi,iv:7GYL4xtPnzyZWtEA0egPHhzvpXGevkIH3Cvr+oD9hKI=,tag:UeDTIKlfcYnOOOJiPNgN0g==,type:str]
client-secret: ENC[AES256_GCM,data:9QFu5a2EkEMjc/B0moPIkMEChrN0Qh5BQtAzFcvW4liKxzVAPbTKgQOJeND+NQjn5ZoE89kqqRyqNJF3bT3H1pWoDFngk9J2X7BN/9r6Qyubias0Jukydw==,iv:xf2kT0fbnJat3L3xxUYm3ht0YvHptU/RIdt9Eh0D6xU=,tag:8iknaQ2kKP1OZ/1jRCa+1g==,type:str]
cookie-secret: ENC[AES256_GCM,data:Qq+l4go/CFtfsZrgYUcTj9bB4sreu2GEqJnu15H6fxIlD7IgnmrodPoIVNpWcdm6LSYabGjm2jraN6RP,iv:EOoDHgvIydzHCC5POohjPq3Vj2YTpzhIhvdG2FzKKzc=,tag:iDXqzFU1Z79bzpG4I4xfww==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBxOUtCUDduMVU5U1J4WXZX
Q1VzaFRVdXlHaEZpeVJNOWprdDhUOEd6Z1hzCi9QZEpXN3NDSENzUE9RTGdpb3Va
eVNHVTFLbXpMU0x5UWlFLzZnVlllWE0KLS0tIDZldVNqR1loNHlXZWovNWZ4WjFD
dVErenJDM3haSStxZEVWZUtBbDBIZ1UK9x6Gy4drjfUz2goAaI+oeUDWdNHQFAJl
JGI+X81r/6Krg5N8QDa/mE96E2M2G0yAO5FS1YbpCKOITUEyheacnQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-27T21:17:08Z"
mac: ENC[AES256_GCM,data:tTGmWFidEWTWHgKPV7b5ABOJwwlP1yQDK5Jd60c9ko6IsmP2GBBPCth7kJgPXuPcw+NHbJ7yt7bcVto7GkPdtY9daJmjPq0Gt16ZO4Xi4hCk22JVKqi/KJcKD/KB0r/VO9w093/Who5CcYgqifextfca4CuxS5m5dcOn4kRJCfE=,iv:0fDvvQr9wp8IrD16u88Ml4HsPEeMhd8R3ljQJoIpw3E=,tag:AanM7C6vbaBBmus1u1GRoA==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.3
@@ -1,27 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: etcd-defrag-creds
namespace: kube-system
labels:
app.kubernetes.io/name: etcd-defrag
type: Opaque
stringData:
ca.crt: ENC[AES256_GCM,data:vso/Ouz5QRoRF2CFj9TsGYhoGkX8oGJIeiFCoS92AAQbyyGPTiZ/fPNFM03p+TpeKDb+Z+Fyo/HXuDeT5Fee5kjPnUHXf2hiUBmJLyHnPMp5i57dRzTC8X4VaNeV/1MmP2WpkWwieWOGBxJVIIrgeQhQexxmIzSlQ5Io9dVOH8F6iyakwdf7qfLFGdoer3rfrXadSOJtKRpLneFJDL2OmR1042LZ8KtNuRRXI/qrdpgr2bnyxFuCRc+tk5QY73mpwwWfKGn6FW/rVdjiEftkC+AvTUItx7RkYMrz0PlTYPUOLustEF2gIgWheYZhE5ebZDurVSG//sudjASliYJItwgSk3G5mZihOSDA7Jcus+U8p1iHQ9CQsey3CSAMmuSGRFmq42kI8135kjtBYrzVswHjX1/51qPMyA2TIeWlRJ7AJlHihG317gT1vPeM1ULl3PkuCxaBLCtjotZBzKLJ/6zXr1ig77Baz71YOVB0D0MgFvzIdao+mdxS8WLOZdiXOCwbI5Y2eo7wJHG4BnpKEdx1rjPkv7DI2NNEoXA7e5FvJWinyAxOy6YGH4Fv6dTlEVhI3Nl/1X+CeafL6Hk8YoqA04Hm+/dJCZi7SsF0r6zfPNNsZLivM8DN18h8KYM3eVY3CwtBldE7atz+oprQ6K/yGUbblHRQwuG3sH3AHrCKLPiUrMIi0jWc0IpdPZRZFL7SnMULfV05QgcvGySOJCuQU+/OYzrRs7umOjwpqvi9PyJYVXBliiYfJutvmfpFAhQj,iv:KnW9LYNzxZ6r3Q6LTeOrgSLLPgiJlibVp6YmTEKBkjE=,tag:KTL1nOWdNWDA4dLVaA4//g==,type:str]
client.crt: ENC[AES256_GCM,data:IEmZe505SwkW3U5vmRtFxR6PpuSFGWEHflUZwyhSODzYG1zLM8olVWDtH+yUVI54kLXxTtcpMkKrTBjpAxHG8zUTLPdTCVzPMq6U20jH4PYFMzoldnSgpRl/eiADUmE4I32CBbPdM5nkPvjVEYMmRafEGyJctjG6Shhh/sUO1yE9A+h1R7o3UCRPttWTozSKN4dPi75v1TSAOLyXqvQYl5Ea9iKW47ea7Q+XTcoKfMPXsTJp44kiZnVW0VT/7xNydR7AVWJJzYltY5380j0Ho+7PfNd+QqmdodkaZPS4b0PaoinMiZ40JnChGVhdS91j3IkUlU1g/+G2iVnPR9KB07xqRubqiASxIvpSaSCeYl3rR7+dXYX18VpWcWnJ6XoREoqxJiqmMQWYSROYDTd4G/zs4Usuh5r0wIodmCjud+g89mmXFtH0PbsMCVvZDUswjVfeT2PUBm036TXlxF/1aAy2GDqycbPgJgDLyw9tdiQUZe2iA+Z/Wx80qgQjmmqCaw77cDergLFj9KnByl3Tinwfm+IWFeQiDW+fwSzz+/vrpl55BkSVkVhw7r2Jd6zyg4VtLVXbxYhzdNz695N+fjkZHuPE5tCwr4WCzAg3vSS9hA7IHo8k93WO8s6+KlyjuwTdjPIYZghebO4ktwdqvhvuqxv8Nzwpc5J7Z2H2IChjXg==,iv:l8BwxxEfHfYrm+mM5UE6Yj42DeeU9y23jjIE1KjIiME=,tag:HwRT6/myIsxc84BYDj3K6w==,type:str]
client.key: ENC[AES256_GCM,data:QD/Glf3lFslBEdEoJ3CNrYzlw2HLCvGSeVuJLtxnFne2aJOuw0sk/e0h/kSAxvG4dZ+LIWJLLbqTjKHOAhkvS/ZOA7GSea4ubNkvTkw8+JBRuYwb2D2iB8kZhlER/CpiGkYJukjkgGP1CzHdvBY1M88pW9w6rH74MD3jY47qExIaNduQj5vkUQZ8ajqkd79l0nk98ZTawfPG4AFcEtAIKAPIuubT2GZ3qqNLjOI7agbtRv8xI44hhb2wj5J9xdl1GrbOWwG5EOo+hLNrcAAOCcWBpNTUCKBy7mrk2ID8jX3HsFWN76DDlQzkhcphMhlM7g==,iv:49HGJAEgllEkB3YSiZLmbWs/vlbFbY52kiKesdGVC/Y=,tag:g8YZ9at3V2l6RFRfvO2lAQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4Z0h0R2dhbDdRcFJydE9o
R0hWeCtwVzluZWZlR0VtOXcxdW9FbklOQXlzCmo3eXNyNlFjejNETkJiVVRVWFhr
a2pORnhIQTI0UUk2N3dWMDZJcEhYU1UKLS0tIEduaHlmMHQza1A0TGVZb0JrbTYz
MWJwQjcrdkkzQXhOTmhycERsM0FtNVEKG+gBRcvUsyfRzuW999EQNzyDh2/5fY6p
8ab2xvTOJPiMUBTBuQZsA+iDImytUO/02ROQsbEZkALijp1nqrsV/g==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-07T05:37:26Z"
mac: ENC[AES256_GCM,data:vu6ltV0jYQNGV4eqIcqAp9p2TJ8ULBX5JBELUgm/nA9GX+I6Pv3fyjpcHRSWY0HbLvCwgXd22CG4IYcj4HJFtQGZR7OcQA5WM5plZH3RCPdvPNix/798vkETfpJxorQjlHoq4nlr3dEmUCbzdtE07qV2W1M6GJr4C9xKvB947bQ=,iv:aA9mjZCFAoLEhGqkl6NfyBZ3x3vWGfLYYAEe8jXuA4U=,tag:Drdiji33ckDd8ildpvSEXw==,type:str]
version: 3.13.2
@@ -0,0 +1,23 @@
apiVersion: ENC[AES256_GCM,data:ECE=,iv:bISz4HovH++X7DW1Qj8Cw0L6+EvPB0+68hh7tfyW5C0=,tag:w+SjD7MsfeIuSf62n+Zl7Q==,type:str]
kind: ENC[AES256_GCM,data:AxXaV3Mb,iv:xfJb354Rrjz3zLctW2i6hl40yh9EsfIqKxDvmn8jqnU=,tag:1xQ/RR3KhPLKCTWCYjebJg==,type:str]
metadata:
name: ENC[AES256_GCM,data:gdQ4OoMYKUPEbsUeA5OI4i1xnh8=,iv:IA2kaCyqQmkYpLolvcTF4aleh+yd/ImXJMhRMvpGCgo=,tag:8qyYYw4EhBKKPzEmpepAeQ==,type:str]
namespace: ENC[AES256_GCM,data:/mOSyXWmRg==,iv:cpeHUTlMqlJzJttGtuR3DoiMtvVqFmDS0/5Tl7K7c2M=,tag:q4GxWgWn2wQJxJHqnq4WQA==,type:str]
type: ENC[AES256_GCM,data:92OcrsuV,iv:Z1XBy6iZ6unGrK4/SSdDa58pbPL3022gU/f1AOM4uvc=,tag:cd4wWRq/ohm6BJ/Eo6HOIw==,type:str]
stringData:
REGISTRY_PAT: ENC[AES256_GCM,data:zDKruOXhzsIcFZTTp4r4r9rwHih9uf1cCp06KZ6eIXSIZDvR6aQY4A==,iv:5Vch5Z7xn1KkRxrgOs2p/M58nd6WhtcuUhoZDCi8LSY=,tag:14SAZIWGOxJMqLBtOorjYQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIaG5QVUYyNjhvaENPcUFy
K3RSSVM2N2hTeFhNK0M5YWZmODhzZU1HaHlFCmNGZWdFcFEvanVwcXpGSmVvRHVx
aG45c2lBY1RuSTYwbDZTVk1QeHNWOTQKLS0tIDg4TnVMNjNtaU1VQk5zQjUvU2hM
R245ZVdqc0ZWWVhJb3dOZ3lpU3JjUlUKawSg09ZPq8FKx5tvOVZZ+K4yh7eTQsUp
be8mWUpS0+eEmNqh35BwU3HrETMQFA6a1kjVp30JOMtqa5rbYlzF7w==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-23T23:14:53Z"
mac: ENC[AES256_GCM,data:yzi6woUaUCi6w9pG/eKnU7k/VfZgoXg/tW8p9joG8p7iabLWMdlXlx23m/CItw/NE0zeGWA5iZPPFZXOit2vN36VzK3kQNoFW6QbhvYLZi+78C/RWIdcpZkxB/tPIRG0vq8Q1SA+rwGjG/0xeAFh+R7k+YBTMd2XWBH3P7EI5T4=,iv:kf6MDaAVrtDvPIEjHMMLxXDSDRC3I1GpsPeJFUYppiw=,tag:NukVbGLa9EoMYmRsa4nBtA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -1,24 +0,0 @@
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: forgejo-registry-token
namespace: cicd
data:
username: ENC[AES256_GCM,data:oRNecs0a7rs=,iv:/5EFdNqpSjoxe7XDmAEDMd6lXd0SMsRiMmBM9SIwDHY=,tag:Wi+ggoP+9/vgz9rHrxB9nA==,type:str]
password: ENC[AES256_GCM,data:HbmwnWk3ST5SDWXwIhu+M5f/FOfFbJHaG0Q/IWrO63HRXmJTmLMpbjtWIXC9IMFoHeDZ1MNAV8A=,iv:ztJZgpaS//yYCiyr6/ubs/UiXkbGMnokvy4Pe45jQMk=,tag:WMpawc/SVAV4mxsCd6XqAA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjQmhxN2E3ZnNTdkR6U2c1
ZStsQktkeWUwMEdmdFIyMkRYRVk1ZGppWlZ3CmUzR2JubWw2VjdWUGt6S2lpNmQ3
cDdSWjJiYzRIWS9UVERpVDFJenFnaDAKLS0tIGhDSmNMSjZwcDVPR3U0dUcwNk9Q
WmdGS1lyS3dkSXIvOFhyRklGRll2UlUK8NGts6a6Fv10c6gxIIPqIIqfgzQ3EEyk
SFrMThwbdaOqLY4AMeDrg+k0Y2t1Xj/XOvdNbRCRictDwZUsptvO7A==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-06T02:01:24Z"
mac: ENC[AES256_GCM,data:imebczVzG9YWd42ER4a+w+Bi3dxbN0HFm/1igxzJt6Z0HG/q6i0s59swpGVruk9dQJWrlO8KKqX739ATp2KGC5szrEuRFvPemF594XFCg6ykW+ZeP5VtTvpJg2nHv7/Pu+71ljKKi4gqOJ4SJDn8owAbSN+su6PiT323wax930A=,iv:iG/G2+qQkvbBU95w/GG2PrbAHDOaamuvjqMDBxSW6zI=,tag:wy9Vn72HBDHqCyzTcV15/Q==,type:str]
version: 3.13.2
@@ -1,28 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: grafana-gotify-bridge
namespace: notifications
type: Opaque
data:
app-token: ENC[AES256_GCM,data:MnkKT0/ubJctmLTFXjdCZjNlz+KgQLsPwIKCmPF2weEtMw6ieRfb3+B7OUlIiDqpktdPaJA/znsMOYPXT2aXDqyKquA=,iv:vcAGkgfTheoViZ7OzJJhbNf0boIbRBZLr60ieW5DhLk=,tag:iBDJEjGScxHopkNExh7jDw==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBYYWZYNFBHbVhRSkZoa3dH
dWp3WllyVmRPcExPTndZUlFsTG1YMHN6WW1jCjRiS1M2U0tIM0tCdGhJOUQ0c2JW
bXNTTExDeVVmcExuNStldFdhTHMrVTgKLS0tIDN3d3JKSEtIUUFhVHROOVBUaUNO
UUVGcVVkOVJmN294dE5DS1R1UkxiZzgKx0MEkIrFsO2dQwjY2410Nj0Nyy3QgC3L
T844m4WT+mxFwB69/y0luGkn4aXMS3xrHHWUC+vjAkM4Unp+MKhhrg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-27T19:35:38Z"
mac: ENC[AES256_GCM,data:Gf5jnvWP+jBRc8fvQHyrnHnTDaaUTcXQgoOqm7Drax5FpBYEHa36pdKaUq+XR6VBd7ch6Ia/f7YavhS47ybgR3jn+w+trMxkpwtZhm5clw2Lo3Hm7Cvi4fG4qhcjm1C8/NOnWGsi5Qm+TJqyG509p9HNTvM99SzElRovPZldLL4=,iv:10hA6nq0WIO0a2I4ZW6uPGXPjesFgxrwmXohNO3FZz4=,tag:bJ37e8jvgEactq6T/pM2UQ==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.3
@@ -1,28 +1,23 @@
apiVersion: v1
kind: Secret
apiVersion: ENC[AES256_GCM,data:rGU=,iv:w1yJeQvN0MPZlPx8VQZmC6zkRaHW4rtjjROea0Kx9X4=,tag:qgYgVUGMQ2BtN0RZ0nZYjQ==,type:str]
kind: ENC[AES256_GCM,data:wp2QP1he,iv:C8c/jKh5pnBOtHqXsTEchGf9DACU/5hOJ0Mp/+Jk1kg=,tag:QUZNeguRXdi3JO6Ov6GMDg==,type:str]
metadata:
name: grafana-oidc
namespace: logging
type: Opaque
name: ENC[AES256_GCM,data:jVsnAcFTlRiKxDoU,iv:bWEqo28kH3bxzfI3ipiY6dsqhIcl9wIxBjFu0mxVGLg=,tag:PVWTveI8xhvLxvXPldhimQ==,type:str]
namespace: ENC[AES256_GCM,data:ZHnVZyd3wA==,iv:3evUnFTQfRz/ZilUQIl/9dsCba3nsX6o/iQsGXMBfgM=,tag:+9kp+VRu1ksroDTbTGmGPA==,type:str]
type: ENC[AES256_GCM,data:bHX+Bu9o,iv:8qqNddsuw4YoHAuJfUcPIqjcqNySIh5Ln8OhsfZxuk8=,tag:P7QGUXo9Io2Y19YqlWv5sQ==,type:str]
stringData:
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ENC[AES256_GCM,data:fGqA1ofVme1lVLxFjWIiU7sbvE9ip2Af7C1ua3+HdidlcVgkwW59vVlXZhgBRdoif86C8LKQSyoCGUqiO+740g==,iv:6Kv2ybbJg1GT8DLqz9P5y+E1zr7vcIBDMUErmvxNTaw=,tag:0wK28B3G/owCVPPqb2sLmQ==,type:str]
GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET: ENC[AES256_GCM,data:qW2SQSsGD0YupU2SMzRaYwkLRjXAwV1OJBnhATpCJrOHWbEytw6IphOmPPd3VZKfHncekA4agXi4K3rUPHIx0Q==,iv:NCeW/UfIl0RvHV39ubN2mDfFp7GTQNuFv8KiEF+7KOE=,tag:lisx4h4PqUfLwBX8ca9Siw==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArTXMxMWdVRHIvbWh1bVNk
bEFFNlgzNU9zTjlzY2o3VGdnbFpnU2lkYmxrCjF4aWVGSlJaNkcxODJ6RDkrVFo2
UkV2RUwwVHFCd0xMNG9RWXdTUHZGaXcKLS0tIHVaWjhaNHRtS2c0UkVzRk83ckhj
OCtBVFR0d1F5amVhV2E0emRZWThvYjAKFW46i9brIL9ZoO1CEn+IewIdoUhvYK/A
RqzZ6xV+mhlDj0zC+SpVcEm/Ki83nMYOj4+vQingZlr/Kr3Nwb0iLQ==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrUkxLMEV3aEpILytic0Ux
V2VEV2EvcENJVnNNRUtBbGkwT0d6eUxYeEFvCjZPSkU2MG5qME5jZ01mRVJwTUFL
M3M0V3c3QXBoRml1Vm9NWW5JTlNRSG8KLS0tIEJnNlFmdFJXb1BPaVJzTmo1RDQv
VXZOVHRHUEZJRUZ4eGZGM1RxSURyWXcK3ZyYCPhRUvpvT/pjPQGJoLIwaktZY6Tg
LS0cIDUhjPFbeu7qUATOrat6vMUi0UREbXSFZ1KyAjNAwJZaUusPMA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-27T19:44:32Z"
mac: ENC[AES256_GCM,data:EJew+p/kJrurcNpLlFxJGTl69hQYxn3IkzP7EDtdSriW/WI7X1xNLC2dp/dTiLomGmG1uh6+2JI9Sc8gXmrRAWLELAbiOaYbcoU6gi89BjTCWGDAMX4lNSIuH40rsGs4YtYj5U7SGfgx5qto4ZH7LxjBmxzPWM/Xz2Uq/I5jV4k=,iv:LdhjhwCfPUIa7D0gved8Twf7ujN+dg3lN8lCnFBFs0g=,tag:bjkwUCMtR8IBL9BkbuXcnw==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.3
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:yUBBV7dLEPld9yJacn+YaOT8M1NJ2ploGIxV66aVOH5x4tDAsskcUQJlzApWCQmORuCuMKg6qfpGgLTojfD5msha+ufVVJXHl7ua+STar1/FPJXaHm+z7YyZdsD5j6f3AH8q18DuLy2DYLjXELmPBsp50g7MDJEm3xLhCUXygjQ=,iv:xl0OtA/2umz0YH5b+6QjlAByZWbvfGOmHqomziNjlkw=,tag:xwkQDZx19Lf6QSwU2zPYTQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,24 @@
apiVersion: ENC[AES256_GCM,data:uiQ=,iv:vknDc16Zrz/lxS1WG3ksG4SHjhJSTDdklVxxwL8eylA=,tag:1srpVd6QTYQGGGYGXT8Cbw==,type:str]
kind: ENC[AES256_GCM,data:K6EPYaNE,iv:VWzawfTDNlag+shjBqwSzZGcQQq/TGxeHDqd4fAh5Vc=,tag:ncPqMYjq/73oMzphWhuuFA==,type:str]
metadata:
name: ENC[AES256_GCM,data:vaRNQHFYfdUz1hzuYO0JxQ==,iv:W7aUJID5mxoO1NQAcyPDYV1FGtBT8myKVMA3s2WgrPk=,tag:4Ef4GBtTR3dVanscvigRmw==,type:str]
namespace: ENC[AES256_GCM,data:HDlmfGwad37/,iv:JFPXxz9xp5vDJRcWy73SYGWDMyHKWQq9Bvy/NJTwTnU=,tag:2ldVXgzKn4xp1rZI/VYgUw==,type:str]
type: ENC[AES256_GCM,data:UgolDLIV,iv:O4himI90UVwx+OjKJXobu10Zg3a5IoQcjAORsdhaWpg=,tag:xltntRsamhQkPEoBNtMytw==,type:str]
stringData:
oidc-client-id: ENC[AES256_GCM,data:RLdxXY/a,iv:US/fBReK7u8WA4IS6TSzYiHQkte2CuVgugIMpA/pIlA=,tag:QKrHGQrzI9YpfNQD+ts68A==,type:str]
oidc-client-secret: ENC[AES256_GCM,data:SS+I92xEkOcJHTfWvZ1AA8hh1bFAtND5adZ6Uh73nIPiqAo958GSag==,iv:9HcmXWrGtflc6WWQe0n/pgB1Xveakofc3hvz5vuE29A=,tag:0emgeyM4GBftVKn/fx7XPg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCWkR5V0ZYSTBSSU1OTWF1
TzFxdnZkUFFIcGJWZ1VUU21ZUnU2cUdob1drCkhVTm02dkZPZ2lNWktOcWZ2c0Vi
dG1PSmZBR2M2anA2Z09iYUU0SjVzTlEKLS0tIGF1eVBBUHdhVk9YSzJlQXVwd3RE
bUd3UTlwR1NiYjNlcGpweTBhdmk5b0UK8pwUoVCr1uBX9dQ7EGRgFMAPk/z8N9Fg
6GRUORFug5h7x5kjXK9sU0yTHjFSekwBi75GLng0IrLEGJxHVM5wVQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T21:07:30Z"
mac: ENC[AES256_GCM,data:NupjQWBJHMOo3/VX5n5nRx+RFT14N71XkrjXRC/VCP7i7H8yn3tcRdH9g14usgWunmqg2KKpGMUIZ9bsQ9Vds9sBvUKvLyRkfQnPwPh9+PFCF3ZZLPgzqcT19kg4I3QabmO+LPzQXsZWLKIqgF9/B60EVIgYyKhZmP7XblY5AVo=,iv:r0deq8+txE8TwzdtxOMaIAZbZc2fu+Tp4Jd2HZGbkhM=,tag:qMpZp6LcWjJHYMDp9ecBkA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,23 @@
apiVersion: ENC[AES256_GCM,data:FO4=,iv:kKIuJp4uGeX6vECsXk7w43F9S281jObQchmK8r1+kmA=,tag:x5LYd34dzNDLCma/78M4+w==,type:str]
kind: ENC[AES256_GCM,data:o4RTN8SK,iv:uyOJWjDmHIPuBl9Wc1S/HxUabV2yytWr1bntHW68sTI=,tag:3On0SnQVJ/ICMMryQe+8JQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:a89dHmLdSeMHE7ZP0Q==,iv:OWJvJhFqJ7A0UGVJnZILzCJ/4VuVFib9sUDjXuwyqJQ=,tag:1XrIQn1GDh8RQ4vmQAu7+g==,type:str]
namespace: ENC[AES256_GCM,data:4wBVpZJgQGM9,iv:3Tn1u/2TP/+SnELu34PDf4Jauf2L4f8+MtgrPIlyKzk=,tag:L3qbKFBjm16g8KX5vUsK6A==,type:str]
type: ENC[AES256_GCM,data:mdrF9vUb,iv:7ukOgwXlnRf5mpJ/+6YXtyGRonnqehEgT4pJJIafw7c=,tag:90mt7jpNb3N80kCEi+R/rA==,type:str]
stringData:
db-encryption-key: ENC[AES256_GCM,data:NWJryAEd/eUJxbNiu05RzLF/l7GDxIQM7RQe3xKBz6XklgRAFwQxxjakcVd85pePUBaQYz3zttFglWhfJlWhkA==,iv:eUWbcs0B/mIheyAQ2+DyKgcA98CRKQGAVC3O0WIkfU8=,tag:be06X6AQqlohYlxuYoZiTg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1Ym5yQ1pmWHZjVnYxbUVO
SkZ5dnI2OXR3cmZjZkM0K1ErMElLNktlK1JFCmpleFBrcXkwcDA4cGFuWWN6VDQv
dVV4NjB4MjEreENVQnZ2ejVLblRYOVUKLS0tIG9VbXJvUmQ3SzMzN3IrRDlac2sr
eGZNYXBoZFZDeisrVCtUMXVPaEpoNFkKjwhUU+Zsqv4Be+m558fET5UhF/2rO73J
AhGbJVaA7PaOCswPWiOLso26zD+MBB3VKHNdUFnenpoM16Nu4pOf5w==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T21:07:30Z"
mac: ENC[AES256_GCM,data:KrGQY/eOmihYCvQCbfcM6bZ6rfx4zKh62M8NM0VQr1//1dSHpOOh25dYGLuQeECnqLxSpGQNWzr2xjUYWs4NOdSEFGq6FL2iHE3LnqoBZspp/JFJYP6+z/8jSUojwKEW7OuZZdPN6gF2u+IPOeWbGftnjS7X+atoSkYag2ty2vk=,iv:IOPEgBizsxrVoK5oSdWmi0uCBUnReOEnMYXuwSBKTzA=,tag:9k/XQ/AE7wSFq5XMbY0FTA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,23 @@
apiVersion: ENC[AES256_GCM,data:4OY=,iv:0Lgui0+X2oTlgCX5HEIIihEsGJD0C2+pDIOTCNi4r+g=,tag:uL7vlreaHjcLJjV7e5I7PQ==,type:str]
kind: ENC[AES256_GCM,data:x/ltkind,iv:KRBfS7COsTs/l3Fhyk5FvH7IVc+r/6M6FVOo+kPDe1Q=,tag:UuTbe213hDfK/WUtKS/doQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:LpOEecTJBuGy5PqNSFM=,iv:Q+DCLGtw3wihhdAgEbzYHyQhFTPWFyE2iJKl9mYaA2E=,tag:E2wrig4lRXGDvSCHec5clQ==,type:str]
namespace: ENC[AES256_GCM,data:6kMVCXaF68NO,iv:gnCCkV7GKXA2HWOOeNUGnpAycf8U7pl6F8Vfo6DhLR4=,tag:0VdeySsuhUr2heXyGXZwBg==,type:str]
type: ENC[AES256_GCM,data:6muC3Zqh,iv:gBaC+6MXvZY/bIpy/cEG/zWwLBaUFMVfxAHa1OnwfnQ=,tag:v6NYyhAKAwovluHAXNaOVA==,type:str]
stringData:
SECRET_ENCRYPTION_KEY: ENC[AES256_GCM,data:jX9ULlaBYTSTFGRMplSder5rMrsz7THU3kgJSGef0r5rZvtoaAG9NVYU2QUEuD0sZgfqRooeMOkDCAqFc8qsuw==,iv:uOmx8w3XPKnoxfasf7gqi/bljxigpLwaAC30c2TroKg=,tag:Dmbu6m3+UZa1w0UR0LRh9g==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6TDVyNGRtYWgzMDYwdjdr
NXJWcGJZNUx6YzBGWm1nWlp0SlN3WXhaR0VBCjZ3bDBmUTFCUjg3U21wak90eHZK
NlRCTUZQSHhoS0ZSUGZ4UHhST01KNmMKLS0tIGUxcGo0NmJ2bmJ6eGJ3QWxzYVM5
c2Z6UkFjQmZ4ejIvK1JQQkZ0VFFIZ3cKA8UzmGsX+s3yOermOHqbnnEtekHCTqmC
R3Jyzf6OolZMJpNwbetcsOfgO61aM7LHl/Xb/hFLbLjZ+eECrt8VpA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:HTrEEVDm8JqnVBFaY/FJknxma0nI95tRiwVc0K76k+2R4jgKCBA3EUAzPVS+gA4rTAeKHPSHrT2eidlkseT2WQUjqMRrpHcUsbSjNfpTEY2g/U0PIiOXZMRGfwmsQVgmu2fqeMOyzDSyya0scdWJXPraRPNZ+OpEkNVlm4rWcgs=,iv:KY3dnnmooJgsu66wc7gbzeIGUXZNgVrxAhpAwxoG+G8=,tag:7r6d41oTD136N57rtY7auA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -1,28 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: igotify-client-token
namespace: notifications
type: Opaque
data:
client-token: ENC[AES256_GCM,data:W+InmDyZkU0i+OqQUsIaRNY/3p6zjMC19ASj/qYHTldhtGmHwj4koQX3tIxdCQV72xZKOxsc8eV3hGX3pqbW02c2/ng=,iv:wWI5AZdpxnzX61f8iTiWmxKcDFMaFM4PHFYo4DMBK4Q=,tag:CpE8Kg5V8fL7P3oAjWjSvQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwaUlFU0xKaEs0SWcxWEEr
T0tUSmErR08zVkhuLy9VMEc3VXdmU1o3cWdrCjhuOW1yTmZPdWxDZHhMeUZtQlcz
UmVzaEMyVHZOZS9KVHdMd2pndUJwUW8KLS0tIGxYeENwVEFDdUpDTGszdGtNRHBQ
Y041WW9HdTJwUlJCUGhMN2RGZWZpUmMKiQbi/O9X3cFJgqw3tvwWcyy8QKkUuq/7
Y+V51R/WFobEQuAZLmDKgWcPSGt51effufB/Vm9LRRqtbkbYQAIoXQ==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-10-27T19:14:20Z"
mac: ENC[AES256_GCM,data:Hxl7AGzMOMK0pwe6T9ZmrE6K45XhQkq4qk/7YkrVuneL1e7jc8Bmv4NlywqRkt9fPVOCMwKikeX9U3ZcsqK13xPPfm1CmAxV3CCluwK0/9589N70OQxL4qJSkO6CXyGaa0Y9wVRX4y/N3wSjW+d25+GOYUXrOxkRpVyU4GFSqAQ=,iv:0Af/MCZBEYJVI3yi7DpPX1eBKwSANXJPCpSAEE3zCq4=,tag:SS1U9TfRQR3z8KkoQ6ZuZA==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.3
@@ -1,26 +0,0 @@
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: memory-agent-oidc
namespace: poimen
data:
CLIENT_ID: ENC[AES256_GCM,data:c/RvtV6anofX8h1BmlKu9w==,iv:3lQ1BR8kVvF1RHVfKstmb0Y7u/uX5OrvmGiCi/xTpjw=,tag:9JC2WkfQFWaiZroAEmPenw==,type:str]
CLIENT_SECRET: ENC[AES256_GCM,data:I258/6O43PccyllYN/wfoKvTgNgZONTivM/KFIr8TGqSl+2XQkwBXvK2VQc5Xgc5cpwlrgnjk8pjQbqr,iv:MZyA+V5z9Po2VoxqvOSlWh1fZp1lrkcbYMx7VcSIaJs=,tag:gXH0BpA8KOZiVd+4RnhRzg==,type:str]
ISSUER: ENC[AES256_GCM,data:GX/uQPDoeJWXXQZlzD6oOIvxo7H4LRAv4MrPAiCgmv6WxW59+KKkymuDT/T9MiDm4bNhxVPgB7nHbDcMHfPvU2zCNM2TWYUsNOgGwxNAqM8=,iv:5OEsdrUZvMLO2C8Ea2i5alsyFGtmEk3othTemk8fOk4=,tag:8CMlRY2JCe4z4fv4WuWjRQ==,type:str]
TOKEN_URL: ENC[AES256_GCM,data:KeU1pQzvQ2/1lQhOqNtcR7t5TGVx1J7NY8ogxCXS6IuHhVe0zWYIbC4BYFc2H9PAjsMJwCCg75N1FrIiCYBvpfK8ln0=,iv:NbPiJC/B7W1Zm+JwnEGDikE2cb3L4l6TBeQTKEb2boM=,tag:FRmllFLZzQW03lsCu9mAUA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmK0dPSmhKT01FSitDdUNW
Um1XQU9FVnBUeDdoWXpCcXRzVXh0VXkvNGxNClZaQ3R5U2NkYTJjaFdHRWhUVmFS
K0tHZVRFRDZGREVRZzZqcWJ6dnRpUUEKLS0tIE15UUNtMStKbWwzb3dEVGEwN1VQ
cktlVTM2SUhpOGJZMXJOOG84RnhCQzAKOdM0pThEaN37SJtRrKIivncMu8Z91Btq
es6NOlJdBUhzIhnEJWfDzZ9aA6I/fYzugKbC+AfUwdGgThAgHx9crg==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-06T02:01:24Z"
mac: ENC[AES256_GCM,data:/BTQ984D+hsk0LmnjD3hxopB65NAR54LhoAPNcATWA7MCTJXfdnYRu3gNxUzyiyCBd+rDaPp77TBnak4jVjMnOiYJsYLj1R7hrrhTZopz8pT+YNMupperwsirKZwK8it4Mu+fnpCyox2mtGED0hk+Nh/b6IteTRt5lS7noumo2M=,iv:RWIrVkNYj+8mqMSPiLueToexabzAtVbIR525m6bUrlg=,tag:5+wcNoNYsjGKpFlML+N70Q==,type:str]
version: 3.13.2
+43 -43
View File
@@ -1,72 +1,72 @@
apiVersion: v1
kind: Secret
apiVersion: ENC[AES256_GCM,data:Mdg=,iv:u/JjNJfdyz7ehk8lHZRh1u9zkNuufHosyXv10Gm+T6E=,tag:pkgZVJid6kpGuy61+oEMlQ==,type:str]
kind: ENC[AES256_GCM,data:/kWhhqyz,iv:DymezGpBHmKT3BM/CcUKEGIWZw/JXNutReSKfhfmoD4=,tag:DnA+316Z+0eqBWN8YLyv1A==,type:str]
metadata:
name: minio-creds
namespace: storage
type: Opaque
name: ENC[AES256_GCM,data:xs1k//nWy1iJ52Q=,iv:NDGvM319FCypxFLWwONcF12osaU/EL0IwD0c2xtqYnU=,tag:6IhfISF08WQbxrUmT49d7g==,type:str]
namespace: ENC[AES256_GCM,data:jpUzI7BmZA==,iv:PQrfm67la6RJCRq0H7qpiY8n2hPUjcaK6gmJ/zPQW30=,tag:bp/3GYbRpYOIub5nT0zz/A==,type:str]
type: ENC[AES256_GCM,data:fndXB4Y1,iv:EXOe0day1ScxvZ7ozn1QyHgaZgnvlPNmvSQG74aw7lM=,tag:quPmKvFTjRqtZoVUEoQAHQ==,type:str]
stringData:
config.env: ENC[AES256_GCM,data:1yogGyBdwrlg27H8R5Kr9VcPu+xwS5/nPhi7eLcmVYtXmPlNokCsWHA70qnn/+I6i4ODys8GML++VeZ/lmcHiP7RIbY+G6xmw6nfBCXitgE8kQwTPsUUzn9bobOn4Sr1YMBf7FQ/Q0N8aupGJA==,iv:EfCdim4TLkP/FP1u1yoKZCq8YGFzVh7ca4yu1+WzXwE=,tag:rTYe0SIAEsUhh0KHQIEKNw==,type:str]
config.env: ENC[AES256_GCM,data:3RIQ1i6NtY1Tv2eJY8JXSgfBEqi9px/9TJVNrPf9+brtGy9Mx3SwjKVnjGXUcA2cYcNZb1JR7temXmznXQdsRfZ9yohe4oc1vL3yPGPY9YfP7AHGYj9E7tghu/rgkHOf93RmfdKv0Rrq6miBcA==,iv:0RQVMi2mMHBRTJ1OX6IDFvq9Qr7zpqgQzt+MPPgBIHA=,tag:HNXLKf5X4P3E+hc7YbDtNQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2SStVR0lpOWs0VnN6dkdp
RktMUmNRR2diTUtiVk9tRk1wSjVTMnRtVlJBCkk1S0dwNUNHb25RQlZ4ZFRocisv
aS9kbmQydWVmN1AxU3VvbFZsS2tmTHcKLS0tICtRY0ZKeFhCTUdaNW5PSFRNZ2dG
OERIZ0xMbHBvSVNyaGxCVFpqRzlxa1EKbrjfk/CFlTSyJYgTzQ67W3JyR1fwv8VF
dxGeAgLY4EQrf+2PbODtazRFreS+sl7dxq+rKMOVZTsjGHakkqHy9A==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtUVJFQ1ErNmkzd1N6b29t
L0VWR2tlcEIxeG1CR1hXRkw2cEt0Q2crMWlVClg2UlNhUzdJZGpLZjJtYlAvbk16
eHk0ZHZiNzVRTWhyQzlieTU5Y3QraGsKLS0tIHA5UVhvUXhNSDk0QlV0R2RzR1dP
azVEQzgvSFNwak9TcW1USFNOb3RvSkUKNLuW3h3cHDSSCZGe9Vyiv5m+wXihtCYX
oJtbRK+kJF8oOUo4emI+SLGrCsWq5cG2wN/wgP+ChR1zUgRgfq5oOA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-26T04:03:16Z"
mac: ENC[AES256_GCM,data:gxu4WcJEuFgjtWCfAjose8R8a566/OLrXoiJmKfcvVfTFDTdGM7I4gzQVvpjEfra5Hjyt7kvLlaV2K5bKJ0k6NXbbmgrqAqEOEcPg+cnPy1FtQGAYOo3VzgKqrpLOCs/7wh4YR23XHHsjQlHAcP8BmhrqnpHXR9FMZObrKjnQj8=,iv:KJZfw0n2DOtdZHeVDk6TrpO8q57yD+xdEVt9+hM0D4Q=,tag:rHQaMjvQwaB8Am/AlTKq1g==,type:str]
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:hQfimKuhfiIiZQAyNDZQR25HZ5biU+kaWig3ElVPiqMY3B0LrmKyOEs7ittd3j3meUkPEFSmm1yYSXcdCuf6w7cwSbEiL5DG8t7QKPbnx/aRdyHAJW+1VC0e3Lye43gaHya0iUaWS/YFQcrT01oEbckLIDWPKfvA6pg2bZ4kMZ4=,iv:AydyWMVcjTQu+7Fzw5vtqkvTTeQEMt8y6snESoLUDE8=,tag:SoKklNqa9nH9G6d9+PnolQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
---
apiVersion: v1
kind: Secret
apiVersion: ENC[AES256_GCM,data:Mdg=,iv:u/JjNJfdyz7ehk8lHZRh1u9zkNuufHosyXv10Gm+T6E=,tag:pkgZVJid6kpGuy61+oEMlQ==,type:str]
kind: ENC[AES256_GCM,data:/kWhhqyz,iv:DymezGpBHmKT3BM/CcUKEGIWZw/JXNutReSKfhfmoD4=,tag:DnA+316Z+0eqBWN8YLyv1A==,type:str]
metadata:
name: minio-oidc
namespace: storage
type: Opaque
name: ENC[AES256_GCM,data:XEQHsQqTgwukYw==,iv:wTw2eoooDA9EfB2df3jzb0IWUdtLSwcuKzx8yDBaihQ=,tag:oncXt2gM1k6Qppafpu/D1w==,type:str]
namespace: ENC[AES256_GCM,data:jpUzI7BmZA==,iv:PQrfm67la6RJCRq0H7qpiY8n2hPUjcaK6gmJ/zPQW30=,tag:bp/3GYbRpYOIub5nT0zz/A==,type:str]
type: ENC[AES256_GCM,data:fndXB4Y1,iv:EXOe0day1ScxvZ7ozn1QyHgaZgnvlPNmvSQG74aw7lM=,tag:quPmKvFTjRqtZoVUEoQAHQ==,type:str]
stringData:
MINIO_IDENTITY_OPENID_CLIENT_SECRET: ENC[AES256_GCM,data:eFfwW2ZnV+o3/6XHjUvyFsDbaIAdosY2PXOnRdN4Ie49YDIjfJPe+Mg1FsbgoEHEjuB33hfxesb4TWOJQfW8Og==,iv:+kRr9A3auts1XHgZWa+t5b2m4lF0x9dVhiRpB17fzv4=,tag:tH/jszFzLQdqORGr9vfTGw==,type:str]
MINIO_IDENTITY_OPENID_CLIENT_SECRET: ENC[AES256_GCM,data:IYBfNW19cTo8WkCGT4zuCljzi2bkbYWJ8DY/yedR2MJNqEO+bMVvjx0OqSqbPp6rnwGyMQOhBN0gXPbEVRDBGg==,iv:I1aGIikbHTKvh//4Inhp7UoNfhjlIXSpcyP4RqljvqM=,tag:Zh3Vh5DQYAsTtiW59D0/QA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2SStVR0lpOWs0VnN6dkdp
RktMUmNRR2diTUtiVk9tRk1wSjVTMnRtVlJBCkk1S0dwNUNHb25RQlZ4ZFRocisv
aS9kbmQydWVmN1AxU3VvbFZsS2tmTHcKLS0tICtRY0ZKeFhCTUdaNW5PSFRNZ2dG
OERIZ0xMbHBvSVNyaGxCVFpqRzlxa1EKbrjfk/CFlTSyJYgTzQ67W3JyR1fwv8VF
dxGeAgLY4EQrf+2PbODtazRFreS+sl7dxq+rKMOVZTsjGHakkqHy9A==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtUVJFQ1ErNmkzd1N6b29t
L0VWR2tlcEIxeG1CR1hXRkw2cEt0Q2crMWlVClg2UlNhUzdJZGpLZjJtYlAvbk16
eHk0ZHZiNzVRTWhyQzlieTU5Y3QraGsKLS0tIHA5UVhvUXhNSDk0QlV0R2RzR1dP
azVEQzgvSFNwak9TcW1USFNOb3RvSkUKNLuW3h3cHDSSCZGe9Vyiv5m+wXihtCYX
oJtbRK+kJF8oOUo4emI+SLGrCsWq5cG2wN/wgP+ChR1zUgRgfq5oOA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-26T04:03:16Z"
mac: ENC[AES256_GCM,data:gxu4WcJEuFgjtWCfAjose8R8a566/OLrXoiJmKfcvVfTFDTdGM7I4gzQVvpjEfra5Hjyt7kvLlaV2K5bKJ0k6NXbbmgrqAqEOEcPg+cnPy1FtQGAYOo3VzgKqrpLOCs/7wh4YR23XHHsjQlHAcP8BmhrqnpHXR9FMZObrKjnQj8=,iv:KJZfw0n2DOtdZHeVDk6TrpO8q57yD+xdEVt9+hM0D4Q=,tag:rHQaMjvQwaB8Am/AlTKq1g==,type:str]
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:hQfimKuhfiIiZQAyNDZQR25HZ5biU+kaWig3ElVPiqMY3B0LrmKyOEs7ittd3j3meUkPEFSmm1yYSXcdCuf6w7cwSbEiL5DG8t7QKPbnx/aRdyHAJW+1VC0e3Lye43gaHya0iUaWS/YFQcrT01oEbckLIDWPKfvA6pg2bZ4kMZ4=,iv:AydyWMVcjTQu+7Fzw5vtqkvTTeQEMt8y6snESoLUDE8=,tag:SoKklNqa9nH9G6d9+PnolQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
---
apiVersion: v1
kind: Secret
apiVersion: ENC[AES256_GCM,data:Mdg=,iv:u/JjNJfdyz7ehk8lHZRh1u9zkNuufHosyXv10Gm+T6E=,tag:pkgZVJid6kpGuy61+oEMlQ==,type:str]
kind: ENC[AES256_GCM,data:/kWhhqyz,iv:DymezGpBHmKT3BM/CcUKEGIWZw/JXNutReSKfhfmoD4=,tag:DnA+316Z+0eqBWN8YLyv1A==,type:str]
metadata:
name: minio-user-ollama
namespace: storage
type: Opaque
name: ENC[AES256_GCM,data:rYX7nN6SYoG5HycaNNrprDM=,iv:4uX7+Sl1UzgsImBH+qm9p8DlOfJu0i0W3bpoE7cFxXg=,tag:RROCnYBVKVwPBNSGgV30FQ==,type:str]
namespace: ENC[AES256_GCM,data:jpUzI7BmZA==,iv:PQrfm67la6RJCRq0H7qpiY8n2hPUjcaK6gmJ/zPQW30=,tag:bp/3GYbRpYOIub5nT0zz/A==,type:str]
type: ENC[AES256_GCM,data:fndXB4Y1,iv:EXOe0day1ScxvZ7ozn1QyHgaZgnvlPNmvSQG74aw7lM=,tag:quPmKvFTjRqtZoVUEoQAHQ==,type:str]
stringData:
CONSOLE_ACCESS_KEY: ENC[AES256_GCM,data:Zxr/Nj0SUKSJbBD1cFjt3GE=,iv:r/B9ULn68hsXdH8uRQkKAoQTJIJIYKMoJuWOZfxS2I8=,tag:Xo4i+IVxuqrp+qDpYQysPw==,type:str]
CONSOLE_SECRET_KEY: ENC[AES256_GCM,data:PoIDNZ+Xs6BdhGm10y1HdL5jy47EVHfHHIJhGy/KX6Q=,iv:voAv32GoTKi9s9/4MQy2yT6i9VY4/igA5XXzehwUqRQ=,tag:DmPyk5biVvlxHjeyDyrLbQ==,type:str]
CONSOLE_ACCESS_KEY: ENC[AES256_GCM,data:FhXl6yqTIzHsbJO6P96KQrA=,iv:gGTaSKxk721GwMQWWyymqUPZlGLidrBzWCWdRtnlOr0=,tag:IaVl8GQqPstBuUjD4N6JTQ==,type:str]
CONSOLE_SECRET_KEY: ENC[AES256_GCM,data:s22GOXY4B/m+S7tgDw8P2gU2F15vdn1tqAp53FWzFTI=,iv:GXBhHQ8YiaVjRRMw2yG4FxdulVOuOurkLXXC0u/SFRA=,tag:pvHcD9+4qaxnvhOSkNxoHA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2SStVR0lpOWs0VnN6dkdp
RktMUmNRR2diTUtiVk9tRk1wSjVTMnRtVlJBCkk1S0dwNUNHb25RQlZ4ZFRocisv
aS9kbmQydWVmN1AxU3VvbFZsS2tmTHcKLS0tICtRY0ZKeFhCTUdaNW5PSFRNZ2dG
OERIZ0xMbHBvSVNyaGxCVFpqRzlxa1EKbrjfk/CFlTSyJYgTzQ67W3JyR1fwv8VF
dxGeAgLY4EQrf+2PbODtazRFreS+sl7dxq+rKMOVZTsjGHakkqHy9A==
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtUVJFQ1ErNmkzd1N6b29t
L0VWR2tlcEIxeG1CR1hXRkw2cEt0Q2crMWlVClg2UlNhUzdJZGpLZjJtYlAvbk16
eHk0ZHZiNzVRTWhyQzlieTU5Y3QraGsKLS0tIHA5UVhvUXhNSDk0QlV0R2RzR1dP
azVEQzgvSFNwak9TcW1USFNOb3RvSkUKNLuW3h3cHDSSCZGe9Vyiv5m+wXihtCYX
oJtbRK+kJF8oOUo4emI+SLGrCsWq5cG2wN/wgP+ChR1zUgRgfq5oOA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-26T04:03:16Z"
mac: ENC[AES256_GCM,data:gxu4WcJEuFgjtWCfAjose8R8a566/OLrXoiJmKfcvVfTFDTdGM7I4gzQVvpjEfra5Hjyt7kvLlaV2K5bKJ0k6NXbbmgrqAqEOEcPg+cnPy1FtQGAYOo3VzgKqrpLOCs/7wh4YR23XHHsjQlHAcP8BmhrqnpHXR9FMZObrKjnQj8=,iv:KJZfw0n2DOtdZHeVDk6TrpO8q57yD+xdEVt9+hM0D4Q=,tag:rHQaMjvQwaB8Am/AlTKq1g==,type:str]
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:hQfimKuhfiIiZQAyNDZQR25HZ5biU+kaWig3ElVPiqMY3B0LrmKyOEs7ittd3j3meUkPEFSmm1yYSXcdCuf6w7cwSbEiL5DG8t7QKPbnx/aRdyHAJW+1VC0e3Lye43gaHya0iUaWS/YFQcrT01oEbckLIDWPKfvA6pg2bZ4kMZ4=,iv:AydyWMVcjTQu+7Fzw5vtqkvTTeQEMt8y6snESoLUDE8=,tag:SoKklNqa9nH9G6d9+PnolQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -1,27 +0,0 @@
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: oauth2-credentials
namespace: iam
data:
api-gw-client-secret: ENC[AES256_GCM,data:YiUCk61cCsTJE8gTLZWhqVPsCSu3b4OnG+Oh456A+mnxip0FjLTTqV+kYBdfZivIkn9ua4RcVW+rYILu,iv:K08m1EaB7/SIVNWZ3FfeRP4tbCAo6KeiNC0NpE9VO1o=,tag:ZXV9YRWUOPJ/vdoqMtvQeA==,type:str]
minio-client-secret: ENC[AES256_GCM,data:vcwLo1cJqbZr2MwkAKO09xPjb09ss7+s6HIvEfaLxt8ka5PUAlXQ37pTkynuktlKq/t0DvSk+x1+5G7QqVD1JJlwyDGM6yuOhBQ3MwucLNl+T4RDKtgGuA==,iv:sCdd+UNX2TfTAM6Zslrh8KrvBSzqpe67F3YMGrl4YDU=,tag:9Tpzm9NEXNuXyCd7l7GeHw==,type:str]
poimen-client-secret: ENC[AES256_GCM,data:dYtCiMGZaOFokihfGD2SXJDfXrhfwEQcTOPNu7JNHDUnx0hqwAdYAPAJTEN21p0OpHYDINMs5lwUpMwY,iv:ll8USuzs0881ryHImVjHGO0iFxUzqg5XtI6Ekd+EFXE=,tag:I/4ZfB9Ss4zEP57sRtRTXQ==,type:str]
paperless-client-secret: ENC[AES256_GCM,data:XMj9rHkI20lI37vChq9wS5lUq7l95SisRX0LChuj09O5w76Zt9NdG6OEUYQFf1z8vCfMJxBInIEEIq0e,iv:UpYy2ZEZ4AgBACEyrlxEcr13V+8Ksu4p8JHYApmyYHo=,tag:o9Z+/b6T9POgowIsks56kw==,type:str]
grafana-client-secret: ENC[AES256_GCM,data:V/j0+P3HoDLqYjRalWK4v1hvJTuVmzxVra47HsF1/BZvWZjIyEIH3Pt8BekT3O1dUFynxjcaxdZMBVYg,iv:9pMp+vIsp/sA7GP9wqNDf5DdZVLDow46OURW0mMRXIc=,tag:i7VJFA5o0hToZEJ5JPWIvw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2amZPendIOU9PeStOcmJG
c1dzOFVvd3RoT3dXQlJUNkdCSytEZUVGVEJJCklMUkNjUytBMXh0OVUrNUlpMWw3
aHRFT1hBZTZHNHVtaGJCTEFyT2ZWNWsKLS0tIENxR3FKMEplV0V5Y0dsZzBkODVG
U0ZlT2R0VFdEQmpsakVvZ1FJRkRQYTAKESQcSlMAuCG0/tLWWZaeoPayf1EKxSaf
e4o8TmjmSgPEhepQ/LmNXl6obyuqUxztkh4Mm6pocqDp+gfzt7X/1g==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-06T02:01:24Z"
mac: ENC[AES256_GCM,data:AI/znHiUa/Rwg0YRz65qUZ+b6V73oi3ZfAG/V5uZZvrGFrA0EISzD5LaP5Kl4GUU1svo4G5EI4aftPDgpdPptz21CCa8iYkXGe9pTDmtaE5ZICQAUbRcVhGMTfuWFhrgEhMfCJdOpfHaIQVEsQJQJgrnkXQVuUe9aT75LQz7pnY=,iv:KbIaYT0VeBT2WeSU5V5ZB4e+04JICYaY+egGN1Pv5bM=,tag:pPbhDxeLUx6V1XC/LPwxTg==,type:str]
version: 3.13.2
@@ -6,7 +6,7 @@ metadata:
type: Opaque
stringData:
PAPERLESS_API_TOKEN: ENC[AES256_GCM,data:wlBC85ep6zioWLhfhNczkKfTMR8tmciN+4r3of8GpQoJmKyE2r7pOg==,iv:Rem/UJl5wUNi8G+uEV7f2eCLabaY26sZ7+AsKYiOZ4I=,tag:bGS1fu13Mwsrr+5NUEzzLw==,type:str]
LLM_AUTH_CLIENT_SECRET: ENC[AES256_GCM,data:OEZqGgJ4cpMsWr/PJH50lsQKvk6sjVNPCFcZP3yQ/7nvlKP3giPlLmW9XgWr1ELq2hwWDs61+f3fAyENt4ln+Q==,iv:W0Tii/nvgm9ZMIEtfzgO5pj4Li+6fpxfd3zFtQhj874=,tag:tsM/fGeduMOEfMZsDZ6lrQ==,type:str]
LLM_AUTH_CLIENT_SECRET: ENC[AES256_GCM,data:co7FfmRXKgrz9fNgR10KtlHL/iG4F5WKJgjcKem1H5hF5FviVi4+CQNekA==,iv:ADtBHiI6BtrkuH1iIfKV3I5odNLMQe5y/9GGTJrPNN0=,tag:j6wX6zO5VhKoLzdr7BQZCw==,type:str]
sops:
age:
- enc: |
@@ -19,6 +19,6 @@ sops:
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-26T04:03:16Z"
mac: ENC[AES256_GCM,data:gOrqy2fPX+77303L+E1EypaTU8mdUXYjbRZnTIJtoRpZhEmHsVvS/Ho8bVcl5KiTPN7UoFNmGyDz8fpyQ/rLTbeCiUYmyxvH7ym9DT4oWvRxITpOqBxz83pWv1BB++HJvqNQ5FllqpnAz60/bAVyxOdOXk4J3lDnuJibNOpxhW8=,iv:suV68u1Uiejl0NNJ3WlAHshgdrSL+aTwUxUBeMXlfWs=,tag:5wug4NeDZ+qclvFGPiEviQ==,type:str]
lastmodified: "2026-09-14T14:19:39Z"
mac: ENC[AES256_GCM,data:jDdmiFg6uB1za7sAjO1CnGHdRPyUnmWCMJxqHrE3BO82pNbtm8yLOf+W6CfFKAjfZYqEE2mdKhABm7zeJgJlub3eXJbAirAFrp5jrbfHw0qsFo85Gj/BsrSDB401Aky6PxvRomz66FyX5zDDXIGAMEMlyNEadMplTfrZQQ9hQWM=,iv:UTSF72ofUyWs1LXGKQwBP60hK64UxhqTFM8QTVozGoo=,tag:OKivMDAqwWgrX+kNjvQrPg==,type:str]
version: 3.13.2
@@ -1,24 +0,0 @@
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: pi-code-agent-api-keys
namespace: poimen-sandbox
stringData:
ANTHROPIC_API_KEY: ENC[AES256_GCM,data:TBcno/dzj3qykHjeX96UKZekuA/WMQT2E7a1ziPyYo5J6lWLPydgli84KeJZhH62/8CnnFtFOhVIe4FWCGPGjvqKH6M8WbiQHaP9y7FN0qQUpFHKNVAQ2R+s0wInhw0eUmANvd7bRx8aIZ2X,iv:oex19yVtWUevoJ0A3mkMLCfWu85o1gPSNp43x1iV0LE=,tag:BW3zQnwvagPceeNird9CZg==,type:str]
DEEPSEEK_API_KEY: ENC[AES256_GCM,data:vq/5gOsMVjlVMd0oXiBtHFHFEr+IF2YR2iP2OBFWx3ZFNoQ=,iv:QhLU/BRIJb/7QKDCRyw+HWPphZifVEhIOLywYDgOb20=,tag:BZdtXw+WsuK+P4E0caTa0Q==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqNm1LNE5nWktnd3c3STg3
UlVWbWJxWElxUHMwa0JVMk81Z0c4d0JaQm1vCnpLQWJWV3hxOU00NzJIaG51Mmlr
ZWpNTFJWcmdtV0w4YndQeHpvSUx2MTAKLS0tIDVnNkNuVWhPUFZ5V2hFOEt1eitG
blVqRy8yNnYzSU1FNzZGNkxrRElvWVEKL3J77INje75+CvKXaJHC3CvONgHv7d0a
a10MITvfreFr6M4AN3Z/mno49SDMp15Q4oQRagHHxSTS1K54mWHo8g==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-06T01:19:49Z"
mac: ENC[AES256_GCM,data:GpYl8IRefms36GvO3RW41c0US7SOgoOhd4rumxsCdEf5ME9kJ0eOxPx8klMgnlYrkBnwHmad8jj7C+HcA5Eim7mdUaYOZZWPFohvC6N4GLHX85aH78P09CelqG5tdm/Gm+1QfxYu0vANYGCAVjLK7YbBbZjbZmbvVfQvAbs3mOY=,iv:y3lglGu8FhTHQpO1poSIKgGYJFu5J9yjIaOjulda6fY=,tag:5Sgh4TbtNFMw7xOJjYRn0g==,type:str]
version: 3.13.2
+26 -37
View File
@@ -7,40 +7,29 @@ metadata:
exec:
path: ksops
files:
- agent-pod-models.enc.yaml
- agent-pod-ssh-key.enc.yaml
- cloudflare-secrets.enc.yaml
- cloudflared-credentials.enc.yaml
- forgejo-registry-pat-api.enc.yaml
- forgejo-registry-pull.enc.yaml
- forgejo-runner-token.enc.yaml
- forgejo-secrets.enc.yaml
- comfyui-oauth2-proxy.enc.yaml
- grafana-oidc-secrets.enc.yaml
- grafana-secrets.enc.yaml
- homelab-ca-secrets.enc.yaml
- loki-secrets.enc.yaml
- minio-secrets.enc.yaml
- paperless-secrets.enc.yaml
- vault-secrets.enc.yaml
- vault-unseal-keys.enc.yaml
- portfolio-secrets.enc.yaml
- gotify-admin-secrets.enc.yaml
- gotify-tokens-secrets.enc.yaml
- gotify-smtp-secrets.enc.yaml
- forgejo-smtp-secrets.enc.yaml
- paperless-ai-secrets.enc.yaml
- memory-agent-oidc.enc.yaml
- oauth2-credentials.enc.yaml
- forgejo-registry-token.enc.yaml
- pi-sandbox-secrets.enc.yaml
- etcd-defrag-creds.enc.yaml
- zitadel-masterkey.enc.yaml
- smtp-gotify-secrets.enc.yaml
- igotify-client-token.enc.yaml
- grafana-gotify-bridge.enc.yaml
# Login UI RSA keypair, adopted from the chart-generated Secret so ArgoCD
# owns it. Without this, the chart re-generates the keypair on every render,
# which changes checksum/secret-login-service-key and leaves both Zitadel
# Deployments permanently OutOfSync.
- zitadel-login-service-key.enc.yaml
- agent-pod-models.enc.yaml
- agent-pod-ssh-key.enc.yaml
- authentik-secrets.enc.yaml
- cloudflare-secrets.enc.yaml
- forgejo-registry-pat.enc.yaml
- forgejo-registry-pat-api.enc.yaml
- forgejo-registry-pull.enc.yaml
- forgejo-runner-token.enc.yaml
- forgejo-secrets.enc.yaml
- grafana-oidc-secrets.enc.yaml
- grafana-secrets.enc.yaml
- homarr-auth-oidc.enc.yaml
- homarr-db-encryption.enc.yaml
- homarr-secrets.enc.yaml
- homelab-ca-secrets.enc.yaml
- loki-secrets.enc.yaml
- minio-secrets.enc.yaml
- paperless-secrets.enc.yaml
- vault-secrets.enc.yaml
- vault-unseal-keys.enc.yaml
- portfolio-secrets.enc.yaml
- gotify-admin-secrets.enc.yaml
- gotify-tokens-secrets.enc.yaml
- gotify-smtp-secrets.enc.yaml
- forgejo-smtp-secrets.enc.yaml
- paperless-ai-secrets.enc.yaml
@@ -1,23 +0,0 @@
apiVersion: v1
data:
token: ENC[AES256_GCM,data:CUJ0rUtgye71fpFz50RSDXJeG+t8Wjrm6T6jboVPbt2FVuIRSscqKg/Pc3k/L8nvLvrkYlzz1rbYu9v5NAdNF2c35AQ=,iv:tsfQ+v4qwj6H7G/O79b6m4XMWRw3mwE67f+4LjDCOoY=,tag:SLGGBYtYoF45AQstETPYZA==,type:str]
kind: Secret
metadata:
creationTimestamp: null
name: smtp-gotify
namespace: iam
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBFWmU5TUxLeGU4YzUxRU9G
ZFR5a2hqS3huYlJtempaQ25zUkljUDJLSENrCkx0bjQ4UVF3UWpWa0t3WHQ2bkNl
eEp2d0dQRHJSOExIYjN4TmRTZWdQY00KLS0tIGF5enUxTmxRTHlQRUt1aTFIYTFB
MEJaOWd6SnhWQU1PQXdUZDJqWmp2MGsKIOOrp52+d3M8AQlMw0VSnLQ2efDtC6Ye
oC/TPqYi9SqYNxaHkODS2QZN9msyfRFcgUh/6ZXr8sivnpwRY8ZZtw==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-27T06:24:20Z"
mac: ENC[AES256_GCM,data:9Jsnij+I1MhLhIPLt2hr1a6Jryf8XcAb4QAknGkX1hNEN+Z9HT5PSnPARqyhl0WTxe8813a/G0h93SJ0A419jIzZugv6zEj4STsOG4hMI8O2NniqOwiDWl+Az9SRXIQ0ahH9En7ssvEdiosIDGJSz31ufNc8FbwKnHvgWCwfK9Q=,iv:VNTOpQkDakoaCu2dG4lRbd+6AzimNZVNzggq/7C2eM8=,tag:cJI9WdnKiG3K3J4LRDqQxQ==,type:str]
version: 3.13.2
@@ -1,23 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: talos-repair-config
namespace: cicd
type: Opaque
stringData:
config: ENC[AES256_GCM,data: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,iv:a4NHhErZolhOuzNCUU51GQiCfCT8GzZ3GQ24s7fkuyU=,tag:w+GrXmaYWADTrBxMko3xtg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjQmk5aE1GN2dpVlREZU1s
N3V5ZUQweWxMaVo3Y1VjdzZTYW96eFBYZGxvCnNJcjVXKzI4VnliTWFXQy9iSy9k
eEZmWlVPQzhZK09NZGowZUVXc1BoZTQKLS0tIHIrMEpTM0lMU0w0eG02aUxJY2VZ
aUQ0cmkvK0UwdzhqcDJwM1RDcnpEU0UKQRiTa9/yNrqlkIdlNeaIDzIKa/xvx5nW
7wTNfjq2M3go0DMnaVjJA3e15JZBOpOs0QP9z9ydKF/FS4PeCIEFyQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-07T04:27:33Z"
mac: ENC[AES256_GCM,data:CxZdlGJdIFFTwaeeS9LW2XDDgvUmSsBeBKtFYyDM1JliS8BHtgEv48yuCWxP5sR3SaYtcgNSIQ55zUa89jM6dQHqvjK+1jSYu1xzFWPOlxUu8gVSlU0QLS4GU1RChXuPpTUOjxzn4ymZukGgclZk9iuMvI0/U+nGD8nvs8k0zpM=,iv:MGZ7YsOsjelJPjLYWtALOrM6r8Vd1HgbU/PKL4c3r3k=,tag:dIE38TCobESY3j56NVEvIw==,type:str]
version: 3.13.2
@@ -1,24 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: zitadel-login-service-key
namespace: iam
type: kubernetes.io/tls
data:
tls.crt: ENC[AES256_GCM,data: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,iv:PLsjmrTwjM7IUS7ZBSyVvJfAJzCSUtU+UAvHbSwoaVk=,tag:pUQwbHoZgfHW4CpPKoULAw==,type:str]
tls.key: ENC[AES256_GCM,data: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,iv:PbRgjgm5ERWlAkkWc7As8eIx8e0rjTUkQ7XlDBrEUlU=,tag:yh2/JuHu5I+dWlxVAmkkXA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSByOGNaakZoV3lhemhMSVVF
RklKbEsxeVFMWWdwQ3ZqR0lhRHlPM1dWT3h3Cnk2RnZUNnplVEwvNW9qMExsSUlt
VVVXejMrcDRRTEdtT1krdThqWThLWWcKLS0tIEVZZEVBWFZyVi84S1puRHdtMWR1
cDBHc1J5aHFpQkxSSEx2VDRjZ1RsdXcKG5MgnUBpFmlNGSkxBijXp7m492ujyqsC
TbTfU8SWJwXhio7V+zDbXG+JW4R51wr9F9Sj47DCw7b64nbsD3N5TQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-26T02:15:51Z"
mac: ENC[AES256_GCM,data:+2eGERwwzeK5JE9H6qhsu+tskR3Y9c7DecVGopfG8Ggq5SHQBfYyvz//IU+MGCOb6nIyP3JPf9+9IDS2DthVcw+RLtviQkN1TeWuUTDbxBxHdCoUv9zepwU7+d0qUMrdUK0N9QQSRZ4TpiwceViDeMuhPF/vTmADP9QWwid/qwk=,iv:nuJo37Dh8MAf+qI/Ty2jsW0IheNhgmZ4l0Va8pJG6+4=,tag:vCpifXvYF1/QU7XExc6raQ==,type:str]
version: 3.13.2
@@ -1,23 +0,0 @@
apiVersion: v1
kind: Secret
metadata:
name: zitadel-masterkey
namespace: iam
type: Opaque
stringData:
masterkey: ENC[AES256_GCM,data:Da543Jt48o6p3f6/AbZRsjChz3NexPJoi/mC/LB6jUI=,iv:irS0n76tZ6QzoQMm0dWrvbw+SPfXz4h13jQb/Bjfgwg=,tag:tfbgaAdxmPIjleB1OXLn0Q==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBOZld2VG9NaFhIVWpBcGpO
SmVwNm9FT2wrQmFKb0RGQmY4TCtITjFEZjBjCmNuNXNJYTdsZE44cDNzR2hiNTdy
QWlVdzBtdjd0cSsxUjQxMzNyblhWTkkKLS0tIHBLT2x1MDBGdjE1eHFOdHVGa1NO
ZGM1K2lDTksraFdPeFdSZ1JyU1VtcjAKgTjjhr7u4YRuQVZQ7kAa1clJ0Rzy10hQ
Umcd4/YStyXHZjsJJpFSpOFdP5cZscGo3xBgpXtxAscKTS1Nw4derg==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-25T01:24:21Z"
mac: ENC[AES256_GCM,data:QFVk7KLsMLCAMH/PHVGCRyfU3k2JiO8AE3jJXmhnlUB4C8MYCTyOWQbnTFXozCMOr0kp+hAt3dYn/e23SWhyvJ7BgVLRb5QF1py704Dr46atVoLUvwHpFI7dyGK/LDTxqiM40RXAnDtWUJlTkVCzKdSFiV8CokIoByKWwxFPayM=,iv:t7HWMcpQl/DJ8Rp51Du7b5gURZO+C+FodsZSJuy5jBc=,tag:qEXSS4S0Y5cvn7T6EjakhA==,type:str]
version: 3.13.2
@@ -8,19 +8,6 @@
# depend on them existing first.
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).
tolerations:
- key: node-role.kubernetes.io/control-plane
@@ -271,40 +271,3 @@ data:
NDKi7/Gxha790QN3TD/U7p9GcEtfXKv8KISKfxnfD9o9Yx6pWOXcgvGefQkpXuRm
kGzDM+aY/VKs5Et5/RyKavBDihuOcumLsmoRg/bFoB/xpeURTyiehUdu
-----END CERTIFICATE-----
---
apiVersion: v1
kind: ConfigMap
metadata:
name: homelab-ca
namespace: cloudflared
data:
ca.crt: |
-----BEGIN CERTIFICATE-----
MIIFBjCCAu6gAwIBAgIJAKoBPdKPDL5AMA0GCSqGSIb3DQEBCwUAMDAxEzARBgNV
BAMMCmhvbWVsYWItY2ExGTAXBgNVBAoMEHJpb3RwaWFvLmhvbWVsYWIwHhcNMjYw
ODEyMjA1NDQzWhcNMzYwODA5MjA1NDQzWjAwMRMwEQYDVQQDDApob21lbGFiLWNh
MRkwFwYDVQQKDBByaW90cGlhby5ob21lbGFiMIICIjANBgkqhkiG9w0BAQEFAAOC
Ag8AMIICCgKCAgEAzw796vZRFOBc/orUu9J6rv91A3SNLJsqz/VuetwAXCaVSlK5
kL/RaBKKkcXToCC5avwix6W21FnwLUW4R8QlQdin8+XEHpx2LY5R4GmSvaCbU7wm
YwYgVGQVv8KlgcMbz/pF09vXU29cHUHwlkN+BWVGAxVGWxam94j1pBzV0dOhhRw2
q5sZYUUrPO5l775od2FMMnMzoRFugxDvJof0p3fAChNAIiFhP95lrcb2YsTwK+a2
6mk3AZc0gMtmgVsH+zryGFcAmWLixq41ye/H23ktj2bjEHCr9mIDpuSOKLyLBzrg
js/HqdxvwkZkkxwXft0Vi7GN0/LGHMdPrDE499pJelAHbxUpBoOk8sfXSNnfESI8
v20LybATGrKXFlOhMKshY1a3JLAoZTaW0xkG4PM4HT7lK/ovr/NLF6kdP7HxJFFQ
+4WjySiwbjusNobpTaQWVwCjD3Imf+2feAqJeX3z+P3EhxfrmvEojLOdUiiPcjMn
CFl9u2EpSlax53umSkcBQJGfFKM17fZzhtakjqhCTzAfXXlxZy4hsHDtfzRcfaoB
Zz/PiJ0TtJSC7TvxZZoPpGexQdoeT7kJEksTvGi5OSfJD7HaKe5RQSZGgpz7ATB+
YMBKbjhMXVu+TAdcO1qZRN0UCncrliN61ki9F0tE8kNgpS9biZ6kheoJMZUCAwEA
AaMjMCEwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcN
AQELBQADggIBAFN1At11d/+Ue6cEjCklnRQpLuD5agXaE+pi5oHNfnggSlO8UjUj
lDzfnmxi3IAI6HruzDARM9J95qJboUxWOAEmK9SNnZNS/1b0GyikP3YcF1gIFqtW
6SgmVfC1cVUFKGt/+QQzZxhOLuoydv9Ozu12nxsTW/WY2t+cpeL+1wrsM22RA6W/
xV/bzYbwzCUT9v4tMZLZHk5CkXztVFXSJyW/8w9rVZdUGZM+mwwn81PLSE0v3s/2
wJH8eUqGl73i3Xgn7CVanN4cJYMGYVVlRD6Qr6muBN4Vh0ETY1PrjzsZ6XsV6vk9
glMbQnErYMZHZIDtnVdaBt60MecUD9O4eLeip0SE6bVRAucKBXA9xNMilMQEL8/X
FBMAxXl10rowBlbX2oaFSOM80eLTla4a3gSL7+0vua3C4Dtv2bLo8ZjCyqXwOJeb
N85cqcVZStz3qlgQN1nG2qGE3BS6scOs7+mUGe9CmS8acJV++PKm8CtmLH6/ibjV
tAc/6+Vg/keo0kty7H7L6oQd+gKpuaJ2I+wKYe4l9UwHi9zEb8YjzGB4n2AtsoWI
NDKi7/Gxha790QN3TD/U7p9GcEtfXKv8KISKfxnfD9o9Yx6pWOXcgvGefQkpXuRm
kGzDM+aY/VKs5Et5/RyKavBDihuOcumLsmoRg/bFoB/xpeURTyiehUdu
-----END CERTIFICATE-----
+49
View File
@@ -49,6 +49,30 @@ spec:
port:
number: 3100
---
# ── Authentik ─────────────────────────────────────────────────────────────────
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: authentik
namespace: iam
annotations:
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
spec:
ingressClassName: nginx
rules:
- host: authentik.riotpiao.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: authentik-server
port:
number: 80
---
# ── Vault ─────────────────────────────────────────────────────────────────────
apiVersion: networking.k8s.io/v1
@@ -275,3 +299,28 @@ spec:
name: temporal-web
port:
number: 8080
---
# NOTE: api.riotpiao.com is deliberately NOT here. Its namespace `api` is
# created in wave 7, and this Application syncs in wave 1 — an Ingress into a
# namespace that doesn't exist yet would fail and mark this whole app
# SyncFailed. It lives in k8s/apps/api/ingress.yaml, synced by the api-gw
# Application.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: homarr
namespace: dashboard
spec:
ingressClassName: nginx
rules:
- host: homarr.riotpiao.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: homarr
port:
number: 7575
+7 -15
View File
@@ -1,21 +1,13 @@
# k8s/bootstrap/ingress/riotpiao-com-cert.yaml
# Public wildcard cert for *.riotpiao.com, signed by Let's Encrypt (DNS-01 via
# Cloudflare, see ../cert-manager/letsencrypt-issuer.yaml).
# Cloudflare, see ../cert-manager/letsencrypt-issuer.yaml). Distinct from
# 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.
#
# nginx serves this secret as its default-ssl-certificate
# (../ingress/nginx-values.yaml), so every Ingress that omits a tls: block uses
# 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.
# issuerRef starts on letsencrypt-staging. Switch to letsencrypt-prod once the
# staging cert issues cleanly, then delete this Certificate's Secret to force
# a re-issue against prod (staging certs aren't browser-trusted).
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
@@ -107,9 +107,7 @@ ingress:
enabled: true
className: nginx
annotations:
# No cert-manager annotation. forgejo.riotpiao.com is covered by the
# *.riotpiao.com wildcard, served as nginx's default-ssl-certificate.
#
cert-manager.io/cluster-issuer: letsencrypt-prod
# This chart Ingress and the hand-written one in
# k8s/bootstrap/ingress/ingress.yaml both claim forgejo.riotpiao.com.
# ingress-nginx breaks the tie by oldest creationTimestamp, and the chart's
@@ -127,9 +125,10 @@ ingress:
paths:
- path: /
pathType: Prefix
# tls: intentionally omitted. The chart would create a second, single-host
# certificate (forgejo-tls) that expired on 2026-10-23 and competed with the
# wildcard. The wildcard covers this host.
tls:
- secretName: forgejo-tls
hosts:
- forgejo.riotpiao.com
# Resources
resources:
@@ -138,15 +138,12 @@ configs:
# cicd — automation-only; apiKey (token) for the CD pipeline, no UI login.
accounts.rock: apiKey,login
accounts.cicd: apiKey
# Zitadel OIDC. clientSecret pulled from the argocd `oidc-secret` Secret.
# The groups claim drives RBAC below.
# Authentik OIDC. clientSecret pulled from the argocd `oidc-secret` Secret
# (created by authentik-provision). The groups claim drives RBAC below.
oidc.config: |
name: Zitadel
issuer: https://zitadel.riotpiao.com
# MUST be quoted: an unquoted 18-digit number is parsed as a float by the
# YAML parser and ArgoCD rejects it ("cannot unmarshal number into ...
# clientID of type string"). Same class of bug as Grafana's client_id.
clientID: "396757801210282367"
name: Authentik
issuer: https://authentik.riotpiao.com/application/o/argocd/
clientID: argocd
clientSecret: $oidc-secret:client-secret
requestedScopes:
- openid
@@ -161,7 +158,7 @@ configs:
server.insecure: false
# RBAC. local `admin` + `rock` + the `cicd` pipeline account all get role:admin;
# the Zitadel `homelab-admins` role (rock is a member) maps to admin so SSO
# the Authentik `homelab-admins` group (rock is a member) maps to admin so SSO
# logins are admin too.
rbac:
policy.default: role:readonly
@@ -1,177 +0,0 @@
# etcd backend maintenance: compact, defrag, disarm NOSPACE.
#
# Why this exists
# ---------------
# etcd's backend file only grows. The apiserver compacts every 5m, but
# compaction marks superseded revisions free *inside* the file; only `defrag`
# returns that space to the filesystem. Nothing ran defrag, so the file grew
# until it crossed the 2 GiB quota:
#
# 2026-10-07 backend 2,148,843,520 B (2.1 GB) on talos-cp-1 and talos-cp-3
# -> etcd raised NOSPACE on those two members
# -> etcd rejected ALL writes cluster-wide
# -> kubelets could not post node status: 3 nodes NotReady
# -> apiserver writes returned 500, the tunnel API path 502
#
# The live data was tiny: 15,943 keys, 69 MB after compaction. The rest was
# dead pages. Recovery was `compact <current-revision>` then `defrag` per
# member then `alarm disarm`. Compaction had also stalled while the alarm was
# up, so `dbSizeInUse` read 100% of the file - defrag alone would NOT have
# worked. Order matters: compact first, then defrag.
#
# The earlier revision of this file never ran, for two independent reasons:
# 1. it existed only on branch fix/etcd-defrag-cron; the ArgoCD application
# `cluster-maintenance` tracks `main` (targetRevision: main), so the
# cluster never had the CronJob or the Secret.
# 2. the image was quay.io/coreos/etcd, which has NO /bin/sh (verified:
# `exec: "/bin/sh": no such file or directory`). Any container that runs
# a shell script needs an image that ships one.
# This revision fixes both: image bitnamilegacy/etcd (ships bash + etcdctl
# 3.6.4, matching the 3.6.11 servers) pinned by digest.
#
# Cadence: every 15 days. Measured growth is ~29 MB/day, so a 15-day cycle
# peaks near 500 MB - comfortably under the 2 GiB quota. The schedule is daily
# and the script exits unless the UTC epoch day is a multiple of 15. That gives
# an exact 15-day interval; `*/15` in the day-of-month field does not (it fires
# on the 1st, 16th and 31st, which puts two runs one day apart).
#
# Safety
# ------
# 1. Compaction uses the MINIMUM revision across members. Compacting to a
# revision a member has not reached wedges that member - measured on
# 2026-10-07, when talos-cp-2 froze ~1.2M entries behind the leader and
# needed a reboot to rejoin.
# 2. A member whose raft applied index lags the leader by more than 100000 is
# skipped and reported. Defragging a frozen member reclaims nothing.
# 3. Members are defragged one at a time. A defrag pauses the member's apply
# loop for the duration of the file copy.
#
# Credentials: Secret etcd-defrag-creds in kube-system, SOPS-managed in
# k8s/argocd/secrets/etcd-defrag-creds.enc.yaml (ksops generator 00-secrets).
# Its client certificate is signed by the cluster's etcd CA and expires
# 2036-10; rotate it before then by re-running the mint and re-encrypting.
#
# Companion alerts (monitoring): etcd_mvcc_db_total_size_in_bytes /
# etcd_server_quota_backend_bytes > 0.8, and an etcd NOSPACE alarm alert.
apiVersion: batch/v1
kind: CronJob
metadata:
name: etcd-defrag
namespace: kube-system
labels:
app.kubernetes.io/name: etcd-defrag
app.kubernetes.io/part-of: cluster-maintenance
spec:
schedule: "0 3 * * *" # daily; the script acts only every 15th day
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: defrag
image: bitnamilegacy/etcd:3.6.4@sha256:c86c06f1ce6a08b6df21eb7a9655b1818778b64c85b5cbb8413ebf7534789730
env:
- name: ETCDCTL_API
value: "3"
- name: ETCDCTL_CACERT
value: /etc/etcd/ca.crt
- name: ETCDCTL_CERT
value: /etc/etcd/client.crt
- name: ETCDCTL_KEY
value: /etc/etcd/client.key
- name: EPS
value: "https://192.168.1.166:2379 https://192.168.1.214:2379 https://192.168.1.162:2379"
command: ["/bin/bash", "-c"]
args:
- |
set -u
DAY=$(( $(date -u +%s) / 86400 ))
if [ $(( DAY % 15 )) -ne 0 ]; then
echo "skip: UTC epoch day $DAY is not a 15-day boundary"
exit 0
fi
echo "== before =="
for ep in $EPS; do
etcdctl --endpoints="$ep" endpoint status -w table || true
done
MINREV=""
MAXAPPLIED=0
for ep in $EPS; do
json=$(etcdctl --endpoints="$ep" endpoint status -w json 2>/dev/null) || continue
rev=$(printf '%s' "$json" | sed -n 's/.*"revision":\([0-9]*\).*/\1/p' | head -1)
app=$(printf '%s' "$json" | sed -n 's/.*"raftAppliedIndex":\([0-9]*\).*/\1/p' | head -1)
echo "member $ep revision=${rev:-?} raftAppliedIndex=${app:-?}"
if [ -n "$rev" ] && { [ -z "$MINREV" ] || [ "$rev" -lt "$MINREV" ]; }; then
MINREV="$rev"
fi
if [ -n "$app" ] && [ "$app" -gt "$MAXAPPLIED" ]; then
MAXAPPLIED="$app"
fi
done
if [ -z "$MINREV" ]; then
echo "FATAL: no member returned a revision; nothing attempted"
exit 1
fi
echo "== compact to revision $MINREV =="
etcdctl --endpoints="$(echo "$EPS" | awk '{print $1}')" compact "$MINREV" || echo "COMPACT-FAILED (continuing: defrag still reclaims free pages)"
# Physical deletion of superseded keys runs in the background
# after the compaction commit; give it a moment to drain.
sleep 20
echo "== defrag each member (sequential, one at a time) =="
rc=0
for ep in $EPS; do
json=$(etcdctl --endpoints="$ep" endpoint status -w json 2>/dev/null) || { echo "SKIP $ep (unreachable)"; rc=1; continue; }
app=$(printf '%s' "$json" | sed -n 's/.*"raftAppliedIndex":\([0-9]*\).*/\1/p' | head -1)
if [ -n "$app" ] && [ "$app" -lt $(( MAXAPPLIED - 100000 )) ]; then
echo "SKIP $ep (applied index $app lags leader $MAXAPPLIED; defrag would reclaim nothing)"
rc=1
continue
fi
echo "-- $ep"
if ! etcdctl --endpoints="$ep" defrag; then
echo "DEFRAG-FAILED $ep"
rc=1
fi
done
echo "== disarm NOSPACE =="
for ep in $EPS; do
etcdctl --endpoints="$ep" alarm list 2>/dev/null || true
etcdctl --endpoints="$ep" alarm disarm 2>/dev/null || true
done
echo "== after =="
for ep in $EPS; do
etcdctl --endpoints="$ep" endpoint status -w table || true
done
etcdctl --endpoints="$(echo "$EPS" | awk '{print $1}')" alarm list || true
exit $rc
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { cpu: 200m, memory: 128Mi }
volumeMounts:
- name: creds
mountPath: /etc/etcd
readOnly: true
volumes:
- name: creds
secret:
secretName: etcd-defrag-creds
defaultMode: 0444
@@ -1,14 +1,10 @@
# 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.
# Dedicated CNPG Postgres for Authentik (GitOps, wave 2 — before authentik w3).
# CNPG generates secret `authentik-db-app` + service `authentik-db-rw` in ns iam;
# authentik-values reads them locally (no cross-ns secret copy).
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: zitadel-db
name: authentik-db
namespace: iam
annotations:
argocd.argoproj.io/sync-options: SkipDryRunOnMissingResource=true
@@ -17,7 +13,7 @@ spec:
imageName: ghcr.io/cloudnative-pg/postgresql:16.2
bootstrap:
initdb:
database: zitadel
database: authentik
owner: app
encoding: UTF8
localeCollate: C
@@ -33,7 +29,7 @@ spec:
enablePodMonitor: true
affinity:
# preferred + tolerations: HA across nodes without deadlocking a partly-
# schedulable 3-CP (same as authentik-db and forgejo-db).
# schedulable 3-CP (same as forgejo-db).
podAntiAffinityType: preferred
topologyKey: kubernetes.io/hostname
tolerations:
+1 -1
View File
@@ -6,6 +6,6 @@ kind: Kustomization
# deployed into it. paperless ns declared in namespaces.yaml above.
resources:
- namespaces.yaml
- zitadel-db.yaml
- authentik-db.yaml
- temporal-db.yaml
- paperless-db.yaml
+2 -2
View File
@@ -1,7 +1,7 @@
# DB clusters are wave 2 — their namespaces must exist first (their apps that
# would CreateNamespace run later, w3/w8). Declared here so the databases App
# creates them. vault/temporal/paperless CreateNamespace=true then no-ops.
# poimen namespace created by poimen-root app (wave 7).
# creates them. authentik/vault/temporal/paperless CreateNamespace=true then
# no-ops. poimen namespace created by poimen-root app (wave 7).
apiVersion: v1
kind: Namespace
metadata:
+284
View File
@@ -0,0 +1,284 @@
# k8s/talos-iam/authentik-values.yaml
# Authentik — SSO Identity Provider for the homelab.
# Provides OAuth2/OIDC login for Grafana, MinIO, Forgejo, and Argo CD.
# Chart: authentik/authentik from https://charts.goauthentik.io
#
# Architecture: server (UI+API) + worker (background tasks) + PostgreSQL + Redis.
# PostgreSQL is the system of record — must persist. Redis is ephemeral cache/queue.
#
# Secrets injected via helmfile --set (from .env / vsource):
# AUTHENTIK_SECRET_KEY — signs sessions and tokens; set once, never rotate casually
# AUTHENTIK_BOOTSTRAP_PASSWORD — initial akadmin password (used once at first login)
# AUTHENTIK_BOOTSTRAP_TOKEN — API token for the setup_talos_iam.sh bootstrap script
# AUTHENTIK_PG_PASSWORD — PostgreSQL user password
authentik:
# host: the external URL Authentik uses to build redirect URIs in OAuth2 flows.
# Must match what the browser sees — if it returns an internal svc URL,
# the browser's redirect after login will fail (can't reach svc DNS externally).
# HTTP (not HTTPS) because the Authentik ingress has no TLS cert configured.
host: "https://authentik.riotpiao.com"
error_reporting:
enabled: false # do not phone home to Sentry
# PostgreSQL connection — points at CloudNativePG cluster in ddb namespace.
# Uses 'app' bootstrap user (CNPG simple pattern, same as Forgejo).
# Credentials injected from authentik-db-app secret via env vars below.
postgresql:
host: authentik-db-rw.iam.svc.cluster.local
port: 5432
name: authentik
user: app # All apps use shared 'app' user (CNPG design pattern)
password: "" # overridden by AUTHENTIK_POSTGRESQL__PASSWORD env var
# Redis connection — bundled subchart, standalone mode (no sentinel/cluster).
redis:
host: authentik-redis-master
# ── HTTP client timeouts ──────────────────────────────────────────────────────
# Increased to tolerate 5+ second pod-to-pod network latency.
# Affects webhooks, outpost management, SCIM, LDAP sync.
# Default: ~30s — too aggressive when latency spikes hit 5-10s.
log_level: debug # enable debug logging to monitor connection issues
# ── CA trust (shared by server and worker) ────────────────────────────────────
# Authentik (Python/Debian) uses requests + httpx for outgoing HTTPS — webhooks,
# outpost management, SCIM. Both libraries need REQUESTS_CA_BUNDLE / SSL_CERT_FILE
# to point to a bundle that includes homelab-ca, otherwise connections to other
# homelab services fail with "certificate signed by unknown authority".
#
# Strategy: a debian:12-slim init container (run as root) concatenates the
# Debian system Mozilla bundle with homelab-ca.crt into an emptyDir. The main
# container then references /merged/ca-bundle.crt via two env vars that cover
# every Python HTTP library.
_caVolumes: &caVolumes
- name: homelab-ca
configMap:
name: homelab-ca
- name: merged-ca
emptyDir: {}
_caVolumeMounts: &caVolumeMounts
- name: homelab-ca
mountPath: /homelab-ca
readOnly: true
- name: merged-ca
mountPath: /merged
readOnly: true
_caInitContainers: &caInitContainers
- name: merge-ca-certs
image: debian:bookworm
imagePullPolicy: IfNotPresent
securityContext:
runAsUser: 0
command:
- sh
- -c
- (cat /etc/ssl/certs/ca-certificates.crt 2>/dev/null; cat /homelab-ca/homelab-ca.crt) > /merged/ca-bundle.crt
volumeMounts:
- name: homelab-ca
mountPath: /homelab-ca
readOnly: true
- name: merged-ca
mountPath: /merged
# NOTE: no authentik-migrate init container — the authentik `server` entrypoint
# runs migrations itself on startup. A separate `manage migrate` init pinned to
# an older image tripped a version-history precheck on an empty DB
# (relation "authentik_version_history" does not exist) and blocked boot.
_caEnv: &caEnv
- name: REQUESTS_CA_BUNDLE
value: /merged/ca-bundle.crt
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
# ── Authentik server (UI + API) ───────────────────────────────────────────────
# Handles all browser traffic: login flows, admin UI, OAuth2 authorize/token endpoints.
# NodePort 32172 is a fallback for direct node access during troubleshooting;
# normal access is via nginx ingress (authentik.riotpiao.com → svc:80).
# Recreate: single replica + RWO-adjacent state — avoids split-brain on redeploy.
server:
replicas: 1
# Merge SOPS-CMP-emitted secret values after the chart's own `authentik` secret.
envFrom:
- secretRef:
name: authentik-secrets
deploymentStrategy:
type: Recreate
service:
type: NodePort
nodePort: 32172
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
volumes: *caVolumes
volumeMounts: *caVolumeMounts
initContainers: *caInitContainers
env:
# Merge CA trust env vars
- name: REQUESTS_CA_BUNDLE
value: /merged/ca-bundle.crt
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
# Override database credentials to use 'app' from authentik-db-app
- name: AUTHENTIK_POSTGRESQL__USER
valueFrom:
secretKeyRef:
name: authentik-db-app
key: username
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-db-app
key: password
podAnnotations:
configmap.reloader.stakater.com/reload: "homelab-ca"
homelab.io/restart-at: "2026-06-21T13-40"
# The /-/health/{live,ready}/ endpoints do a DB round-trip; under transient
# CNPG contention they respond in 5-6s while still returning 200. The chart's
# default 3s liveness timeout then flags a working backend as dead and kubelet
# kills it in a restart loop — the pod never stays Ready, gets dropped from the
# authentik-server Service endpoints, and the OAuth-provisioning PostSync hook
# fails with "Host is unreachable". Widen the timeouts so slow-but-healthy
# checks aren't treated as failures. (Only these fields are overridden; the
# chart deep-merges the rest of each probe, incl. the httpGet path.)
livenessProbe:
initialDelaySeconds: 300 # skip probe until 5min passed (migrations finish)
timeoutSeconds: 15
failureThreshold: 6
readinessProbe:
initialDelaySeconds: 300 # skip probe until migrations complete
timeoutSeconds: 15
failureThreshold: 6
startupProbe:
timeoutSeconds: 15
failureThreshold: 120 # 120 × 10s = 20min for fresh DB migrations
# Every OIDC login (Grafana, Argo CD, MinIO, Forgejo) depends on this server —
# its request latency/error rate explains SSO-driven slowness on those services.
metrics:
enabled: true
serviceMonitor:
enabled: true
scrapeTimeout: 30s
# ── Authentik worker ──────────────────────────────────────────────────────────
# Runs background tasks: email delivery, LDAP sync, flow policy evaluation,
# event log cleanup, and managed outpost updates. Stateless — no PVC needed.
# Same resource profile as server; Authentik 2023+ merged some worker duties
# into the server process but the worker pod is still required.
worker:
replicas: 1
envFrom:
- secretRef:
name: authentik-secrets
deploymentStrategy:
type: Recreate
resources:
requests:
cpu: 100m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
volumes: *caVolumes
volumeMounts: *caVolumeMounts
initContainers: *caInitContainers
env:
# Merge CA trust env vars
- name: REQUESTS_CA_BUNDLE
value: /merged/ca-bundle.crt
- name: SSL_CERT_FILE
value: /merged/ca-bundle.crt
# Override database credentials to use 'app' from authentik-db-app
- name: AUTHENTIK_POSTGRESQL__USER
valueFrom:
secretKeyRef:
name: authentik-db-app
key: username
- name: AUTHENTIK_POSTGRESQL__PASSWORD
valueFrom:
secretKeyRef:
name: authentik-db-app
key: password
podAnnotations:
configmap.reloader.stakater.com/reload: "homelab-ca"
homelab.io/restart-at: "2026-06-21T13-40"
livenessProbe:
initialDelaySeconds: 300 # skip probe until 5min passed (migrations finish)
readinessProbe:
initialDelaySeconds: 300 # skip probe until migrations complete
startupProbe:
initialDelaySeconds: 30 # let server finish DB work first
failureThreshold: 120
metrics:
enabled: true
serviceMonitor:
enabled: true
scrapeTimeout: 30s
# ── PostgreSQL (external: CloudNativePG cluster in ddb namespace) ─────────────
# Authentik connects to the dedicated authentik-db (1 primary + 2 replicas).
# Do not use the bundled Bitnami subchart — CNPG is already running.
postgresql:
enabled: false
primary:
persistence:
enabled: true
storageClass: longhorn
size: 8Gi
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
# ── Bundled Redis ─────────────────────────────────────────────────────────────
# Cache and async task queue only — no durable data. If Redis restarts, in-flight
# background tasks are retried and cached tokens are recomputed. Losing Redis
# data does not lose user accounts or flow configuration (that's in PostgreSQL).
# persistence: false saves a PVC and makes restarts faster.
#
# Same prefer-worker / fallback-to-cp scheduling as PostgreSQL.
# architecture: standalone — no Sentinel/cluster overhead for a 3-node homelab.
redis:
enabled: true
master:
persistence:
enabled: false
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
affinity:
nodeAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
preference:
matchExpressions:
- key: node-role.kubernetes.io/worker
operator: Exists
architecture: standalone
# Ingress disabled — rule lives in k8s/ingress/ingress.yaml (authentik.riotpiao.com).
# For direct access during bootstrap: kubectl -n iam port-forward svc/authentik-server 7000:80

Some files were not shown because too many files have changed in this diff Show More