StorageClass.parameters is immutable and mkfsParams was added after creation,
so every sync failed. Replace=true recreates it instead of patching. Existing
volumes keep their format; only new ones get mkfsParams.
Longhorn names its own disk key and writes storageReserved into it, so git's
default-disk never matched and selfHeal kept trying to add a second disk on the
same path. Dropped spec.disks from git, added ignoreDifferences.
Two bugs. Kong timeouts were on the Ingress; it reads them from the Service, so
its 60s default applied. Moved to the isvc, which KServe propagates.
Probes ran 'ollama list' — models on disk, not in VRAM — so the pod went Ready
before it could serve. Now 'ollama ps', and both models are warmed at startup.
Five model servers were applied by hand and tracked nowhere. Exported live,
kubectl diff empty on all five, so the first sync adopts without restarting.
prune: false — KServe copies isvc labels to its child Deployment, so ArgoCD
would prune children it does not own and KServe would recreate them.
Two independent bugs, both silent, both found while pointing an agent harness
at api.riotpiao.com.
1. Requests over ~10.6KB failed with HTTP 400
{"error":{"message":"[] is too short - 'messages'"}}.
The request-transformer plugin on the llm-chat-* routes rewrites the JSON
body, which means it reads it via kong.request.get_body(). That returns
nothing once nginx spills the body past client_body_buffer_size into a temp
file, so the plugin re-serialized a body with no `messages` and the upstream
rejected it. Measured on /v1/ornith/chat/completions: 10588 B -> 200,
11088 B -> 400. Isolated by size-sweeping /v1/embeddings, the one route with
no request-transformer, which passed an 18057 B body straight through to a
semantic 413 from TEI.
Raises the Kong http-block buffer to 16m. Any agent request carrying tool
schemas clears the old ceiling in a single turn.
2. key-auth was never applied to any model route.
The model-key-auth KongPlugin sat in namespace `api` while all five routes
that annotate it live in `llm-serving`. The ingress controller resolves
konghq.com/plugins against the annotated object's own namespace, so the
reference dangled and the plugin never bound. Verified before the fix:
unauthenticated GET /v1/models and POST /v1/ornith/chat/completions both
returned 200. A dangling plugin reference fails open and logs nothing.
Re-test both without a key after this syncs; expect 401.
Note for follow-up: llm-embeddings and llm-score carry no plugins annotation at
all, so they stay unauthenticated even after this change.
Co-Authored-By: Claude Opus 5 <[email protected]>
llm-serving-default-deny admits port 8080 only from pods carrying
llm-client=true. Kong lacked it, so every route that actually contacts an
upstream timed out. /v1/models masked the problem: request-termination answers
inside Kong and never touches an upstream, so it returned 200 throughout.
Opting in via podLabels rather than relaxing the policy — it is a compensating
control, not hygiene, since vLLM v0.11.0 is frozen on Volta and will not receive
patches for several remote/unauthenticated advisories.
podLabels land only in the pod template, not spec.selector.matchLabels, so this
is not an immutable-field change.
Kong matches routes on host/path/method/header, never on the request body, so a
single /v1/chat/completions dispatching on body.model is not expressible in Kong
OSS (ai-proxy-advanced, which does multi-target model routing, is Enterprise).
Model therefore goes in the path:
GET /v1/models static list (request-termination)
POST /v1/reasoning/chat/completions reasoning-predictor (vLLM)
POST /v1/ornith/chat/completions ornith-predictor (Ollama)
POST /v1/qwen/chat/completions ornith-predictor (Ollama, same pod)
POST /v1/embeddings embeddings-predictor (TEI)
POST /v1/rerank reranker-predictor (TEI)
POST /v1/score verifier-predictor (vLLM pooling)
- each chat route force-overwrites body.model via request-transformer add+replace:
ornith:35b and qwen2.5:3b-instruct share one Ollama pod, so without this a
client hitting /v1/qwen with "model":"ornith:35b" would silently get the 35B
- routes live in ns llm-serving, not api: an Ingress can only reference a Service
in its own namespace, and KIC watches all namespaces
- embeddings and score need no rewrite (TEI/vLLM already serve the canonical
paths); rerank does, since /v1/rerank 404s and only /rerank exists
- read/write timeouts 1h: Kong defaults to 60s, which a 32B model on Volta
exceeds mid-generation and returns 504
- nginx_proxy_proxy_buffering=off: buffered responses lump or stall SSE, and both
hops (nginx Ingress and Kong) must be unbuffered or the buffered one wins
- no auth for now, per decision; api.riotpiao.com is reachable through nginx, so
GPU time is currently unauthenticated
- namespace: PodSecurity privileged, needed for /dev/kvm + privileged QEMU
- storageclass: 1 replica, strict-local, WaitForFirstConsumer
- deployment: nodeSelector workload=imessage + matching NoSchedule toleration,
Recreate strategy (two QEMU procs on one qcow2 corrupts it), no readiness
probe (guest install is interactive and takes many minutes)
- services: ClusterIP only; VNC is an unauthenticated console, reach it with
port-forward, never an Ingress
- networkpolicy: default-deny, opt-in via sms-client=true on port 1234
argocd-server ran --insecure (plain HTTP :8080) while its Helm-managed
Ingress set ssl-passthrough: true, which sends nginx's raw TLS handshake
straight to the pod - HTTP server can't complete a TLS handshake, nginx
logged 502 (peer closed connection in SSL handshake). Compounded by a
second, conflicting Ingress for the same host in
k8s/bootstrap/ingress/ingress.yaml - two Ingress objects on one host is
undefined nginx routing behavior. Disabled the Helm-managed Ingress
(enabled: false) so ingress.yaml's passthrough Ingress is the sole
source of truth, and set server.insecure: false so argocd-server
actually terminates TLS itself, matching passthrough's requirement.
ArgoCD directory.include uses Go filepath.Match glob syntax, not shell
brace expansion - {a,b,c} silently matched nothing, only the original 2
files stayed tracked.
homelab-ca was referenced by 6 manifests (authentik, forgejo-runner,
blackbox-exporter, management-service) as a CA trust ConfigMap but never
existed anywhere - not in git, not live in cluster. Generated a new
10-year self-signed root CA, wired it as a ClusterIssuer (cert-manager
namespace) and distributed the public cert as a ConfigMap to every
consuming namespace (iam, cicd, monitoring, sqs). Private key lives only
in the encrypted Secret. Widened cert-manager-issuers' directory include
glob rather than creating a new Application - destination.namespace is
just a fallback default on a plain directory source, not a transformer,
so it doesn't fight with each ConfigMap's own explicit namespace.
Also adds grafana-oidc secret (GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET),
same pre-existing gap as grafana-admin - was meant to come from a deleted
manual script, value already available in .env.
nodeSelector still targeted az-a/talos-cp-1 from before the 3-CP topology
change. talos-cp-2 (az-b) has the dedicated Longhorn disks now, so the
pod's zone pin and the PVC's only viable replica location never matched
- ReplicaSchedulingFailure: disks are unavailable, pod stuck
ContainerCreating waiting on AttachVolume.
Vault's S3 storage backend needs AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
from vault-minio-creds, previously generated by a helmfile presync hook
that no longer exists post-Terraform/helmfile removal. Sourced from the
same MINIO_ROOT_USER/PASSWORD already in .env. vault-unseal-keys still
missing separately — needs a live 'vault operator init' run, deferred.
PVC sat Pending for 17 days — storageclass.storage.k8s.io "longhorn-wffc"
not found. Only longhorn, longhorn-cnpg, longhorn-static exist. Straight
naming drift, no such class was ever created.
repoServer CPU limit (500m) was too tight once the SOPS sidecar added real
decrypt work under the liveness probe's 1s timeout — repo-server kept
getting killed mid-sync. Raised to 1000m (node has 23+ idle cores, no
scarcity). Separately, the generate script's doc-separator fix exposed
that several .enc.yaml files (cloudflared, temporal, authentik, loki) are
raw Helm-values snippets, not K8s manifests — ArgoCD hard-failed the whole
batch on the first one missing 'kind:'. Script now skips those, so
correctly-shaped Secrets (grafana-admin included) sync independently.
grafana-admin also needed an admin-user key alongside admin-password —
the chart looks up both from the same existingSecret.
Sidecar container was absent from live repo-server Deployment (never in
helm history), causing sops-secrets Application to fail with cmp-server
socket not found — cascaded CreateContainerConfigError across every app
depending on SOPS-decrypted secrets. Also fixes duplicate version field
in plugin ConfigMap that produced a mismatched socket filename, and adds
an initContainer to fetch the sops binary into a writable emptyDir since
the sidecar runs non-root. grafana-secrets.enc.yaml rewritten from a bare
values file (never valid as a K8s Secret) to a proper Secret manifest so
grafana-admin now actually gets created.
- Replace all forgejo.riotpiao.com repo URLs with [email protected] SSH URLs
- Enables immediate GitOps sync without waiting for Forgejo mirror setup
- Includes ingress-nginx now fully ArgoCD-managed (wave 0)
- SOPS secrets can now sync and decrypt TLS certificates
TOPOLOGY CHANGE:
- All 3 control-plane nodes now schedulable (no NoSchedule taints)
- Pod distribution: ~59 on cp-1, ~21 on cp-2, ~23 on cp-3
- Better resource utilization across cluster
ADDED HARD RULE:
- Control-plane scheduling controlled via Terraform
- terraform.tfvars → allow_scheduling = true/false
- Never manual kubectl taint (Talos will revert)
- Workflow: terraform apply → talosctl apply-config
IMPLEMENTATION:
- Terraform: Set allow_scheduling=true for cp-2, cp-3
- Applied via talosctl --mode no-reboot (no disruption)
- Verified: kubectl get nodes shows no taints
ADDED:
- CloudNativePG (CNPG) Database Pattern section
- Explains shared 'app' user model (not per-app roles)
- Documents bootstrap.sh credential distribution pattern
- Working examples (Forgejo, Authentik)
- Prescriptive DO/DON'T guidance for new apps
FIXED:
- Storage topology: 3-node HA (not "sole Longhorn node")
- Verified: all 17 PVCs have replicas across all 3 nodes
- Updated last-modified date
This documents the architectural pattern established during CNPG refactor.
UNIFIED PATTERN: All apps follow same credential distribution
FORGEJO PATTERN (now universal):
1. CNPG creates ddb-cluster-app in ddb namespace (source)
2. bootstrap.sh copies to app namespaces (cicd, iam)
3. Apps reference local copy via secretKeyRef
4. No PostSync Jobs needed
CHANGES:
- bootstrap.sh: Copy ddb-cluster-app to iam namespace (like cicd)
- authentik-values.yaml: Reference local ddb-cluster-app via env vars
- Removed: sync-db-credentials PostSync Job (not needed)
- kustomization.yaml: Removed PostSync Job reference
BENEFITS:
✅ Same pattern as working Forgejo
✅ No complex PostSync Jobs
✅ bootstrap.sh handles setup for future clusters
✅ Simple secretKeyRef, no cross-namespace issues
✅ ArgoCD manages applications, not secrets
Database recreated with app owner (fresh migrations needed).
ARCHITECTURAL CHANGE: Align with CNPG design intent
BEFORE (Complex, broken):
- Per-app roles (authentik, temporal) with Database CR owner field
- Database CR doesn't transfer ownership properly
- Needed manual permission grants (PostSync Job)
- Apps couldn't create tables without grants from 'app' role
AFTER (Simple, works):
- All apps use shared 'app' bootstrap user
- Database CRs: owner: app (matches actual ownership)
- No permission grants needed (owner has full rights)
- Isolation via separate database names only
CHANGES:
- Database CRs: owner changed from app-specific to 'app'
- ddb-cluster.yaml: removed managed.roles section
- Deleted grant-schema-permissions PostSync Job
- Follows Forgejo pattern (already working this way)
MANUAL STEPS REQUIRED:
1. Update authentik-secrets: AUTHENTIK_POSTGRESQL__USER=app
2. Update temporal secrets: similar change
3. Recreate databases with app as owner
4. Restart applications
Benefits:
- Simpler architecture
- No permission grant complexity
- Aligns with CNPG single-cluster design
- Matches working Forgejo implementation
Fresh authentik deployment runs ~100 database migrations which takes 15-20
minutes. Previous startup probe failureThreshold of 60 (10 minutes) killed
the pod before migrations could complete, causing infinite restart loop.
Increased to 120 failures (20 minutes) to allow migrations to finish.
Fixes: nginx 503 due to pod never becoming Ready.
Authentik migrations need to CREATE SCHEMA (not just tables in public schema).
This requires GRANT CREATE ON DATABASE, not just schema-level permissions.
Added to PostSync Job:
- GRANT CREATE ON DATABASE authentik TO authentik
- GRANT CREATE ON DATABASE temporal TO temporal
- GRANT CREATE ON DATABASE temporal_visibility TO temporal
App user can grant these (it owns the databases).
Removes duplicate longhorn-kafka StorageClass managed by Kafka chart.
All applications now use single 'longhorn' StorageClass (3 replicas, Immediate binding).
Changes:
- Kafka chart: use 'longhorn' instead of 'longhorn-kafka'
- Delete Kafka StorageClass template (no longer needed)
- Update longhorn-storageclass.yaml to match deployed config (Immediate, not WaitForFirstConsumer)
Existing Kafka PVCs remain bound to old longhorn-kafka StorageClass (safe - no data loss).
New PVCs will use unified 'longhorn' StorageClass.
ROOT CAUSE: CNPG Database CR creates databases but doesn't grant schema
permissions to the owner role. Bootstrap DB owner 'app' retains CREATE
privilege on public schema, blocking authentik/temporal from creating tables.
SECURITY FIX: Removed insecure 'GRANT TO PUBLIC' from postInitApplicationSQL.
SOLUTION: PostSync Job connects as 'app' (DB owner) and grants schema
permissions to named roles (authentik, temporal) in their respective databases.
Runs after Database CRs reconcile, survives CNPG database recreation.
Pattern: Per-database grants via PostSync, not cluster-wide PUBLIC grants.
Adds SQL to postInitApplicationSQL granting schema permissions to PUBLIC.
Allows any role (authentik, temporal, etc) to create tables in databases.
For existing cluster: run SQL manually (done).
For future bootstrap: automatic via initdb.
Pattern for apps: Database CR + app-specific init Job optional (co-located).
CNPG Database CR creates DBs but doesn't grant schema permissions properly.
Database owner is 'app' instead of specified role (authentik, temporal).
PostSync Job grants ALL on schema public to both app and named roles,
ensuring applications can create tables. Runs after Database CRs reconcile.
Fixes: authentik InsufficientPrivilege error on migration.
Minio operator requires privileged securityContext. Without these labels,
StatefulSet stuck at 0/0 replicas (PodSecurity admission blocks pod creation).
CoreDNS rewrites *.riotpiao.com → ingress-nginx-controller but bootstrap
deployed as ingress-nginx-bootstrap-controller. Service alias makes both work.
- Add explicit tls block with riotpiao-com-tls secret
- Enables HTTPS access to https://forgejo.riotpiao.com
- Matches wildcard certificate (*.riotpiao.com)
The file comment mentioned TLS should be handled via default-ssl-certificate,
but explicit TLS blocks are needed for proper HTTPS routing.
Root cause: Longhorn refuses to schedule replicas on nodes without spec.name
field. talos-cp-1 was auto-discovered (has spec.name), but cp-2/cp-3 were
manually created CRDs without it.
Error: 'no node name provided to check node down or deleted'
Fix: Add spec.name matching metadata.name for both nodes.
Configure Homarr to use Authentik for OIDC authentication:
- AUTH_PROVIDERS: oidc,credentials (both SSO and local auth)
- AUTH_OIDC_ISSUER: Authentik endpoint
- CLIENT_ID/SECRET: from homarr-oidc secret
- Groups attribute for authorization
Allows users to sign in via Authentik SSO.
homarr-patches Application doesn't have SOPS support.
Secret is managed by sops-secrets Application instead.
Kustomization now only contains:
- fix-probes-job.yaml (PostSync hook)