Author SHA1 Message Date
Story Crater Bot ac1849d2a9 fix(llm-serving): bump reasoning memory limit to 36Gi headroom 2026-08-19 15:16:46 -07:00
Story Crater Bot 4eab8271c7 fix(llm-serving): num_cpu_blocks=2000 hung pod startup, drop to 32 2026-08-19 15:09:28 -07:00
Story Crater Bot dd491f6f8b feat(llm-serving): offload reasoning's KV cache to CPU DRAM
vLLM 0.11.0's native OffloadingConnector -- spills KV blocks to CPU RAM on
preemption instead of discarding them, avoiding recompute. Built into vLLM
core, no extra dependency. Bumped memory request/limit (+4Gi/replica) to
give the CPU block pool real room; worker-1 had ~18Gi of request headroom
across both replicas.
2026-08-19 15:02:28 -07:00
Story Crater Bot 5b041df884 fix(agent-pod): committed progress ledger so resume skips done tasks
Resuming the phase branch alone only recovers the code -- the task loop
still walked from the first task, re-verifying every already-done one
through a full planner call before reaching the first task that actually
needed work. .agent-progress is committed (not gitignored) and appended
per completed task, so a resumed run reads it once and skips straight
past known-done tasks with zero LLM calls. Validated locally against a
throwaway repo: second run skipped both tasks instantly (resumed: true)
instead of re-running planner on them.
2026-08-19 13:16:03 -07:00
Story Crater Bot 3ea057d83e fix(agent-pod): group sessions by repo, resume phase branches, fix empty-diff bug
- agent-manager spawn now gets --group repoId, so the TUI clusters
  planner/investigator/implementer/judge under one repo heading instead of
  4 unrelated sessions.
- runPhase was called with phaseBranch where it needed the true baseBranch,
  so every per-task judge review compared phaseBranch...HEAD -- always
  empty, since HEAD is phaseBranch while checked out. Judges only produced
  real verdicts anyway because they fell back to their own git log/show.
- Every restart re-cloned baseBranch fresh and started a new phase branch,
  discarding whatever a prior run had already committed mid-phase. Now:
  fetch+resume an existing phase branch if origin has one, push after every
  task instead of only at phase-end, and delete the phase branch (local +
  origin) once its milestone squash-merges into base.
2026-08-19 11:46:53 -07:00
Story Crater Bot 43c0e1faa2 fix(agent-pod): absolute paths for every sentinel/verdict file, cwd reminder per call
A pooled session's shell cwd drifts as it explores the repo between turns.
Seen live: a repo whose internal workspace dir is one letter off from the
repo's own directory name was enough for the agent to touch its sentinel
one level off from where coordinator watches for it -- coordinator waited
out the full timeout for a file that existed, just in the wrong place.
2026-08-19 11:26:33 -07:00
Story Crater Bot 3a244577e4 fix(agent-pod): fold judgeOnly status check into planner, drop separate judge pre-check 2026-08-19 10:57:21 -07:00
Story Crater Bot 7a0d09cbe0 fix(agent-pod): install python3 and sqlite3 in the container init 2026-08-19 10:46:50 -07:00
Story Crater Bot cb52356d13 fix(agent-pod): sync coordinator.js (slug repoId), tighten compaction, route judge to reasoning model 2026-08-19 10:41:33 -07:00
Story Crater Bot d0cbfac7a4 fix(agent-pod): stateless role pool (/new per reuse), never commit PLAN.md 2026-08-19 07:59:01 -07:00
Story Crater Bot 930374a3b8 feat(agent-pod): persistent per-role agent pool, concurrency moves to repo level
coordinator.js now runs one long-lived planner/investigator/implementer/judge
session per repo (reused across every task via tmux send-keys) instead of a
fresh spawn per task per stage. Tasks within a repo run sequentially against
that pool; concurrency is now REPO_CONCURRENCY (default 3) concurrent repos
via a new --repos flag, not concurrent tasks in one repo's phase.
2026-08-18 21:30:49 -07:00
Story Crater Bot b10d1c3a25 fix(llm-serving): use hermes tool-call parser, not deepseek_v3
deepseek_v3 400s on this checkpoint: "could not locate tool call start/end tokens in the tokenizer". unsloth/DeepSeek-R1-Distill-Qwen-32B is a Qwen2.5 base distilled on R1 reasoning traces -- it kept R1's <think> format but never got DeepSeek-V3's own special tool-call tokens registered in its tokenizer. hermes parses from text patterns instead of special tokens, so it works against the underlying Qwen tokenizer.
2026-08-18 20:56:40 -07:00
Story Crater Bot 4c54674dff fix(llm-serving): enable tool calling on homelab-reasoning
pi sends tool_choice="auto" for every session (Read/Bash/etc.) -- vLLM 400s on that without --enable-auto-tool-choice and a --tool-call-parser. Verified this deployed vLLM v0.11.0's registered parsers directly; deepseek_v3 matches, same family as the deepseek_r1 reasoning-parser already set (this Qwen-base distillation still emits DeepSeek's own tool-call format).
2026-08-18 20:50:21 -07:00
Story Crater Bot 05ff12c117 fix(agent-pod): use process.exitCode not process.exit() in coordinator.js
process.exit() right after console.log() can drop buffered stdout when it's piped (not a TTY) -- exactly kubectl exec's case. Explains the silent empty-output-exit-1 failures. process.exitCode + natural exit lets the event loop drain and flush first.
2026-08-18 19:13:07 -07:00
Story Crater Bot 56b1c96fcf fix(agent-pod): clone deterministically, not through a headless LLM call
git clone is mechanical -- routing it through spawnPi meant a crash gave zero diagnostic output, just a silent exit code. Direct runGit call now, same as commitPending/the squash-merge sequence. Drops the now-unused runStageWithResolver.
2026-08-18 18:57:18 -07:00
Story Crater Bot 26a959215e fix(agent-pod): sync coordinator.js ConfigMap, was stale since auto-discovery landed
The pod's coordinator-src ConfigMap still had the pre-auto-discovery version -- --tasks was required, no task-board parsing, no self-chained stages, no judge model routing. Regenerated from the current source.
2026-08-18 18:42:45 -07:00
Story Crater Bot 193b040de6 feat(agent-pod): implementer and judge learn Playwright for UI verification
Both skills already have Bash in allowed-tools -- no new pi capability needed. For UI/frontend work, implementer screenshots/clicks through the golden path via npx playwright instead of trusting that code compiling means it renders correctly; judge does the same as review evidence, FAILing on visual defects a diff alone wouldn't show. Doesn't apply to non-UI work.
2026-08-18 18:37:46 -07:00
Story Crater Bot 12778d5576 feat(llm-serving): scale ornith to 2 replicas instead of a dedicated grm GPU
reasoning keeps its 2 GPUs untouched. verifier's freed GPU goes to a second ornith replica instead of a standalone qwen-only pod -- both replicas load ornith:35b + qwen2.5:3b-instruct, k8s Service load-balances across them, so 2 concurrent implementer-style calls get independent instances.
2026-08-18 18:25:12 -07:00
Story Crater Bot cd6c620619 feat(llm-serving): retire verifier-predictor, add grm (qwen2.5:3b)
Frees verifier's GPU from an underused vLLM PRM deployment. qwen2.5:3b-instruct moves off ornith-predictor's shared pod onto its own dedicated GPU (grm.yaml), so verification/judge traffic stops contending with ornith:35b's agent traffic. /v1/qwen/chat/completions now points at grm-predictor; path unchanged.
2026-08-18 18:18:31 -07:00
Story Crater Bot 0a87302e19 fix(api): retire Kong key-auth on model routes; agent-pod builds agent-manager fork + ships coordinator.js
Kong key-auth rejected the Authorization: Bearer header every OpenAI-SDK-compatible client sends (verified: raw apikey header works, Bearer doesn't), so it's commented out and stripped from every llm-routes.yaml annotation until there's a Bearer-compatible fix. agent-pod now clones and builds the agent-manager fork from source at container start (no prebuilt binary shipped -- wrong arch and over ConfigMap's size cap) and ships coordinator.js alongside hub.js, so multiple repos can run the pipeline concurrently in one pod via kubectl exec. hub.js keeps its existing role as the container's foreground process, unchanged.
2026-08-18 17:50:52 -07:00
Story Crater Bot c64b68a36b fix(agent-pod): remote tui session for multi-agent 2026-08-18 15:08:04 -07:00
Story Crater Bot 4146a048c9 fix(ci): make the hardcoded-secret scan blocking and close the .gitignore/.sops.yaml gaps that let a plaintext deploy key through — also untracks tfplan binaries and skills-lock.json 2026-08-18 15:08:04 -07:00
Story Crater Bot f0fa1dbd27 fix(argocd): clone the public GitHub seed anonymously over HTTPS and delete the SSH deploy-key Secret — its private half had been committed in plaintext to a public remote, and a public repo needs no credential at all 2026-08-18 15:08:04 -07:00
Story Crater Bot 2b1c4b1df4 fix(forgejo): strategy Recreate for RWO data PVC — RollingUpdate deadlocked (new pod Multi-Attach error on the RWO gitea PVC held by the old pod, stuck Init forever) 2026-08-18 15:08:04 -07:00
Story Crater Bot 479318c532 fix(authentik): label argocd oidc-secret part-of=argocd — argocd's $secret substitution only reads labelled Secrets; without it OIDC login failed with oauth2 invalid_client (empty client_secret to IdP) 2026-08-18 15:08:04 -07:00
Story Crater Bot ff216429b9 feat(argocd): wire Authentik OIDC + local rock/cicd accounts + RBAC — adds oidc.config (homelab-admins->admin SSO), url, accounts.rock (login+apiKey) and accounts.cicd (apiKey for CD pipeline token), all role:admin 2026-08-18 15:08:04 -07:00
Story Crater Bot 7441aaf9c3 fix(homarr): raise CPU limit 500m->2 + disable analytics cron — Next.js aborted with exit 134 (SIGABRT) under CPU throttle during icon-updater/analytics, self-restarting in a loop and 502ing at the ingress 2026-08-18 15:08:04 -07:00
Story Crater Bot edd739198d fix(cilium): restrict L2 announcement to control-plane nodes — GPU worker lacks eno1 (Mellanox enp28s0f*), so when it won the .160 lease it couldn't ARP the VIP, black-holing all ingress (flapped on reboots) 2026-08-18 15:08:04 -07:00
Story Crater Bot 20f8aac95d fix(api): label Kong pods llm-client=true so llm-serving NetworkPolicy admits them — chat/embeddings/rerank/score routes silently hung until the client timeout because Cilium dropped Kong's packets
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.
2026-08-18 15:08:04 -07:00
Story Crater Bot dbd3dc7b3d feat(api): add DeepSeek-shaped LLM API on Kong — /v1/models, per-model chat completions, embeddings, rerank, score; disable Kong response buffering so stream:true actually streams
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
2026-08-18 15:08:04 -07:00
Story Crater Bot bebe8dc31b fix(ingress): remove stale ingress-nginx-controller-alias Service — its selfHeal kept clobbering the helm LoadBalancer Service (same name, dead ingress-nginx-bootstrap selector, 0 endpoints), unannouncing LB IP .160 and taking down all ingress 2026-08-18 15:08:04 -07:00
Story Crater Bot 4c0d30ce30 fix(homarr): add AUTH_OIDC_URI + email account linking — homarr hides the Authentik sign-in button unless AUTH_OIDC_URI (authorize endpoint) is set alongside AUTH_OIDC_ISSUER (per authentik/homarr SSO docs); was the missing var 2026-08-18 15:08:04 -07:00
Story Crater Bot a17ceedcd8 refactor(ingress): drop redundant ArgoCD ingress-nginx app — chart 4.15.1 was double-managed by both the helm-bootstrap release and this ArgoCD app (same chart), fighting over the controller/LB service (ingress-config drift). ingress-nginx is bootstrap-critical (ArgoCD's own reachability path), so helm-bootstrap is the single owner 2026-08-18 15:08:04 -07:00
Story Crater Bot 9a779ccaf4 feat(sms): add BlueBubbles iMessage delivery (Docker-OSX macOS VM pinned to worker-2) + ArgoCD app + dedicated longhorn-imessage-local SC — default longhorn SC can't schedule a 3-replica 200Gi volume (only worker-1 has 200Gi free at 100% over-provisioning) and Immediate binding would pin the qcow2 to the wrong node
- 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
2026-08-18 15:08:04 -07:00
Story Crater Bot 20d0517f79 feat(monitoring): enable Alertmanager (null receiver, longhorn PVC, az-a) + fix forgejo-rules ns forgejo->cicd — alerting delivery was disabled; forgejo PrometheusRule targeted a nonexistent namespace 2026-08-18 15:08:04 -07:00
Story Crater Bot c8ea7b9190 fix(prometheus): use longhorn StorageClass, drop nonexistent longhorn-wffc — Prometheus CR requested storageClass longhorn-wffc which doesn't exist (deprecated), so operator never created the StatefulSet (Reconciled=False, no metrics server) 2026-08-18 15:08:04 -07:00
Story Crater Bot d3e2215b5c fix(homarr): tune probes via chart values, drop fragile fix-probes-job — first-boot icon updater blocks health endpoint ~50s; default 10s×3 liveness SIGTERMs the pod (247 restarts, 503); chart exposes probes so the PostSync patch-job was unnecessary and reverted on every rollout 2026-08-18 15:08:04 -07:00
Story Crater Bot c00b2d1b53 fix(authentik): add minio policy scope mapping (homelab-admins->consoleAdmin else readonly) + set rock email — MinIO CLAIM_NAME=policy got no claim (no MinIO access); empty rock email broke Grafana OIDC (GitHub-style /emails 404) 2026-08-18 15:08:04 -07:00
Story Crater Bot db6bf742da fix(grafana): add email/login/name_attribute_path for Authentik OIDC — Grafana was falling back to GitHub-style <api_url>/emails (404 'Error getting email address'), breaking OAuth login; read identity from userinfo claims instead 2026-08-18 15:08:04 -07:00
Story Crater Bot f6298086f2 fix(forgejo-runner): cicd ns PSS privileged (dind needs it) + mount homelab-ca as ConfigMap not Secret — runner RS created 0 pods under baseline PSS, then FailedMount because homelab-ca is a ConfigMap trust bundle, not a Secret 2026-08-18 15:08:04 -07:00
Story Crater Bot fbc4e55718 feat(forgejo): add runner-token Secret via ksops — forgejo-runner register initContainer needs the registration token (from gitea actions generate-runner-token); was missing so runner deploy stuck 0/1 2026-08-18 15:08:04 -07:00
Story Crater Bot 06c35fb338 fix(coredns): own Corefile+hostname rewrites via Talos inlineManifest (single-source terraform/files/coredns/Corefile), drop ArgoCD coredns-config app — in-cluster *.riotpiao.com now resolves to nginx ingress so MinIO/OIDC discovery works; update cp-2 IP .213->.214 2026-08-18 15:08:04 -07:00
Story Crater Bot 09fa9c6145 feat(reloader): enable autoReloadAll + reloadOnCreate — watch all workloads without per-Deployment annotations (charts like homarr don't expose them); auto-restart pods when ksops secrets are created/rotated 2026-08-18 15:08:04 -07:00
Story Crater Bot bd99208754 fix(homarr): add auth-oidc-secret + db-encryption Secrets via ksops — homarr chart's envSecrets expect these exact names (oidc-client-id/secret, db-encryption-key); were never created so homarr CreateContainerConfigError 2026-08-18 15:08:04 -07:00
Story Crater Bot 58605e1b5c chore(duckdns): remove duckdns updater entirely — superseded by cloudflared tunnel; drop app-def, manifests, kube-system Deployment 2026-08-18 15:08:04 -07:00
Story Crater Bot 40fcbd036c fix(cert-manager): regenerate homelab-ca cert with basicConstraints CA:TRUE — old self-signed cert lacked CA:TRUE so the homelab-ca ClusterIssuer rejected it ('certificate is not a CA'); regen keypair Secret + trust-bundle ConfigMaps (4 ns) with matching CA cert 2026-08-18 15:08:04 -07:00
Story Crater Bot 69b5fc371d fix: deploy authentik/loki/vault Secrets via ksops (were dead helm-values fragments, causing CreateContainerConfigError) 2026-08-18 15:08:04 -07:00
Story Crater Bot 582524f921 fix(cert-manager): cert-manager-issuers directory.include renders empty — switch to explicit resources list, restore automated sync 2026-08-18 15:08:04 -07:00
Story Crater Bot 828e3fb287 refactor(argocd): replace SOPS CMP with ksops kustomize generator, rotate age key — CMP discover glob silently shadowed kustomize rendering of any app whose path held a .enc.yaml (MinIO Tenant/cloudflared/authentik jobs never applied); centralize 8 Secret manifests under k8s/argocd/secrets, defer 4 helm-values fragments 2026-08-18 15:08:04 -07:00
Story Crater Bot 1d5c18d62c fix(cert-manager): add homelab-ca.crt key to homelab-ca ConfigMaps — authentik init merge-ca-certs cats /homelab-ca/homelab-ca.crt which was missing, causing Init:Error and 503 2026-08-18 15:08:04 -07:00
Story Crater Bot bc1a6d8689 fix(argocd): resolve 502 on argocd.riotpiao.com, dedupe Ingress and TLS mode mismatch
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.
2026-08-18 15:08:04 -07:00
Story Crater Bot e4485412b0 fix(argocd): use comma-separated include list, not brace expansion
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.
2026-08-18 15:08:04 -07:00
Story Crater Bot 2022595426 feat(cert-manager): add self-signed homelab-ca ClusterIssuer + trust bundle, fix grafana-oidc secret
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.
2026-08-18 15:08:04 -07:00
Story Crater Bot f67aaa41d0 fix(portainer): pin to az-b (talos-cp-2), the real Longhorn storage node
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.
2026-08-18 15:08:04 -07:00
Story Crater Bot 69e8cfd6d1 fix(vault): add vault-minio-creds secret, was created by deleted helmfile presync hook
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.
2026-08-18 15:08:04 -07:00
Story Crater Bot db2fc9afc7 fix(portainer): correct storageClass name, longhorn-wffc never existed as a class
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.
2026-08-18 15:08:04 -07:00
Story Crater Bot 2b74b58ea6 fix(argocd): wire SOPS CMP sidecar + grafana-admin secret on repo-server 2026-08-18 15:08:04 -07:00
Story Crater Bot 27dbfb1bd7 feat(terraform): GPU worker node support (schematic, interface/diskSelector/swap tuning, gpu-node label, NVIDIA LTS extensions) 2026-08-18 15:08:04 -07:00
Story Crater Bot 4e67fd907a feat(argocd): migrate all applications from Forgejo to GitHub
- 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
2026-08-18 15:08:04 -07:00
124 changed files with 5054 additions and 1013 deletions
+35 -13
View File
@@ -156,22 +156,44 @@ jobs:
- name: Check for Secrets in Code
run: |
echo "=== Scanning for hardcoded secrets ==="
SECRETS_FOUND=0
# BLOCKING. This step used to only count findings and then exit 0, so a
# plaintext deploy key rode through it into a public remote. Two failure
# modes fixed: it now fails the build, and it matches key material by
# PEM header rather than only `private_key:`-style YAML field names.
# Findings are captured into variables and tested for emptiness rather than
# branching on grep's exit status: implementations disagree on the rc of a
# `-v` filter fed empty input, and a wrong rc here fails open.
# NOTE: --include must precede `--`; after `--` grep treats it as a filename
# and silently scans nothing.
FAILED=0
for pattern in "password:" "secret:" "token:" "api_key:" "apikey:" "private_key:" "privatekey:"; do
if grep -r "$pattern" k8s/ --include="*.yaml" --include="*.yml" | grep -v "^Binary"; then
echo "⚠️ Found potential secret pattern: $pattern"
SECRETS_FOUND=$((SECRETS_FOUND + 1))
fi
done
if [ $SECRETS_FOUND -gt 0 ]; then
echo "⚠️ Warning: Found $SECRETS_FOUND potential secrets"
echo "Secrets should be encrypted with SOPS or stored in ArgoCD Sealed Secrets"
else
echo "✓ No hardcoded secrets found"
# Any private key block is fatal, regardless of the field name carrying it.
KEYS=$(grep -rIE --include="*.yaml" --include="*.yml" \
-- "-----BEGIN ([A-Z]+ )?PRIVATE KEY-----" k8s/ \
| grep -v "\.enc\.yaml" || true)
if [ -n "$KEYS" ]; then
echo "❌ Unencrypted private key material found:"
echo "$KEYS"
FAILED=1
fi
# Plaintext values in secret-ish YAML fields. SOPS output is ENC[...],
# so encrypted files never trip this.
VALS=$(grep -rInE --include="*.yaml" --include="*.yml" \
-- "^[[:space:]]*(password|token|apiKey|api_key|sshPrivateKey|client_secret):[[:space:]]*[\"']?[^\"'[:space:]{\$]{8,}" k8s/ \
| grep -v "ENC\[" | grep -v "\.enc\.yaml" || true)
if [ -n "$VALS" ]; then
echo "❌ Plaintext secret value found:"
echo "$VALS"
FAILED=1
fi
if [ "$FAILED" -ne 0 ]; then
echo "Encrypt with SOPS (see .sops.yaml) — *.enc.yaml files are exempt."
exit 1
fi
echo "✓ No hardcoded secrets found"
# === Check K8s Security Best Practices ===
- name: Check K8s Security Best Practices
run: |
+11 -2
View File
@@ -50,10 +50,19 @@ terraform/*.tfstate.*
terraform.tfvars.local
skills-lock.json
secrets-plaintext.yaml
skills-lock.json
# Saved plan files — binary, environment-specific, may embed resource attributes
terraform/tfplan
terraform/tfplan-*
.DS_Store
CLAUDE.md
docs/
k8s/argocd/seed-repo-secret.yamlbootstrap-argocd.log
bootstrap-argocd.log
# Any plaintext (non-SOPS) secret manifest. Encrypted ones are *.enc.yaml and
# ARE committed — see .sops.yaml. A missing newline once merged two patterns on
# one line here, which is how a plaintext deploy key reached a public remote.
k8s/**/*-secret.yaml
!k8s/**/*.enc.yaml
+4 -2
View File
@@ -1,3 +1,5 @@
creation_rules:
- path_regex: k8s/.*secrets.*\.ya?ml
age: age1smu533f803gmd0jq60s2zaj9zlznajy0ca6rtewd4r37mr2hs3uqsrldfh
# `secrets?` — singular too. A `seed-repo-secret.yaml` once slipped this regex
# and was committed in plaintext to a public remote.
- path_regex: k8s/.*secrets?.*\.ya?ml
age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
+2 -2
View File
@@ -1,6 +1,6 @@
# ── Node IPs (3-CP HA topology) ───────────────────────────────────────────────
CP1_IP := 192.168.1.166 # talos-cp-1
CP2_IP := 192.168.1.213 # talos-cp-2 (storage: 3 disks)
CP2_IP := 192.168.1.214 # talos-cp-2 (storage: 3 disks)
CP3_IP := 192.168.1.162 # talos-cp-3
CP_VIP := 192.168.1.166 # controlplane VIP (currently .166)
@@ -28,7 +28,7 @@ W_CONFIG = cluster-config/worker-$(N).yaml
# ── Help ──────────────────────────────────────────────────────────────────────
.PHONY: help
help:
@echo "Homelab cluster (3-CP HA: .166/.213/.163) — available targets"
@echo "Homelab cluster (3-CP HA: .166/.214/.162) — available targets"
@echo ""
@echo " Status & Services"
@echo " nodes kubectl get nodes"
+6 -11
View File
@@ -12,16 +12,15 @@
# - Talos cluster up; kubectl context points at it
# - helm 3, kubectl
# - SOPS age key at $SOPS_KEY (for the ArgoCD SOPS CMP plugin)
# - GitHub read-only deploy key private half at $DEPLOY_KEY (public half added
# to the GitHub repo's Deploy keys)
#
# The GitHub seed repo is public, so it is cloned anonymously over HTTPS — no
# deploy key, no repository Secret, one less thing to bootstrap before ArgoCD.
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BOOT="$SCRIPT_DIR/k8s/bootstrap"
SOPS_KEY="${SOPS_KEY:-$HOME/.sops/key.txt}"
DEPLOY_KEY="${DEPLOY_KEY:-$HOME/.ssh/argocd_seed}"
GITHUB_SSH="[email protected]:Riotpiaole/riotpiao.homelab.com.git"
log() { echo "[$(date +%H:%M:%S)] $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
@@ -39,7 +38,6 @@ preflight() {
kubectl cluster-info >/dev/null || die "kubectl not configured / cluster unreachable"
command -v helm >/dev/null || die "helm 3 not found"
[[ -f "$SOPS_KEY" ]] || die "SOPS age key missing at $SOPS_KEY"
[[ -f "$DEPLOY_KEY" ]] || die "GitHub deploy key missing at $DEPLOY_KEY (see phase4-argocd/seed-repo-secret.example.yaml)"
log "✅ preflight ok"
}
@@ -194,13 +192,10 @@ p3_forgejo() {
p4_argocd() {
phase "PHASE 4: ArgoCD (seeded from GitHub)"
# Always ensure namespace + repository secret (idempotent)
# Always ensure namespace (idempotent). The seed repo is public — ArgoCD clones
# it anonymously over HTTPS, so there is no repository Secret to create.
kubectl create ns argocd --dry-run=client -o yaml | kubectl apply -f -
kubectl -n argocd create secret generic seed-github-repo \
--from-literal=type=git --from-literal=url="$GITHUB_SSH" \
--from-file=sshPrivateKey="$DEPLOY_KEY" --dry-run=client -o yaml | kubectl apply -f -
kubectl -n argocd label secret seed-github-repo argocd.argoproj.io/secret-type=repository --overwrite 2>/dev/null || true
# Decrypt and apply any encrypted secrets from bootstrap dir (local SOPS)
if command -v sops &> /dev/null; then
export SOPS_AGE_KEY_FILE="$SOPS_KEY"
+18
View File
@@ -0,0 +1,18 @@
apiVersion: v1
data:
settings.json: |
{
"defaultProvider": "homelab-ornith",
"defaultModel": "ornith:35b",
"defaultThinkingLevel": "medium",
"theme": "light",
"compaction": {
"enabled": true,
"reserveTokens": 16000,
"keepRecentTokens": 6000
}
}
kind: ConfigMap
metadata:
name: pi-config
namespace: agent-pod
+36
View File
@@ -0,0 +1,36 @@
# Exposes agent-hub at api.riotpiao.com/console (WebSocket) and /run
# (trigger a new session) -- both are routes on the same hub.js service.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: console
namespace: agent-pod
annotations:
konghq.com/strip-path: "false"
spec:
ingressClassName: kong
rules:
- host: api.riotpiao.com
http:
paths:
- path: /console
pathType: Prefix
backend:
service:
name: agent-hub
port:
number: 9090
- path: /run
pathType: Prefix
backend:
service:
name: agent-hub
port:
number: 9090
- path: /sessions
pathType: Prefix
backend:
service:
name: agent-hub
port:
number: 9090
@@ -0,0 +1,862 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: coordinator-src
namespace: agent-pod
data:
coordinator.js: |
#!/usr/bin/env node
// coordinator: local CLI that drives a multi-phase, multi-task pipeline of
// planner/investigator/implementer/judge stages, same state machine as
// hub.js's old /pipeline handler. Every interactive stage runs through the
// patched agent-manager fork's `spawn` subcommand, so it is a real tracked
// session (tmux pane on agent-manager's private socket + a state.db row)
// from the moment it exists -- attachable and visible in agent-manager's
// own TUI the whole time it runs.
//
// Per repo, each role (planner/investigator/implementer/judge) is ONE
// persistent agent-manager session, not a fresh spawn per task: the first
// task to need a role spawns it, every later task for that role reuses the
// same tmux pane via `tmux send-keys` (see runOnPool) -- the same nudge
// mechanism that used to only fire on a stall now doubles as "give this
// agent its next task." Every role reads everything it needs fresh off disk
// each call, so every pane gets a `/new` before every reuse instead of
// accumulating history that degrades and eventually errors out task after
// task -- same pane, same agent-manager session, zero memory of the last
// task it handled.
// Because only one implementer/judge/etc. exists per repo, tasks within a
// phase run strictly sequentially against the pool -- no per-task worktree,
// no per-task branch, no merge-back step; every task commits straight onto
// the phase branch in the repo's one shared clone.
//
// The unit of concurrency is now the REPO, not the task: runCoordinator
// takes a list of repos and runs up to REPO_CONCURRENCY of them at once,
// each with its own clone (under WORK_DIR/<repoId>) and its own 4-agent
// pool. The coordinator never kills a role's session; it rests at an idle
// prompt between tasks, and agent-manager's session list becomes the audit
// trail of everything every repo's pipeline ran. Completion is signaled by
// sentinel files under the repo's clone (unchanged convention), waited on
// with fs.watch instead of polling.
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const WORK_DIR = process.env.HUB_WORK_DIR || path.join(require("node:os").tmpdir(), "agent-harness-work");
// Never rely on a bare `pi`/`agent-manager` on $PATH -- see PI_BIN's own
// comment below; the same collision risk applies to any CLI name. Always
// invoke explicit pinned paths.
const PI_BIN =
process.env.PI_BIN ||
path.join(__dirname, "..", ".pi-cli", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "cli.js");
const AGENT_MANAGER_BIN = process.env.AGENT_MANAGER_BIN || path.join(__dirname, "..", ".bin", "agent-manager-fork");
// Empty means "let pi fall back to ~/.pi/agent/settings.json's default"
// (currently anthropic/claude-sonnet-4-5, real paid usage). Set both to
// route every stage -- headless (spawnPi) and interactive (runOnPool) --
// at the homelab model instead, e.g. AGENT_PROVIDER=homelab-ornith
// AGENT_MODEL=ornith:35b.
const AGENT_PROVIDER = process.env.AGENT_PROVIDER || "";
const AGENT_MODEL = process.env.AGENT_MODEL || "";
// judge can run a different model than the rest of the chain, e.g.
// homelab-reasoning instead of homelab-ornith now that verifier/PRM is
// retired. Falls back to AGENT_PROVIDER/AGENT_MODEL when unset, so a run
// that doesn't care keeps one uniform model everywhere.
const JUDGE_PROVIDER = process.env.JUDGE_PROVIDER || AGENT_PROVIDER;
const JUDGE_MODEL = process.env.JUDGE_MODEL || AGENT_MODEL;
function providerModelFor(role) {
return role === "judge" ? { provider: JUDGE_PROVIDER, model: JUDGE_MODEL } : { provider: AGENT_PROVIDER, model: AGENT_MODEL };
}
// agent-manager's private tmux server and session-naming scheme
// (internal/tmux/tmux.go: defaultSocket = "agentmgr", sessionName(id) =
// "am_"+id) -- stable, documented internals of the fork, used here only
// for read-only introspection (pane capture) and role nudges, exactly the
// class of operation hub.js already ran directly against its own sessions
// rather than asking a model to do it.
const AM_SOCKET = "agentmgr";
function amSessionName(id) {
return `am_${id}`;
}
function runAmTmux(args) {
return runCmd("tmux", ["-L", AM_SOCKET, ...args]);
}
const ROLE_SKILLS = new Set(["planner", "investigator", "info-collector", "implementer", "judge", "resolver"]);
// Every role reads everything it needs fresh off disk each call -- PLAN.md,
// the task spec, judge's verdict file, `git diff` against baseBranch --
// nothing depends on remembering earlier tasks. Left to accumulate, a
// pooled session's conversation grows without bound across every task in a
// repo and both correctness and reliability degrade hard once it does
// (observed: a planner session at ~1.5M cumulative tokens started erroring
// out every call, an investigator session that far gone started narrating a
// different codebase entirely). So every role gets reset to a clean
// conversation before every reuse instead of just being nudged with the
// next prompt -- same pane, same agent-manager session (still
// visible/attachable), zero history carried between tasks.
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
const HARD_RULES =
"Read and follow ~/.pi/agent/skills/karpathy-guidelines/SKILL.md and " +
"~/.pi/agent/skills/caveman/SKILL.md as hard rules for this entire task, before anything else. ";
function parseVerdictLine(text, label) {
if (!text) return null;
const re = new RegExp(`${label}:\\s*(\\w+)`, "i");
const m = text.match(re);
return m ? m[1].toUpperCase() : null;
}
function runCmd(bin, args, cwd) {
return new Promise((resolve) => {
const child = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
child.stdout.on("data", (c) => (out += c));
child.stderr.on("data", (c) => (out += c));
child.on("close", (code) => resolve({ code, out: out.trim() }));
});
}
function runGit(cwd, args) {
return runCmd("git", args, cwd);
}
// A stage saying "commit" in its prompt is a request, not a guarantee -- seen
// in practice: a stage writes a real file and simply never runs `git add`/
// `git commit`, leaving it untracked and invisible to every later `git diff`.
// Sweep and commit anything left dirty after every stage, deterministically.
async function commitPending(cwd, message) {
await runGit(cwd, ["add", "-A"]);
const status = await runGit(cwd, ["status", "--porcelain"]);
if (!status.out) return { committed: false };
const commit = await runGit(cwd, ["commit", "-m", message]);
return { committed: commit.code === 0, error: commit.code !== 0 ? commit.out : undefined };
}
// Headless one-shot pi call (`pi -p --mode json <prompt>`), used only for
// quick diagnostic/mechanical calls that don't need to be a watchable
// session: resolver's crash/stall diagnosis, and the initial clone. Kept
// exactly as before -- only the interactive per-role stages (runOnPool,
// below) go through agent-manager.
function spawnPi({ agent, prompt, cwd }) {
const finalPrompt = ROLE_SKILLS.has(agent) ? `/skill:${agent} ${HARD_RULES}${prompt}` : prompt;
const args = ["-p", "--mode", "json"];
if (AGENT_PROVIDER) args.push("--provider", AGENT_PROVIDER);
if (AGENT_MODEL) args.push("--model", AGENT_MODEL);
args.push(finalPrompt);
const child = spawn(PI_BIN, args, { stdio: ["ignore", "pipe", "pipe"], cwd });
let lastText = "";
let stderrTail = "";
let buf = "";
child.stdout.on("data", (chunk) => {
buf += chunk;
let idx;
while ((idx = buf.indexOf("\n")) !== -1) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event.type === "message_end" && event.message && Array.isArray(event.message.content)) {
const text = event.message.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
if (text) lastText = text;
}
} catch {
// non-JSON stdout noise, ignore
}
}
});
child.stderr.on("data", (chunk) => {
process.stderr.write(chunk);
stderrTail = (stderrTail + chunk.toString()).slice(-4000);
});
return new Promise((resolve) => {
child.on("close", (code) => resolve({ code, lastText, stderrTail }));
});
}
async function askResolver(cwd, repoId, diagnosticPrompt) {
const result = await spawnPi({ agent: "resolver", prompt: diagnosticPrompt, cwd });
return parseVerdictLine(result.lastText, "RESOLUTION");
}
const STAGE_TIMEOUT_MS = 10 * 60 * 1000;
const NUDGE_TIMEOUT_MS = 5 * 60 * 1000;
// Resolves as soon as filePath appears (fs.watch on its directory), or
// after limitMs with no sign of it -- event-driven completion instead of
// hub.js's old 10s poll.
function waitForSentinel(filePath, limitMs) {
return new Promise((resolve) => {
if (fs.existsSync(filePath)) return resolve(true);
const dir = path.dirname(filePath);
let settled = false;
let watcher;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (watcher) {
try {
watcher.close();
} catch {
// already closed
}
}
resolve(result);
};
try {
watcher = fs.watch(dir, () => {
if (fs.existsSync(filePath)) finish(true);
});
} catch {
// dir missing at watch time is a real bug elsewhere (cwd should
// already exist); surface it as a timeout rather than hang forever.
return finish(false);
}
// Closes the race between the existsSync check above and the watcher
// actually being attached.
if (fs.existsSync(filePath)) return finish(true);
const timer = setTimeout(() => finish(false), limitMs);
});
}
// Runs one task's worth of work on a persistent per-role agent: spawns the
// role's session the first time it's ever needed for this repo, sends every
// later prompt into that same tmux pane via send-keys -- prefixed with a
// `/new` first, so the pane and agent-manager session stay the same but the
// model starts that prompt with a clean conversation, no history carried
// over from whatever task this role last handled. pool is a plain object
// keyed by role name ("planner"/"investigator"/"implementer"/"judge"),
// shared across every task in a repo's pipeline (see runRepoPipeline) -- it
// IS the 4-agent pool, one entry per role, filled in lazily as each role
// gets its first task.
async function runOnPool(pool, cwd, repoId, role, prompt, sentinelFile) {
fs.rmSync(sentinelFile, { force: true });
const label = `${repoId}-${role}`;
let target = pool[role];
// A pooled session's shell cwd drifts as it explores the repo (e.g. cd
// into a Rust workspace subdirectory to read source) and nothing resets
// it back between turns. Seen in practice: a repo whose own internal
// workspace folder is one letter off from the repo's own directory name
// ("poiman" the repo vs. "poimen" the crate workspace inside it) was
// enough for the agent to touch its sentinel one level off from where
// this function is watching for it -- coordinator waits out the full
// STAGE_TIMEOUT_MS for a file that already exists, just in the wrong
// place. State the absolute target directory and use absolute paths for
// every filesystem instruction, so there's nothing for the agent to get
// wrong by reasoning about a relative "current directory."
const cwdReminder = `Your working directory for this task is ${cwd} -- if your shell isn't already there, run: cd ${cwd}\n\n`;
if (!target) {
const spawnArgs = ["spawn", "--tool", "pi", "--cwd", cwd, "--name", label, "--group", repoId, "--prompt", cwdReminder + HARD_RULES + prompt];
const { provider, model } = providerModelFor(role);
if (provider) spawnArgs.push("--provider", provider);
if (model) spawnArgs.push("--model", model);
const spawned = await runCmd(AGENT_MANAGER_BIN, spawnArgs);
if (spawned.code !== 0) return { ok: false, crashed: true, error: spawned.out, sessionName: label };
target = amSessionName(spawned.out);
pool[role] = target;
} else {
await runAmTmux(["send-keys", "-t", target, "/new", "Enter"]);
await sleep(1000);
await runAmTmux(["send-keys", "-t", target, cwdReminder + HARD_RULES + prompt, "Enter"]);
}
let ok = await waitForSentinel(sentinelFile, STAGE_TIMEOUT_MS);
if (!ok) {
const pane = await runAmTmux(["capture-pane", "-t", target, "-p", "-S", "-200"]);
const resolution = await askResolver(
cwd,
repoId,
`Repo ${repoId}'s "${role}" agent hasn't finished its current task after 10 minutes. Its pane tail:\n${pane.out.slice(-3000)}\n\n` +
`Decide: is it still making real progress and worth nudging to wrap up, or stuck and worth abandoning?`
);
if (resolution === "RETRY") {
await runAmTmux(["send-keys", "-t", target, `Please wrap up now and run: touch ${sentinelFile}`, "Enter"]);
ok = await waitForSentinel(sentinelFile, NUDGE_TIMEOUT_MS);
}
}
return { ok, sessionName: label };
}
function plannerPrompt(task, specHint, judgeOnly, cwd) {
// judgeOnly (auto-discovered tasks only, see parseTaskBoard): planner
// itself decides whether the task is already done before planning it,
// reading tasks/INDEX.md's own status notes plus git log/current code --
// replaces what used to be a separate judge pre-check call. One LLM round
// trip instead of two, and the same agent that's about to plan the task
// is the one deciding whether planning it is even necessary.
const resultFile = path.join(cwd, `.task-result-${task}`);
const decideStep = judgeOnly
? `First, decide whether task ${task} is already fully implemented on this branch: check ` +
`\`git log --oneline --grep '${task}'\`, tasks/INDEX.md's own status notes for this task, and the current ` +
`code directly against its spec (${specHint})'s acceptance criteria. Write your decision to ` +
`${resultFile} as a single "VERDICT: PASS" (already done, no further work needed) or ` +
`"VERDICT: FAIL" (needs work) line plus one line of rationale. If VERDICT is FAIL, continue below and ` +
`draft the plan in this same turn; if VERDICT is PASS, skip the rest and go straight to the touch step.\n\n`
: "";
return (
`${decideStep}Use the planner skill to draft PLAN.md for task ${task}, reading its spec (${specHint}). ` +
`PLAN.md is scratch state for this harness, not a deliverable -- do NOT commit it or add it to git. ` +
`Then run: touch ${path.join(cwd, `.stage-done-${task}-planner`)}`
);
}
function investigatorPrompt(task, cwd) {
return (
`Use the investigator skill to confirm PLAN.md against real sources for task ${task}, append findings. ` +
`PLAN.md is scratch state for this harness, not a deliverable -- do NOT commit it or add it to git. ` +
`Then run: touch ${path.join(cwd, `.stage-done-${task}-investigator`)}`
);
}
function implementerPrompt(task, attempt, feedbackHint, cwd) {
return (
`Use the implementer skill to implement what the current PLAN.md specifies for task ${task} (commit as you go). ` +
`${feedbackHint} Then run: touch ${path.join(cwd, `.stage-done-${task}-implementer-${attempt}`)}`
);
}
function judgePrompt(task, baseBranch, attempt, cwd) {
return (
`Use the judge skill to review the diff against ${baseBranch}...HEAD for task ${task}. Write your verdict to ` +
`${path.join(cwd, `.task-result-${task}`)} as a single "VERDICT: PASS" or "VERDICT: FAIL" line plus one line of rationale, then ` +
`run: touch ${path.join(cwd, `.stage-done-${task}-judge-${attempt}`)}`
);
}
const MAX_IMPLEMENT_ATTEMPTS = 5;
const MAX_PLAN_REVISIONS = 3;
// Runs one task against the repo's shared role pool: planner drafts
// PLAN.md (for auto-discovered tasks, first deciding off tasks/INDEX.md and
// the repo's own state whether the task is already done -- see
// plannerPrompt's judgeOnly branch; judge never does this pre-check),
// investigator confirms it, then implementer and judge go back and
// forth -- judge's FAIL rationale lands in .task-result-<task>, which the
// next implementer attempt is told to read and address. After
// MAX_IMPLEMENT_ATTEMPTS straight fails, the planner role is asked to judge
// whether the plan itself is wrong -- fresh conversation, same as any other
// planner call, reading PLAN.md/the judge feedback/the
// diff off disk rather than remembering having drafted the original plan.
// If it decides the approach is wrong it revises PLAN.md and the implementer
// gets a fresh attempt budget.
// MAX_PLAN_REVISIONS caps this from looping forever on a task that's
// genuinely stuck. All work happens directly in cwd (the repo's one shared
// clone, currently checked out to the phase branch) -- no worktree, since
// only one implementer/judge exist per repo and tasks run strictly one at a
// time (see runPhase).
async function runTaskOnPool(cwd, baseBranch, task, pool, repoId, pipelineSession, judgeOnly) {
const resultFile = path.join(cwd, `.task-result-${task}`);
fs.rmSync(resultFile, { force: true });
const specHint = `the file under tasks/ starting with "${task}-"`;
const stage = async (role, prompt, sentinel, displayLabel) => {
const label = displayLabel || role;
pipelineSession.activeTasks[task] = { stage: label, startedAt: new Date().toISOString() };
logProgress(pipelineSession);
const result = await runOnPool(pool, cwd, repoId, role, prompt, sentinel);
await commitPending(cwd, `task: ${task} (${label})`);
return result;
};
const abandon = (stageLabel, result, attempt) => {
delete pipelineSession.activeTasks[task];
logProgress(pipelineSession);
return {
task,
status: result.crashed ? "spawn-crashed" : "timed-out",
error: result.error,
stoppedAt: stageLabel,
...(attempt !== undefined ? { attempt } : {}),
};
};
// PLAN.md is scratch state for this one task, not a deliverable (see
// plannerPrompt/investigatorPrompt -- it's gitignored too, as a backstop
// in case an agent commits it anyway). Discard it once the task is done,
// whatever the outcome, so it never bleeds into the next task's planner
// call or sits around as stale harness clutter in the shared clone.
try {
let result = await stage("planner", plannerPrompt(task, specHint, judgeOnly, cwd), path.join(cwd, `.stage-done-${task}-planner`));
if (!result.ok) return abandon("planner", result);
if (judgeOnly) {
const quickText = fs.existsSync(resultFile) ? fs.readFileSync(resultFile, "utf8") : "";
if (parseVerdictLine(quickText, "VERDICT") === "PASS") {
delete pipelineSession.activeTasks[task];
logProgress(pipelineSession);
return { task, status: "done", judgeRationale: quickText, judgeOnlyPass: true };
}
}
result = await stage("investigator", investigatorPrompt(task, cwd), path.join(cwd, `.stage-done-${task}-investigator`));
if (!result.ok) return abandon("investigator", result);
let planRevisions = 0;
let implementAttempt = 0;
let verdict = null;
let resultText = "";
let justRevisedPlan = false;
while (true) {
implementAttempt++;
const feedbackHint = fs.existsSync(resultFile)
? justRevisedPlan
? `${resultFile} holds the judge's feedback against the OLD plan, which prompted a plan revision -- ` +
`PLAN.md has since changed. Read the current PLAN.md as the source of truth, not the old feedback verbatim.`
: `A previous judge review exists at ${resultFile} -- read it and address every issue it raises.`
: "";
justRevisedPlan = false;
result = await stage(
"implementer",
implementerPrompt(task, implementAttempt, feedbackHint, cwd),
path.join(cwd, `.stage-done-${task}-implementer-${implementAttempt}`)
);
if (!result.ok) return abandon("implementer", result, implementAttempt);
result = await stage("judge", judgePrompt(task, baseBranch, implementAttempt, cwd), path.join(cwd, `.stage-done-${task}-judge-${implementAttempt}`));
if (!result.ok) return abandon("judge", result, implementAttempt);
resultText = fs.existsSync(resultFile) ? fs.readFileSync(resultFile, "utf8") : "";
verdict = parseVerdictLine(resultText, "VERDICT");
if (verdict === "PASS") break;
if (implementAttempt >= MAX_IMPLEMENT_ATTEMPTS) {
if (planRevisions >= MAX_PLAN_REVISIONS) break;
planRevisions++;
result = await stage(
"planner",
`Implementer failed judge review ${MAX_IMPLEMENT_ATTEMPTS} times in a row for task ${task}. Read PLAN.md, ` +
`the judge's feedback in ${resultFile}, and the current diff against ${baseBranch}...HEAD. Decide ` +
`whether the plan's approach itself is wrong, not just the implementation -- if so, revise PLAN.md. If ` +
`you change the approach, also use the investigator skill to confirm the new approach against real ` +
`sources. If the plan is sound, note why in PLAN.md and leave it as-is. PLAN.md is scratch state for ` +
`this harness, not a deliverable -- do NOT commit it or add it to git. Then run: ` +
`touch ${path.join(cwd, `.stage-done-${task}-planner-revise-${planRevisions}`)}`,
path.join(cwd, `.stage-done-${task}-planner-revise-${planRevisions}`),
"planner-revise"
);
if (!result.ok) return abandon("planner-revise", result, planRevisions);
implementAttempt = 0;
justRevisedPlan = true;
}
}
delete pipelineSession.activeTasks[task];
logProgress(pipelineSession);
if (verdict !== "PASS" && planRevisions >= MAX_PLAN_REVISIONS) {
return { task, status: "unresolved", judgeRationale: resultText, implementAttempts: implementAttempt, planRevisions };
}
return { task, status: verdict === "PASS" ? "done" : "done-with-concerns", judgeRationale: resultText };
} finally {
fs.rmSync(path.join(cwd, "PLAN.md"), { force: true });
}
}
// Committed (never gitignored) so it survives a resumed phase branch --
// one task id per line, appended as each task resolves. This is what lets
// a resumed run skip straight past already-resolved tasks instead of
// re-running planner's judgeOnly decision on every one of them again:
// resuming the git branch alone only recovers the CODE, not "which tasks
// are already settled," and re-deciding that from scratch for every task
// burns a full LLM call per already-done task before ever reaching the
// first one that actually needs work.
function progressLedgerPath(cwd) {
return path.join(cwd, ".agent-progress");
}
function readCompletedTasks(cwd) {
const file = progressLedgerPath(cwd);
if (!fs.existsSync(file)) return new Set();
return new Set(
fs
.readFileSync(file, "utf8")
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
);
}
async function recordTaskComplete(cwd, task) {
fs.appendFileSync(progressLedgerPath(cwd), `${task}\n`);
await runGit(cwd, ["add", path.basename(progressLedgerPath(cwd))]);
await runGit(cwd, ["commit", "-m", `chore: mark ${task} complete in progress ledger`]);
}
// Runs every task in a phase (no declared dependency between them) strictly
// one at a time against the repo's shared role pool -- only one implementer/
// judge/etc. exists per repo, so there is no per-task concurrency to have
// here anymore (see REPO_CONCURRENCY below for where concurrency now
// lives). No worktrees: every task commits directly onto phaseBranch in the
// one shared cwd. baseBranch here is the TRUE base (e.g. "main") -- judge
// reviews `git diff baseBranch...HEAD`, not phaseBranch...HEAD, which would
// always be empty since HEAD *is* phaseBranch while it's checked out.
//
// Pushes phaseBranch after every task, not just once at full-phase-end: the
// pod is ephemeral and every restart re-clones baseBranch fresh (see
// runRepoPipeline) -- without this, a redeploy mid-phase silently discards
// every task committed so far, and the next run re-decides "is this done?"
// from a clone that never saw any of that work.
async function runPhase(cwd, baseBranch, phaseBranch, phaseTasks, pool, repoId, pipelineSession) {
const entries = phaseTasks.map((t) => (typeof t === "string" ? { id: t, judgeOnly: false } : t));
const completed = readCompletedTasks(cwd);
for (const entry of entries) {
if (completed.has(entry.id)) {
const result = { task: entry.id, status: "done", resumed: true };
pipelineSession.taskResults.push(result);
logProgress(pipelineSession);
continue;
}
const result = await runTaskOnPool(cwd, baseBranch, entry.id, pool, repoId, pipelineSession, entry.judgeOnly);
pipelineSession.taskResults.push(result);
if (result.status === "done" || result.status === "done-with-concerns") {
await recordTaskComplete(cwd, entry.id);
}
await runGit(cwd, ["push", "-u", "origin", phaseBranch]);
logProgress(pipelineSession);
}
}
// Discovers phases/tasks from the repo's own tasks/INDEX.md instead of
// requiring the caller to pass --tasks. Matches this convention's board
// shape (see e.g. Poimen/agent-rust's tasks/INDEX.md): a numbered phase
// heading ("## 1 — Foundations · T0.x"), followed by a markdown table
// whose rows link to each task's own spec file ("| [T0.1](T0.1-....md) |
// ... |"). Headings that aren't a numbered phase (prose sections like
// "## Ordering — declared, never derived", "## Progress") are skipped --
// only "## <digits> — ..." starts a new phase. Returns null if
// tasks/INDEX.md doesn't exist; an empty array if it exists but no phase
// yielded any task rows.
function parseTaskBoard(cwd) {
const indexPath = path.join(cwd, "tasks", "INDEX.md");
if (!fs.existsSync(indexPath)) return null;
const phaseHeaderRe = /^##\s+\d+\s+—/;
const taskRowRe = /^\|\s*\[([A-Za-z0-9.]+)\]\(/;
const phases = [];
let current = null;
for (const line of fs.readFileSync(indexPath, "utf8").split("\n")) {
if (phaseHeaderRe.test(line)) {
current = [];
phases.push(current);
continue;
}
const m = line.match(taskRowRe);
if (m && current) current.push(m[1]);
}
return phases.filter((phase) => phase.length > 0);
}
function phaseLabelFor(phaseTasks, index) {
const first = phaseTasks[0];
const id = typeof first === "string" ? first : first.id;
const dot = id.indexOf(".");
return dot === -1 ? `phase-${index}` : id.slice(0, dot);
}
function logProgress(pipelineSession) {
console.log(`[repo ${pipelineSession.id}] ${JSON.stringify(pipelineSession)}`);
}
// Runs one repo's full pipeline: clone, then phases strictly sequentially.
// tasks: array of phases, each phase an array of task ids with no declared
// dependency on each other (e.g. [["T0.1","T0.2"], ["T1.1","T1.2","T1.3"]]).
// A flat array of ids is also accepted and treated as one single phase. If
// omitted, phases are discovered from the repo's own tasks/INDEX.md and run
// judgeOnly first (a cheap "is this already done" check against the
// board's possibly-stale checkmarks). Each phase gets its own branch
// (agent-run/<repoId>/<phaseLabel>, e.g. .../T1); once every task in that
// phase lands "done" or "done-with-concerns" AND the phase judge (the same
// pooled judge agent that reviewed each task) passes the integration
// review, the phase branch is squash-merged into baseBranch and pushed,
// then the next phase branches off that updated base. Any failure halts
// this repo's pipeline before merging -- it does not affect other repos
// running concurrently (see runCoordinator).
async function runRepoPipeline({ repoId, repo, baseBranch, tasks, branchName }, pipelineSession) {
const cwd = path.join(WORK_DIR, repoId);
// repoId is a slug derived from the repo URL now (see slugFor), not a
// fresh UUID -- reusable across separate `runCoordinator` invocations
// against the same repo, so a stale clone from a prior run has to be
// wiped before this one starts, not merged into.
fs.rmSync(cwd, { recursive: true, force: true });
fs.mkdirSync(cwd, { recursive: true });
const pool = {};
const finish = (status) => {
pipelineSession.status = status;
pipelineSession.endedAt = new Date().toISOString();
logProgress(pipelineSession);
return pipelineSession;
};
// Deterministic, not routed through an LLM -- clone is 100% mechanical
// (same reasoning as commitPending/the squash-merge sequence below), and
// was the one place left that broke that pattern: a headless spawnPi
// call here meant a crash gave zero diagnostic output, just a silent
// exit code with nothing to debug from.
const clone = await runGit(cwd, ["clone", "--branch", baseBranch, repo, "."]);
if (clone.code !== 0) {
pipelineSession.gitError = clone.out;
return finish("clone-crashed");
}
if (!fs.existsSync(path.join(cwd, ".git"))) return finish("clone-missing");
let phases = tasks ? (Array.isArray(tasks[0]) ? tasks : [tasks]) : parseTaskBoard(cwd);
if (!phases || phases.length === 0) {
pipelineSession.gitError = "no tasks given and tasks/INDEX.md not found or empty";
return finish("no-tasks-found");
}
if (!tasks) {
phases = phases.map((phase) => phase.map((id) => ({ id, judgeOnly: true })));
}
pipelineSession.totalTasks = phases.flat().length;
for (let i = 0; i < phases.length; i++) {
const phaseTasks = phases[i];
const phaseLabel = phaseLabelFor(phaseTasks, i);
const phaseBranch = branchName ? `${branchName}/${phaseLabel}` : `agent-run/${repoId}/${phaseLabel}`;
// Resume a phase branch a prior (since-restarted) run already pushed,
// instead of always branching fresh off baseBranch -- otherwise every
// redeploy silently discards whatever tasks that prior run already
// committed and pushed (see runPhase's per-task push below).
const fetchExisting = await runGit(cwd, ["fetch", "origin", phaseBranch]);
const resuming = fetchExisting.code === 0;
const branchResult = resuming
? await runGit(cwd, ["checkout", "-b", phaseBranch, "FETCH_HEAD"])
: await runGit(cwd, ["checkout", "-b", phaseBranch]);
if (branchResult.code !== 0) {
pipelineSession.gitError = branchResult.out;
return finish("branch-crashed");
}
if (i === 0 && !resuming) {
const gitignoreAdditions = [
"",
"# agent-harness: build artifacts and vendored archives never belong in source control",
"*.tar.gz",
"*.tgz",
"*.crate",
"*.zip",
"*.bin",
"*.whl",
"vendor/",
"node_modules/",
"",
"# agent-harness: task/phase completion sentinel files, harness bookkeeping only",
".task-result-*",
".phase-result-*",
".stage-done-*",
"",
"# agent-harness: PLAN.md is per-task planner scratch state, never a deliverable",
"PLAN.md",
].join("\n");
fs.appendFileSync(path.join(cwd, ".gitignore"), gitignoreAdditions + "\n");
await runGit(cwd, ["add", ".gitignore"]);
await runGit(cwd, ["commit", "-m", "chore: broaden .gitignore for agent-run artifacts"]);
}
await runPhase(cwd, baseBranch, phaseBranch, phaseTasks, pool, repoId, pipelineSession);
const phaseTaskIds = new Set(phaseTasks.map((t) => (typeof t === "string" ? t : t.id)));
const phaseResults = pipelineSession.taskResults.filter((r) => phaseTaskIds.has(r.task));
const phaseClean =
phaseResults.length === phaseTaskIds.size && phaseResults.every((r) => r.status === "done" || r.status === "done-with-concerns");
if (!phaseClean) {
pipelineSession.haltedAt = phaseLabel;
return finish("halted-phase-failed");
}
const phaseResultFile = path.join(cwd, `.phase-result-${phaseLabel}`);
fs.rmSync(phaseResultFile, { force: true });
const phaseJudge = await runOnPool(
pool,
cwd,
repoId,
"judge",
`Use the judge skill to review the full phase diff for phase ${phaseLabel} against ` +
`${baseBranch}...HEAD (covers every task in this phase: ${[...phaseTaskIds].join(", ")}). Every ` +
`individual task already passed its own judge review -- your job here is different: confirm the ` +
`tasks integrate correctly as one coherent narrative, and that real integration tests (not just ` +
`each task's isolated unit checks) exist and actually exercise the phase's intended use case end ` +
`to end. Write your verdict to ${phaseResultFile} as a single "VERDICT: PASS" or ` +
`"VERDICT: FAIL" line plus rationale, then run: touch ${path.join(cwd, `.stage-done-phase-${phaseLabel}-judge`)}`,
path.join(cwd, `.stage-done-phase-${phaseLabel}-judge`)
);
await commitPending(cwd, `phase: ${phaseLabel} integration review`);
if (!phaseJudge.ok) {
pipelineSession.haltedAt = phaseLabel;
pipelineSession.gitError = phaseJudge.error;
return finish("phase-judge-crashed");
}
const phaseJudgeText = fs.existsSync(phaseResultFile) ? fs.readFileSync(phaseResultFile, "utf8") : "";
if (parseVerdictLine(phaseJudgeText, "VERDICT") !== "PASS") {
pipelineSession.haltedAt = phaseLabel;
pipelineSession.phaseJudgeRationale = phaseJudgeText;
return finish("halted-phase-judge-failed");
}
const checkoutBase = await runGit(cwd, ["checkout", baseBranch]);
if (checkoutBase.code !== 0) {
pipelineSession.gitError = checkoutBase.out;
return finish("squash-crashed");
}
const squash = await runGit(cwd, ["merge", "--squash", phaseBranch]);
if (squash.code !== 0) {
await runGit(cwd, ["merge", "--abort"]);
pipelineSession.gitError = squash.out;
return finish("squash-crashed");
}
const commit = await runGit(cwd, ["commit", "-m", `feat: ${phaseLabel} (${[...phaseTaskIds].join(", ")})`]);
if (commit.code !== 0) {
pipelineSession.gitError = commit.out;
return finish("squash-crashed");
}
const push = await runGit(cwd, ["push", "origin", baseBranch]);
if (push.code !== 0) {
pipelineSession.gitError = push.out;
return finish("squash-push-crashed");
}
// Milestone's content now lives in baseBranch as one squashed commit --
// the phase branch (and whatever a prior restart already pushed of it)
// has no further reason to exist. Delete it both places so a future run
// never tries to resume a phase that's already done, and so origin
// doesn't accumulate one dangling branch per completed phase forever.
await runGit(cwd, ["branch", "-D", phaseBranch]);
await runGit(cwd, ["push", "origin", "--delete", phaseBranch]);
logProgress(pipelineSession);
}
return finish("completed");
}
async function runConcurrent(items, limit, worker) {
const results = new Array(items.length);
let i = 0;
async function next() {
while (i < items.length) {
const idx = i++;
results[idx] = await worker(items[idx], idx);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, next));
return results;
}
// How many repos can be mid-flight at once. Each repo gets its own clone
// and its own 4-agent pool (planner/investigator/implementer/judge), so
// this is now the real concurrency knob -- tasks within one repo are
// already serialized against that repo's pool (see runPhase). The backend
// (homelab-ornith) actually runs 2 GPU replicas behind one Kubernetes
// Service, each with its own copy of the model loaded (see homelab's
// k8s/apps/llm-serving/ornith.yaml) -- so up to 2 concurrent LLM calls get
// real independent instances; a 3rd+ concurrent call queues inside
// whichever replica the Service's own load-balancing lands it on (each
// replica runs OLLAMA_NUM_PARALLEL=1). REPO_CONCURRENCY above 2 is still
// useful (more repos in flight overlaps git/file work, not just LLM calls)
// but past 2 simultaneous LLM calls, extra concurrency mostly means queueing
// rather than added throughput -- bump the backend's replica count to
// change that, not this constant.
const REPO_CONCURRENCY = Number(process.env.REPO_CONCURRENCY) || 3;
// repoId is the repo's own name, not a random id -- it's what every role
// session's --name is built from (see runOnPool: `${repoId}-${role}`), so
// agent-manager's own session list groups naturally by repo ("portfolio-
// planner", "portfolio-judge", "poiman-planner", ...) instead of by opaque
// UUID. Takes the last path segment of the URL, strips a trailing `.git`,
// and sanitizes anything that isn't safe in a tmux session name / directory
// name / git branch name. Two different repos that happen to share a
// basename (e.g. two orgs' "portfolio") would collide -- not handled, since
// nothing about this harness's usage has needed more than one org per run.
function slugFor(repoUrl) {
const last = repoUrl.replace(/\/+$/, "").split("/").pop() || repoUrl;
return last.replace(/\.git$/, "").replace(/[^a-zA-Z0-9._-]/g, "-");
}
// Top-level entry point: runs every repo in `repos` to completion, up to
// REPO_CONCURRENCY at a time. Returns a map of repoId -> final
// pipelineSession, one per repo, independent of how the others fared.
async function runCoordinator({ repos, base, tasks, branchName }) {
const sessions = {};
await runConcurrent(repos, REPO_CONCURRENCY, async (repoUrl) => {
const repoId = slugFor(repoUrl);
const pipelineSession = {
id: repoId,
repo: repoUrl,
status: "running",
taskResults: [],
activeTasks: {},
totalTasks: 0,
startedAt: new Date().toISOString(),
};
sessions[repoId] = pipelineSession;
await runRepoPipeline({ repoId, repo: repoUrl, baseBranch: base, tasks, branchName }, pipelineSession);
});
return sessions;
}
function parseArgs(argv) {
const opts = { base: "main" };
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--repo") opts.repo = argv[++i];
else if (a === "--repos") opts.repos = argv[++i];
else if (a === "--base") opts.base = argv[++i];
else if (a === "--tasks") opts.tasks = argv[++i];
else if (a === "--branch") opts.branch = argv[++i];
}
return opts;
}
async function main() {
const opts = parseArgs(process.argv.slice(2));
const repos = opts.repos ? opts.repos.split(",") : opts.repo ? [opts.repo] : null;
if (!repos || repos.length === 0) {
console.error(
"usage: coordinator.js --repos <url1,url2,...> [--tasks T0.1,T0.2;T1.1,T1.2,...] [--base main] [--branch <name>]\n" +
" --repo <url> also accepted for a single repo\n" +
" --tasks applies to every repo listed; omitted: each repo discovers its own phases from tasks/INDEX.md\n" +
" REPO_CONCURRENCY env var (default 3): how many repos run at once"
);
// process.exitCode + natural exit, not process.exit() -- stdout piped
// through kubectl exec (not a TTY) can drop buffered console.log/
// console.error output if the process exits before it flushes. Setting
// exitCode and letting the event loop drain naturally is the
// documented-safe way to exit with a specific code without racing it.
process.exitCode = 1;
return;
}
const phases = opts.tasks ? opts.tasks.split(";").map((phase) => phase.split(",")) : null;
const sessions = await runCoordinator({ repos, base: opts.base, tasks: phases, branchName: opts.branch });
process.exitCode = Object.values(sessions).every((s) => s.status === "completed") ? 0 : 1;
}
if (require.main === module) {
main();
}
module.exports = { runCoordinator, runRepoPipeline, runOnPool, parseTaskBoard };
+157
View File
@@ -0,0 +1,157 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: agent-pod
namespace: agent-pod
spec:
replicas: 1
selector:
matchLabels:
app: agent-pod
template:
metadata:
labels:
app: agent-pod
spec:
# api.riotpiao.com has no in-cluster DNS record (only resolves from the
# home network's own resolver) -- pin it to ingress-nginx-controller's
# ClusterIP so pi's models.json baseUrl works unchanged. TLS still
# terminates correctly since SNI/Host still say api.riotpiao.com.
hostAliases:
- ip: "10.101.128.185"
hostnames:
- "api.riotpiao.com"
containers:
# hub.js runs in the same container as pi (not a sidecar) so it can
# spawn `pi -p --mode json` directly via child_process -- a separate
# container can't exec into another container's filesystem/PATH.
# It IS the container's long-running process now; no more `sleep
# infinity` placeholder.
#
# Also builds the agent-manager fork (github.com/Riotpiaole/
# agent-manager, add-headless-spawn branch) from source and drops
# coordinator.js in beside hub.js -- neither is the container's
# foreground process. hub.js keeps that role unchanged; coordinator.js
# itself now owns multi-repo concurrency (REPO_CONCURRENCY env,
# default 3), so one invocation handles every repo:
# `kubectl exec <pod> -- node /root/coordinator.js --repos
# repoA,repoB,... --tasks ...`. Each repo gets its own clone and its
# own persistent 4-agent pool (planner/investigator/implementer/
# judge, one agent-manager session per role, reused across every
# task in that repo) on the container's local tmux server --
# `kubectl exec -it <pod> -- agent-manager` attaches its TUI live
# against those same sessions, no cross-machine visibility problem
# since spawner, tmux server, and viewer are all colocated here.
#
# No prebuilt Linux binary is shipped for agent-manager: the local
# .bin/ build is macOS arm64 (wrong OS/arch for this container
# anyway) and it's 27MB, well over a ConfigMap's ~1MiB cap. Debian's
# `apt-get golang-go` is far too old for this fork's go 1.26.5
# requirement, so the real Go toolchain is fetched directly from
# go.dev instead.
- name: pi
image: node:22-slim
command:
- sh
- -c
- |
set -e
apt-get update && apt-get install -y git curl jq openssh-client tmux python3 sqlite3
ssh-keygen -y -f /root/.ssh/id_forgejo > /root/.ssh/id_forgejo.pub
eval "$(ssh-agent -s)"
ssh-add /root/.ssh/id_forgejo
npm install -g @earendil-works/[email protected]
npm install --prefix /root ws
curl -fsSL "https://go.dev/dl/go1.26.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz
export PATH="$PATH:/usr/local/go/bin"
git clone --branch add-headless-spawn --depth 1 \
https://github.com/Riotpiaole/agent-manager.git /root/agent-manager-src
(cd /root/agent-manager-src && go build -o /usr/local/bin/agent-manager .)
node /root/hub.js
env:
- name: PI_BIN
value: pi
- name: AGENT_MANAGER_BIN
value: /usr/local/bin/agent-manager
- name: HUB_WORK_DIR
value: /root/agent-harness-work
# planner/investigator/implementer stay on the default
# (homelab-ornith/ornith:35b, pi's settings.json default). Judge
# moves to the separate homelab-reasoning backend (DeepSeek-R1,
# its own 2 GPU replicas) so judge calls stop contending with the
# other 3 roles for the 2 ornith pods -- an entire role's worth
# of traffic moves onto otherwise-idle capacity instead.
- name: JUDGE_PROVIDER
value: homelab-reasoning
- name: JUDGE_MODEL
value: reasoning
ports:
- containerPort: 9090
resources:
requests:
cpu: "4"
memory: 8Gi
limits:
cpu: "8"
memory: 16Gi
volumeMounts:
- name: pi-config
mountPath: /root/.pi/agent/settings.json
subPath: settings.json
- name: pi-models
mountPath: /root/.pi/agent/models.json
subPath: models.json
- name: pi-skills
mountPath: /root/.pi/agent/skills
- name: hub-src
mountPath: /root/hub.js
subPath: hub.js
- name: coordinator-src
mountPath: /root/coordinator.js
subPath: coordinator.js
- name: ssh-key
mountPath: /root/.ssh/id_forgejo
subPath: id_forgejo
- name: ssh-config
mountPath: /root/.ssh/config
subPath: config
volumes:
- name: pi-config
configMap:
name: pi-config
- name: pi-models
secret:
secretName: pi-models
- name: pi-skills
configMap:
name: pi-skills
items:
- key: planner-SKILL.md
path: planner/SKILL.md
- key: investigator-SKILL.md
path: investigator/SKILL.md
- key: info-collector-SKILL.md
path: info-collector/SKILL.md
- key: implementer-SKILL.md
path: implementer/SKILL.md
- key: judge-SKILL.md
path: judge/SKILL.md
- key: resolver-SKILL.md
path: resolver/SKILL.md
- key: brave-search-SKILL.md
path: brave-search/SKILL.md
- name: hub-src
configMap:
name: hub-src
- name: coordinator-src
configMap:
name: coordinator-src
- name: ssh-key
secret:
secretName: agent-pod-ssh-key
defaultMode: 0600
- name: ssh-config
configMap:
name: agent-pod-ssh-config
+700
View File
@@ -0,0 +1,700 @@
apiVersion: v1
data:
hub.js: |
#!/usr/bin/env node
// agent-hub: lives inside the pi container (not a sidecar) so it can spawn
// `pi` directly, and control the pod's own tmux server. One persistent
// in-cluster service -- POST /run to trigger a single ad-hoc headless agent
// run, POST /pipeline to run an ordered list of task phases against a
// repo/branch (phases run sequentially, up to PHASE_CONCURRENCY tasks within
// a phase run concurrently, each in its own git worktree). Within one task,
// planner/investigator/implementer/judge are separate agents in separate
// named tmux sessions (task-<id>-<role>, attachable via `kubectl exec -it --
// tmux attach -t <name>` while running), coordinating only through what's on
// disk in that task's worktree -- not one shared conversation. GET /console
// (WebSocket) watches every concurrent headless run live, relaying pi's own
// session protocol verbatim (same event shape Claude Code sessions use).
const http = require("node:http");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { spawn } = require("node:child_process");
const readline = require("node:readline");
const { WebSocketServer } = require("ws");
const PORT = process.env.HUB_PORT || 9090;
const WORK_DIR = process.env.HUB_WORK_DIR || path.join(require("node:os").tmpdir(), "agent-harness-work");
// Never rely on a bare `pi` on $PATH -- both `pi` and `agent-console` collide
// with unrelated tools on this machine (a Rust CLI and a Datadog TUI,
// respectively, discovered the hard way this session). Always invoke the
// exact pinned @earendil-works/[email protected] installed locally under
// .pi-cli/, by explicit path.
const PI_BIN =
process.env.PI_BIN ||
path.join(
__dirname,
"..",
".pi-cli",
"node_modules",
"@earendil-works",
"pi-coding-agent",
"dist",
"cli.js"
);
// Job-type skills under pi/skills/<name>/SKILL.md (mounted at
// ~/.pi/agent/skills/<name>/ in agent-pod). When `agent` matches one of
// these, the prompt is forced through pi's `/skill:<name> <args>` mechanism
// instead of being sent bare -- see pi's skills.md docs on single-shot skill
// loading. `resolver` is never dispatched directly by a caller; only the
// pipeline driver invokes it, on stage crashes.
const ROLE_SKILLS = new Set([
"planner",
"investigator",
"info-collector",
"implementer",
"judge",
"resolver",
]);
const sessions = new Map(); // id -> {id, agent, status, events, startedAt, endedAt, pipelineId?, stage?}
const viewers = new Set(); // WebSocket connections watching /console
function broadcast(type, session) {
const msg = JSON.stringify({ type, session });
for (const ws of viewers) {
if (ws.readyState === ws.OPEN) ws.send(msg);
}
}
function startSession(agent, extra = {}) {
const id = extra.id || crypto.randomUUID();
const session = {
...extra,
id,
agent,
status: "running",
events: [],
startedAt: new Date().toISOString(),
};
sessions.set(id, session);
broadcast("start", session);
return session;
}
function addEvent(session, rawLine) {
const event = JSON.parse(rawLine);
session.events.push(event);
broadcast("event", session);
return event;
}
function endSession(session, status) {
session.status = status;
session.endedAt = new Date().toISOString();
broadcast("end", session);
}
// Extracts the plain-text content of a message_end event, if any -- used to
// find the VERDICT:/RESOLUTION: line judge/resolver skills are required to
// end their final message with.
function textOf(event) {
if (event.type !== "message_end" || !event.message || !Array.isArray(event.message.content)) {
return null;
}
return event.message.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("\n");
}
// Core spawn primitive. Spawns `pi -p --mode json <extraArgs> <prompt>`,
// relays every line as a session event exactly like before. Returns
// { session, done } -- `session` is available synchronously (so an HTTP
// handler can respond with its id right away, same as the old runAgent),
// `done` is a Promise resolving once the process exits, for callers that
// need to wait on a stage (the pipeline driver) rather than fire-and-forget.
function spawnPi({ agent, prompt, provider, model, cwd, sessionExtra = {} }) {
const session = startSession(agent, sessionExtra);
const args = ["-p", "--mode", "json"];
if (provider) args.push("--provider", provider);
if (model) args.push("--model", model);
const finalPrompt = ROLE_SKILLS.has(agent) ? `/skill:${agent} ${prompt}` : prompt;
args.push(finalPrompt);
const child = spawn(PI_BIN, args, {
stdio: ["ignore", "pipe", "pipe"],
cwd,
});
const rl = readline.createInterface({ input: child.stdout });
let lastText = "";
let stderrTail = "";
rl.on("line", (line) => {
if (!line.trim()) return;
try {
const event = addEvent(session, line);
const text = textOf(event);
if (text) lastText = text;
} catch {
// non-JSON stdout noise, ignore
}
});
child.stderr.on("data", (chunk) => {
process.stderr.write(chunk);
stderrTail = (stderrTail + chunk.toString()).slice(-4000);
});
const done = new Promise((resolve) => {
child.on("close", (code) => {
endSession(session, code === 0 ? "done" : "error");
resolve({ code, session, lastText, stderrTail });
});
});
return { session, done };
}
function runAgent(agent, prompt, extraArgs = {}) {
// Fire-and-forget: caller (the /run handler) doesn't await `done`.
return spawnPi({ agent, prompt, ...extraArgs }).session;
}
function parseVerdictLine(text, label) {
if (!text) return null;
const re = new RegExp(`${label}:\\s*(\\w+)`, "i");
const m = text.match(re);
return m ? m[1].toUpperCase() : null;
}
// Deterministic git operations, run directly by hub.js rather than left to
// the model -- branch creation and pushing after each task are mechanical,
// not judgment calls, and need to happen reliably every time regardless of
// what a task's stages did or didn't remember to do.
function runCmd(bin, args, cwd) {
return new Promise((resolve) => {
const child = spawn(bin, args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
child.stdout.on("data", (c) => (out += c));
child.stderr.on("data", (c) => (out += c));
child.on("close", (code) => resolve({ code, out: out.trim() }));
});
}
function runGit(cwd, args) {
return runCmd("git", args, cwd);
}
// A stage saying "commit" in its prompt is a request, not a guarantee -- seen
// in practice: a stage writes a real file and simply never runs `git add`/
// `git commit`, leaving it untracked and invisible to every later `git diff`.
// Sweep and commit anything left dirty after every stage, deterministically.
async function commitPending(cwd, message) {
await runGit(cwd, ["add", "-A"]);
const status = await runGit(cwd, ["status", "--porcelain"]);
if (!status.out) return { committed: false };
const commit = await runGit(cwd, ["commit", "-m", message]);
return { committed: commit.code === 0, error: commit.code !== 0 ? commit.out : undefined };
}
// Invokes the `resolver` skill to diagnose a stuck/crashed stage and decide
// RETRY vs ABORT. Shared by both crash-recovery paths below (headless
// exit-code failures and interactive sentinel-file timeouts) -- the
// diagnostic prompt differs per caller, but "ask resolver, parse the
// RESOLUTION: line" is identical either way.
async function askResolver(pipelineId, cwd, task, diagnosticPrompt) {
const resolverResult = await spawnPi({
agent: "resolver",
prompt: diagnosticPrompt,
cwd,
sessionExtra: { pipelineId, stage: "resolver", task },
}).done;
return parseVerdictLine(resolverResult.lastText, "RESOLUTION");
}
// Runs one pipeline stage, and if it crashes (nonzero exit -- not a semantic
// judge FAIL, which is handled separately), asks the resolver to diagnose
// and decide RETRY vs ABORT. Retries the failed stage at most once,
// regardless of what resolver recommends a second time -- a hard cap, not
// indefinite trust in the model's judgment.
async function runStageWithResolver(pipelineId, cwd, stage, prompt, task) {
let result = await spawnPi({
agent: stage,
prompt,
cwd,
sessionExtra: { pipelineId, stage, task },
}).done;
if (result.code === 0) return result;
const resolution = await askResolver(
pipelineId,
cwd,
task,
`Stage "${stage}" exited with code ${result.code}. Its stderr tail:\n${result.stderrTail}`
);
if (resolution === "RETRY") {
result = await spawnPi({
agent: stage,
prompt,
cwd,
sessionExtra: { pipelineId, stage, task },
}).done;
}
return result;
}
// Deterministic tmux operations -- same rationale as runGit: mechanical,
// not a judgment call, run directly rather than trusted to a prompt.
function runTmux(args) {
return runCmd("tmux", args);
}
function tmuxSessionName(task) {
return `task-${task.replace(/[^a-zA-Z0-9]/g, "-")}`;
}
// Bounded-concurrency pool -- runs `worker` over `items`, at most `limit` in
// flight at once. No external dep; a plain in-order index cursor shared by
// `limit` runner loops.
async function runConcurrent(items, limit, worker) {
const results = new Array(items.length);
let i = 0;
async function next() {
while (i < items.length) {
const idx = i++;
results[idx] = await worker(items[idx], idx);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, next));
return results;
}
const STAGE_TIMEOUT_MS = 10 * 60 * 1000;
const NUDGE_TIMEOUT_MS = 5 * 60 * 1000;
const POLL_MS = 10 * 1000;
async function waitForFile(filePath, limitMs) {
const start = Date.now();
while (!fs.existsSync(filePath)) {
if (Date.now() - start > limitMs) return false;
await new Promise((r) => setTimeout(r, POLL_MS));
}
return true;
}
const MAX_IMPLEMENT_ATTEMPTS = 5;
const MAX_PLAN_REVISIONS = 3;
// Runs one role as its own fresh interactive pi session in its own named
// tmux session -- planner, investigator, implementer, and judge are
// separate agents with separate context, not turns in one shared
// conversation. They coordinate only through what's on disk in the task's
// worktree: PLAN.md, committed code, judge's result file. Each session is
// attachable while it runs (kubectl exec -it -- tmux attach -t <name>) and
// killed once its sentinel file lands or it's abandoned after resolver
// escalation.
async function runStage(pipelineId, cwd, task, stageLabel, stagePrompt, sentinelFile) {
const sessionName = `${tmuxSessionName(task)}-${stageLabel}`;
fs.rmSync(sentinelFile, { force: true });
const spawned = await runTmux(["new-session", "-d", "-s", sessionName, "-c", cwd, PI_BIN, stagePrompt]);
if (spawned.code !== 0) return { ok: false, crashed: true, error: spawned.out, sessionName };
let ok = await waitForFile(sentinelFile, STAGE_TIMEOUT_MS);
if (!ok) {
const pane = await runTmux(["capture-pane", "-t", sessionName, "-p", "-S", "-200"]);
const resolution = await askResolver(
pipelineId,
cwd,
task,
`Task ${task}'s "${stageLabel}" stage hasn't finished after 10 minutes. ` +
`Its pane tail:\n${pane.out.slice(-3000)}\n\nDecide: is it still making ` +
`real progress and worth nudging to wrap up, or stuck and worth abandoning?`
);
if (resolution === "RETRY") {
await runTmux([
"send-keys",
"-t",
sessionName,
`Please wrap up the "${stageLabel}" stage now and touch ${path.basename(sentinelFile)} when done.`,
"Enter",
]);
ok = await waitForFile(sentinelFile, NUDGE_TIMEOUT_MS);
}
}
await runTmux(["kill-session", "-t", sessionName]);
return { ok, sessionName };
}
// Runs one task in its own git worktree (see runPhase): planner drafts
// PLAN.md, investigator confirms it, then implementer and judge go back and
// forth -- judge's FAIL rationale lands in .task-result-<task>, which the
// next implementer attempt is told to read and address. After
// MAX_IMPLEMENT_ATTEMPTS straight fails, planner is brought back in to
// judge whether the *plan* itself is wrong, not just the implementation; if
// so it revises PLAN.md and the implementer gets a fresh attempt budget
// against the new plan. MAX_PLAN_REVISIONS caps this from looping forever
// on a task that's genuinely stuck.
async function runTaskInteractive(pipelineId, cwd, baseBranch, task, pipelineSession, judgeOnly) {
const resultFile = path.join(cwd, `.task-result-${task}`);
fs.rmSync(resultFile, { force: true });
const specHint = `the file under tasks/ starting with "${task}-"`;
const runRole = async (stageLabel, prompt, sentinel) => {
pipelineSession.activeTasks[task] = {
stage: stageLabel,
sessionName: `${tmuxSessionName(task)}-${stageLabel}`,
startedAt: new Date().toISOString(),
};
broadcast("event", pipelineSession);
const result = await runStage(pipelineId, cwd, task, stageLabel, prompt, sentinel);
await commitPending(cwd, `task: ${task} (${stageLabel})`);
return result;
};
const abandon = (stageLabel, result, attempt) => {
delete pipelineSession.activeTasks[task];
broadcast("event", pipelineSession);
return {
task,
status: result.crashed ? "spawn-crashed" : "timed-out",
error: result.error,
stoppedAt: stageLabel,
...(attempt !== undefined ? { attempt } : {}),
};
};
// Task's implementation is inherited already-committed (e.g. from a base
// branch of prior work) -- try one judge pass against the spec directly
// (no PLAN.md exists yet) before paying for a full planner/investigator
// redo. PASS ends the task here; FAIL falls through into the normal flow
// below, so planner/implementer pick up with the judge's real feedback.
if (judgeOnly) {
const quick = await runRole(
"judge",
`Task ${task} may already be implemented on this branch -- check ` +
`\`git log --oneline --grep '${task}'\` and the current code directly against its spec ` +
`(${specHint})'s acceptance criteria (no PLAN.md exists for this task yet). Write your ` +
`verdict to .task-result-${task} as a single "VERDICT: PASS" or "VERDICT: FAIL" line plus ` +
`one line of rationale, then run: touch .stage-done-${task}-judge-0`,
path.join(cwd, `.stage-done-${task}-judge-0`)
);
if (!quick.ok) return abandon("judge", quick, 0);
const quickText = fs.existsSync(resultFile) ? fs.readFileSync(resultFile, "utf8") : "";
if (parseVerdictLine(quickText, "VERDICT") === "PASS") {
delete pipelineSession.activeTasks[task];
broadcast("event", pipelineSession);
return { task, status: "done", judgeRationale: quickText, judgeOnlyPass: true };
}
}
let result = await runRole(
"planner",
`Use the planner skill to draft PLAN.md for task ${task}, reading its spec (${specHint}). Commit PLAN.md, then run: touch .stage-done-${task}-planner`,
path.join(cwd, `.stage-done-${task}-planner`)
);
if (!result.ok) return abandon("planner", result);
result = await runRole(
"investigator",
`Use the investigator skill to confirm PLAN.md against real sources, append findings, commit. Then run: touch .stage-done-${task}-investigator`,
path.join(cwd, `.stage-done-${task}-investigator`)
);
if (!result.ok) return abandon("investigator", result);
let planRevisions = 0;
let implementAttempt = 0;
let verdict = null;
let resultText = "";
let justRevisedPlan = false;
while (true) {
implementAttempt++;
const feedbackHint = fs.existsSync(resultFile)
? justRevisedPlan
? `.task-result-${task} holds the judge's feedback against the OLD plan, which prompted a plan revision -- ` +
`PLAN.md has since changed. Read the current PLAN.md as the source of truth, not the old feedback verbatim.`
: `A previous judge review exists at .task-result-${task} -- read it and address every issue it raises.`
: "";
justRevisedPlan = false;
result = await runRole(
"implementer",
`Use the implementer skill to implement what the current PLAN.md specifies (commit as you go). ${feedbackHint} Then run: touch .stage-done-${task}-implementer-${implementAttempt}`,
path.join(cwd, `.stage-done-${task}-implementer-${implementAttempt}`)
);
if (!result.ok) return abandon("implementer", result, implementAttempt);
result = await runRole(
"judge",
`Use the judge skill to review the diff against ${baseBranch}...HEAD. Write your verdict to ` +
`.task-result-${task} as a single "VERDICT: PASS" or "VERDICT: FAIL" line plus one line of ` +
`rationale, then run: touch .stage-done-${task}-judge-${implementAttempt}`,
path.join(cwd, `.stage-done-${task}-judge-${implementAttempt}`)
);
if (!result.ok) return abandon("judge", result, implementAttempt);
resultText = fs.existsSync(resultFile) ? fs.readFileSync(resultFile, "utf8") : "";
verdict = parseVerdictLine(resultText, "VERDICT");
if (verdict === "PASS") break;
if (implementAttempt >= MAX_IMPLEMENT_ATTEMPTS) {
if (planRevisions >= MAX_PLAN_REVISIONS) break;
planRevisions++;
result = await runRole(
"planner-revise",
`Implementer failed judge review ${MAX_IMPLEMENT_ATTEMPTS} times in a row for task ${task}. Read PLAN.md, ` +
`the judge's feedback in .task-result-${task}, and the current diff against ${baseBranch}...HEAD. Decide ` +
`whether the plan's approach itself is wrong, not just the implementation -- if so, revise PLAN.md and ` +
`commit. If you change the approach, also use the investigator skill to confirm the new approach against ` +
`real sources before committing. If the plan is sound, note why in PLAN.md and leave it as-is. Then run: ` +
`touch .stage-done-${task}-planner-revise-${planRevisions}`,
path.join(cwd, `.stage-done-${task}-planner-revise-${planRevisions}`)
);
if (!result.ok) return abandon("planner-revise", result, planRevisions);
implementAttempt = 0;
justRevisedPlan = true;
}
}
delete pipelineSession.activeTasks[task];
broadcast("event", pipelineSession);
if (verdict !== "PASS" && planRevisions >= MAX_PLAN_REVISIONS) {
return { task, status: "unresolved", judgeRationale: resultText, implementAttempts: implementAttempt, planRevisions };
}
return {
task,
status: verdict === "PASS" ? "done" : "done-with-concerns",
judgeRationale: resultText,
};
}
const PHASE_CONCURRENCY = 3;
// Runs one phase (a batch of tasks with no declared dependency on each
// other) with up to PHASE_CONCURRENCY tasks in flight at once. Each task
// gets its own git worktree off workBranch -- concurrent pi sessions writing
// into one shared working tree would corrupt the index; worktrees share the
// same object database but give each task an isolated checkout. After a
// task's session ends, its branch is merged back into workBranch and pushed,
// one merge at a time (git ref updates aren't safe to run concurrently even
// though the worktrees themselves are isolated).
async function runPhase(pipelineId, cwd, workBranch, phaseTasks, pipelineSession) {
// Each entry is either a plain task id, or { id, judgeOnly: true } when
// the task's implementation already exists (e.g. inherited from a base
// branch) and just needs a real judge pass rather than a full
// planner/investigator/implementer redo.
const entries = phaseTasks.map((t) => (typeof t === "string" ? { id: t, judgeOnly: false } : t));
const worktrees = {};
for (const entry of entries) {
const task = entry.id;
const wtDir = path.join(WORK_DIR, pipelineId, `wt-${task.replace(/[^a-zA-Z0-9]/g, "-")}`);
const taskBranch = `task/${task}`;
const add = await runGit(cwd, ["worktree", "add", "-b", taskBranch, wtDir, workBranch]);
if (add.code !== 0) {
pipelineSession.taskResults.push({ task, status: "worktree-crashed", error: add.out });
continue;
}
worktrees[task] = { wtDir, taskBranch };
}
const runnable = entries.filter((e) => worktrees[e.id]);
await runConcurrent(runnable, PHASE_CONCURRENCY, async (entry) => {
const { wtDir } = worktrees[entry.id];
const result = await runTaskInteractive(pipelineId, wtDir, workBranch, entry.id, pipelineSession, entry.judgeOnly);
pipelineSession.taskResults.push(result);
return result;
});
// Merge + push sequentially -- ref updates on the shared repo, one at a
// time, in the declared task order for this phase.
for (const entry of runnable) {
const task = entry.id;
const { wtDir, taskBranch } = worktrees[task];
const result = pipelineSession.taskResults.find((r) => r.task === task);
const merge = await runGit(cwd, ["merge", "--no-ff", taskBranch, "-m", `merge: ${task}`]);
if (merge.code !== 0) {
await runGit(cwd, ["merge", "--abort"]);
if (result) {
result.status = "merge-conflict";
result.mergeError = merge.out;
}
} else {
const push = await runGit(cwd, ["push", "-u", "origin", workBranch]);
if (result) {
result.pushed = push.code === 0;
if (!result.pushed) result.pushError = push.out;
}
}
await runGit(cwd, ["worktree", "remove", wtDir, "--force"]);
await runGit(cwd, ["branch", "-D", taskBranch]);
broadcast("event", pipelineSession);
}
}
// tasks: array of phases, each phase an array of task ids with no declared
// dependency on each other (e.g. [["T0.1","T0.2"], ["T1.1","T1.2","T1.3"]]) --
// caller's responsibility to supply real phase grouping (see tasks/INDEX.md;
// filename/numeric sort does NOT match execution order on boards like this).
// A flat array of ids is also accepted and treated as one single phase.
// Phases run strictly sequentially (a phase boundary is a real dependency
// gate); tasks within a phase run concurrently, each in its own worktree --
// see runPhase.
function runPipeline({ pipelineId, repo, baseBranch, tasks, branchName }) {
const cwd = path.join(WORK_DIR, pipelineId);
fs.mkdirSync(cwd, { recursive: true });
const phases = Array.isArray(tasks[0]) ? tasks : [tasks];
const pipelineSession = startSession("pipeline", {
id: pipelineId,
pipelineId,
stage: "pipeline",
taskResults: [],
activeTasks: {},
totalTasks: phases.flat().length,
});
(async () => {
const clone = await runStageWithResolver(
pipelineId,
cwd,
"planner",
`Run exactly this command, verbatim, no variation: git clone --branch ${baseBranch} ${repo} . -- the trailing dot is required, it clones directly into the current directory instead of creating a subdirectory. Do not cd anywhere first or after. Do nothing else.`,
"clone"
);
if (clone.code !== 0) return endSession(pipelineSession, "clone-crashed");
if (!fs.existsSync(path.join(cwd, ".git"))) {
// The model deciding to `cd` elsewhere before cloning (instead of
// cloning into the assigned cwd) is a real failure mode seen in
// practice, not a hypothetical -- exit code 0 doesn't mean the clone
// landed where every later stage's cwd assumes it did.
return endSession(pipelineSession, "clone-missing");
}
// Dedicated branch, never main -- and pushed after every single task
// (not just at the end) so a pod restart mid-run loses at most the
// in-progress task's work, not everything since the start.
const workBranch = branchName || `agent-run/${pipelineId}`;
const branchResult = await runGit(cwd, ["checkout", "-b", workBranch]);
if (branchResult.code !== 0) {
pipelineSession.gitError = branchResult.out;
return endSession(pipelineSession, "branch-crashed");
}
// Seen in practice: a task manually downloads a dependency tarball
// (crates.io registry access isn't guaranteed from every sandboxed
// checkout) and it lands at repo root, outside whatever .gitignore
// already covers -- then git add -A (ours or the model's own) commits
// it. Append broad build-artifact/archive patterns before any task
// runs, so it's excluded regardless of who stages files later.
const gitignoreAdditions = [
"",
"# agent-harness: build artifacts and vendored archives never belong in source control",
"*.tar.gz",
"*.tgz",
"*.crate",
"*.zip",
"*.bin",
"*.whl",
"vendor/",
"node_modules/",
"",
"# agent-harness: task completion sentinel files, harness bookkeeping only",
".task-result-*",
".stage-done-*",
].join("\n");
fs.appendFileSync(path.join(cwd, ".gitignore"), gitignoreAdditions + "\n");
await runGit(cwd, ["add", ".gitignore"]);
await runGit(cwd, ["commit", "-m", "chore: broaden .gitignore for agent-run artifacts"]);
for (const phaseTasks of phases) {
await runPhase(pipelineId, cwd, workBranch, phaseTasks, pipelineSession);
}
const crashed = pipelineSession.taskResults.filter((r) => r.status.endsWith("-crashed"));
endSession(pipelineSession, crashed.length > 0 ? "completed-with-crashes" : "completed");
})();
return pipelineSession;
}
const server = http.createServer((req, res) => {
const url = new URL(req.url, "http://localhost");
if (url.pathname === "/healthz") {
res.writeHead(200).end();
return;
}
if (url.pathname === "/sessions" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify([...sessions.values()]));
return;
}
if (url.pathname === "/run" && req.method === "POST") {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
try {
const { agent, prompt, provider, model } = JSON.parse(body);
if (!agent || !prompt) throw new Error("agent and prompt are required");
const session = runAgent(agent, prompt, { provider, model });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id: session.id }));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
if (url.pathname === "/pipeline" && req.method === "POST") {
let body = "";
req.on("data", (chunk) => (body += chunk));
req.on("end", () => {
try {
const { repo, baseBranch, tasks, branchName } = JSON.parse(body);
if (!repo || !baseBranch || !Array.isArray(tasks) || tasks.length === 0) {
throw new Error("repo, baseBranch, and a non-empty tasks array are required");
}
const pipelineId = crypto.randomUUID();
runPipeline({ pipelineId, repo, baseBranch, tasks, branchName });
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ id: pipelineId }));
} catch (err) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
res.writeHead(404).end();
});
const wss = new WebSocketServer({ server, path: "/console" });
wss.on("connection", (ws) => {
for (const session of sessions.values()) {
ws.send(JSON.stringify({ type: "snapshot", session }));
}
viewers.add(ws);
ws.on("close", () => viewers.delete(ws));
});
server.listen(PORT, () => console.log(`agent-hub listening on :${PORT}`));
kind: ConfigMap
metadata:
name: hub-src
namespace: agent-pod
+11
View File
@@ -0,0 +1,11 @@
apiVersion: v1
kind: Service
metadata:
name: agent-hub
namespace: agent-pod
spec:
selector:
app: agent-pod
ports:
- port: 9090
targetPort: 9090
+12
View File
@@ -0,0 +1,12 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: agent-pod
resources:
- deployment.yaml
- configmap.yaml
- hub-configmap.yaml
- coordinator-configmap.yaml
- pi-skills-configmap.yaml
- ssh-configmap.yaml
- hub-service.yaml
- console-ingress.yaml
+161
View File
@@ -0,0 +1,161 @@
apiVersion: v1
data:
brave-search-SKILL.md: |
---
name: brave-search
description: Web search via the Brave Search API, called directly with curl. Use for searching documentation, facts, or any current web content.
allowed-tools: Bash
---
# Brave Search
Direct HTTP call to the Brave Search API — no separate script or package, just `curl` (`BRAVE_API_KEY` is already set in the environment).
## Search
```bash
curl -s -H "Accept: application/json" -H "X-Subscription-Token: $BRAVE_API_KEY" \
--get --data-urlencode "q=<query>" --data-urlencode "count=5" \
"https://api.search.brave.com/res/v1/web/search"
```
Options (add as extra `--data-urlencode` pairs):
- `count=<n>` — number of results (max 20, default 5)
- `country=<code>` — two-letter country code (default US)
- `freshness=pd|pw|pm|py` — past day/week/month/year, or `freshness=YYYY-MM-DDtoYYYY-MM-DD`
Response is JSON; the results live at `.web.results[]`, each with `title`, `url`, `description`, `age`. Pipe through `jq` if you want a shorter view, e.g.:
```bash
curl -s -H "Accept: application/json" -H "X-Subscription-Token: $BRAVE_API_KEY" \
--get --data-urlencode "q=<query>" "https://api.search.brave.com/res/v1/web/search" \
| jq -r '.web.results[] | "- \(.title)\n \(.url)\n \(.description)\n"'
```
There's no page-content-extraction helper here — if a result needs reading in full, `curl` the URL directly and read the raw HTML/text; don't expect readability-cleaned markdown.
## When to Use
- Searching for documentation or API references
- Looking up facts or current information
- Confirming a claim or approach against real sources
implementer-SKILL.md: |
---
name: implementer
description: Turns a confirmed PLAN.md into real code changes in the current checkout, committing incrementally. Use as the implementation stage of a spec-to-push pipeline, after planner and investigator have run.
allowed-tools: Read Grep Find Ls Write Edit Bash
---
Execute the already-agreed plan; don't re-litigate it. `PLAN.md` (plus any `## Investigation` flags) is the source of truth for *what*; use judgment only for *how*, within the codebase's existing conventions.
- Read `PLAN.md` top to bottom. Treat flagged/unconfirmed steps conservatively (safer, more literal reading; note it in the commit). Work steps in order. Commit after each meaningful step (`git add -A && git commit -m "..."`), not one giant commit — the judge stage needs real diff history.
- Push only if the task explicitly asks for it.
Match existing style. Don't refactor or "improve" code the plan didn't ask you to touch.
**UI/frontend changes:** don't trust that the code compiles as proof it works. Start the app (or its dev server) and use `npx playwright` via Bash to actually load the page and look — screenshot the affected view before and after your change, and click through the golden path the plan describes. `npx playwright screenshot <url> out.png` for a quick visual check; for interaction (clicks, form fills, navigation), write a small throwaway script under a scratch path (e.g. `/tmp/`, never committed) using `playwright` the library, run it with `node`, then delete it. This doesn't apply to non-UI work (a Rust library, a CLI, a backend-only change) — use judgment.
**Hard rules:**
- Follow DRY and SOLID. Don't duplicate logic that already exists elsewhere in the codebase you're touching — reuse or extract instead. Keep each unit responsible for one thing.
- Never commit anything that doesn't belong in source control: build artifacts, downloaded/vendored dependencies, secrets, scratch/debug files. `.gitignore` already blocks common patterns; if you create something outside those patterns, delete it before committing rather than relying on `.gitignore` to catch it.
**Never vendor a dependency by downloading/extracting it into the repo.** Use the language's real package manager (`cargo add`, `npm install`, etc.) so the dependency is declared in the manifest and lockfile, not a tarball or extracted source tree sitting in the checkout. If the package manager can't reach its registry from here, say so in your commit message rather than working around it — a later commit sweep (`git add -A`) commits whatever's in the checkout, including anything downloaded for a workaround, even if you never intended to keep it.
info-collector-SKILL.md: |
---
name: info-collector
description: Gathers and summarizes information on a topic from the web without judging or confirming any particular approach. Use standalone when you need raw research/context on a subject, not a verdict on a specific plan (that's the investigator skill).
allowed-tools: Read Bash
---
**Persona:** You are a research assistant. Your job is to gather relevant information on a topic and summarize it neutrally — you are not asked to approve, reject, or recommend anything, just to collect and organize what's out there.
**Thinking mode:** Medium — breadth of coverage matters more than deep verification here (that's the investigator skill's job).
**Modes:**
- **Collect mode** (default) — use the `brave-search` skill (`curl` against the Brave Search API) with varied queries to cover the topic from multiple angles, then produce a structured summary: topic areas found, key facts, and links to sources for each. Do not editorialize about which approach is "right" — that's out of scope for this skill.
- If asked to write the summary to a file, write it and report the path; otherwise return it directly in your response.
investigator-SKILL.md: |
---
name: investigator
description: Reads an existing PLAN.md and confirms its approach against real, current sources via web search, appending findings and flags. Use to sanity-check a plan before implementation, or standalone to verify a claimed approach is actually correct.
allowed-tools: Read Bash
---
Check whether the plan's claims about the real world are actually true right now. Cite sources; don't assert without one.
- Read `PLAN.md` and the spec docs on disk. For each claim depending on external facts (a library's current API, a service's behavior), use `brave-search` to confirm or refute it. Append a `## Investigation` section to `PLAN.md`: each claim, its source(s), PASS/FLAG. Commit: `git add PLAN.md && git commit -m "investigate: confirm plan against sources"`.
- No `PLAN.md`? Just answer the question asked, citing sources.
Flag unconfirmed/contradicted claims rather than silently fixing them — that decision belongs to whoever reads the flag next.
**Never download anything into the repo checkout.** Need to inspect a dependency's real source/docs? Fetch into `/tmp/`, not the repo tree — a later `git add -A` sweep commits whatever's sitting in the checkout, staged or not.
judge-SKILL.md: |
---
name: judge
description: LLM-as-judge. Reviews a git diff against PLAN.md and the original spec, and returns a PASS/FAIL verdict with rationale. Use as the final review/report stage of a spec-to-push pipeline (the implementer stage already pushed; this reports on what shipped), or standalone to review any diff against stated criteria.
allowed-tools: Read Bash
---
Independent reviewer. Judge whether the implementation satisfies the plan and spec, on the evidence in front of you — not on how confident the commit messages sound. Don't rubber-stamp.
- Run `git diff <base-branch>...HEAD` to see exactly what changed. Compare against `PLAN.md`'s steps and the spec docs. Does every step have a corresponding change? Does the diff contradict any investigator flag? Anything obviously broken on inspection?
- **UI/frontend changes:** a diff that reads correctly can still render broken. Start the app and use `npx playwright` via Bash to actually look — screenshot the affected view, click through the golden path the plan/spec describes. FAIL on a visual defect the diff alone wouldn't show (broken layout, a control that doesn't do what its code claims, a state the plan promised that never renders). Doesn't apply to non-UI work — use judgment.
- FAIL on DRY/SOLID violations (duplicated logic that should reuse existing code, mixed-responsibility units) and on anything committed that doesn't belong in source control (build artifacts, vendored dependencies, secrets, scratch files) — name the specific file/lines in your rationale.
- No `PLAN.md`/base given? Review whatever diff/criteria are in the task directly.
You MUST end your final message with a literal verdict line, exactly one of:
```
VERDICT: PASS
```
```
VERDICT: FAIL
```
followed by your rationale. The pipeline driver parses this exact line mechanically to record the outcome — omitting it or rephrasing it breaks the pipeline.
planner-SKILL.md: |
---
name: planner
description: Clones a target repo/branch, reads its markdown specs, and writes a verifiable step-by-step implementation plan (PLAN.md). Use as the first stage of a spec-to-PR pipeline, or standalone when asked to plan out a task before implementing it.
allowed-tools: Read Grep Find Ls Write Bash
---
Plan a concrete, verifiable implementation. Don't implement — that's `implementer`'s job, after `investigator` confirms.
- If cwd is empty: `git clone` the given repo/branch first (only at pipeline start).
- Read every markdown spec file for the task. Decompose into a numbered list of concrete steps, each naming the files/areas it touches and how to verify it's done. State assumptions explicitly; if ambiguous, pick the literal reading and note the ambiguity.
- Write the plan to exactly `./PLAN.md` in the repo root — not `tasks/PLAN.md`, not `plans/<name>.md`, not any other name or location. Every later stage looks for the plan at that exact path. Commit: `git add PLAN.md && git commit -m "plan: <summary>"`.
Don't touch any other file.
resolver-SKILL.md: |
---
name: resolver
description: Diagnoses why a pipeline stage crashed (nonzero exit, not a semantic pass/fail) and decides whether it's safe to retry. Invoked by the pipeline driver when a planner/investigator/implementer/judge stage process fails to run to completion.
allowed-tools: Read Bash
---
**Persona:** You are an incident triager, not a fixer. A pipeline stage stopped running — your job is to look at what's on disk and what the failed stage's own output said, figure out why, and decide whether re-running that stage is likely to succeed or would just fail the same way again.
**Thinking mode:** Medium — this is triage (root cause + retry/no-retry judgment), not deep design work.
**Modes:**
- **Triage mode** (default) — read the failed stage's name and its last stdout/stderr tail (given in the task). Check the working directory's current state (`git status`, `git log -1`) to see what, if anything, that stage managed to do before stopping. Distinguish transient causes (network blip, a flaky command, an interrupted git operation left in a bad-but-fixable state) from structural ones (the plan itself is broken, a required tool/credential is missing, the repo is in a state no retry will fix).
You MUST end your final message with a literal resolution line, exactly one of:
```
RESOLUTION: RETRY
```
```
RESOLUTION: ABORT
```
followed by your rationale. The pipeline driver parses this exact line mechanically and retries the failed stage **at most once** regardless of what you recommend a second time — don't assume unlimited retries. If the working directory is left in a broken state that a retry needs cleaned up first (e.g. a half-finished `git` operation), say so and do that cleanup yourself (via `bash`) before recommending `RETRY`.
kind: ConfigMap
metadata:
name: pi-skills
namespace: agent-pod
+12
View File
@@ -0,0 +1,12 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: agent-pod-ssh-config
namespace: agent-pod
data:
config: |
Host git.riotpiao.com
IdentityFile /root/.ssh/id_forgejo
Port 2222
User git
StrictHostKeyChecking accept-new
+83
View File
@@ -0,0 +1,83 @@
# API Auth Layer — Authentik service account + Kong JWT (model invoke)
Protect the model API (`api.riotpiao.com/*`, Kong OSS 3.9) so only an Authentik
service account holding a valid **client_credentials** JWT can invoke the KServe
models. "Invoke role" = **possession of a JWT from the dedicated model-invoke
OAuth2 provider** (only the service account can obtain one).
## Flow
```
service account ── client_credentials ──▶ Authentik token endpoint
(client_id + secret) https://authentik.riotpiao.com/application/o/token/
▼ RS256 JWT (iss = https://authentik.riotpiao.com/application/o/model-invoke/)
client ── Authorization: Bearer <jwt> ──▶ Kong (api.riotpiao.com/*)
jwt plugin: verify RS256 sig via Authentik JWKS,
check iss/exp → map to KongConsumer → allow
KServe model (reasoning / ornith / ...)
```
Kong OSS has no enterprise `openid-connect` plugin, so we use the built-in
**`jwt`** plugin: it validates an RS256 signature against a public key we pin on
a KongConsumer, keyed by the token's `iss`.
## Changes
### 1. Authentik (k8s/infra/iam/scripts/authentik-provision.py)
- New **service account** user `model-invoker` (type `service_account`, no
password; Authentik issues an app-password/token for M2M).
- New **OAuth2 provider + application** `model-invoke`:
- `client_type: confidential`, `grant_types: ["client_credentials"]`
- signing key = existing RS256 keypair (same as other providers)
- mappings: `openid` (+ optionally a static `invoke` scope) — no user scopes
needed for M2M.
- Client secret written to k8s Secret `api/model-invoke-oidc`
(keys `client-id`, `client-secret`), labelled for whoever consumes it.
- Bind the service account so it (and only it) can use the provider.
### 2. Kong (k8s/apps/api/, new file `model-auth.yaml`)
- **KongConsumer** `model-invoker` (ns api).
- **`jwt` credential** on that consumer (a Secret of type
`konghq.com/v1/credential`):
- `algorithm: RS256`
- `key` = the token `iss``https://authentik.riotpiao.com/application/o/model-invoke/`
- `rsa_public_key` = the PEM public key of Authentik's `model-invoke` signing
cert (fetched from Authentik JWKS / cert, stored in git or ksops).
- **KongPlugin** `jwt-auth` (`plugin: jwt`, `config.claims_to_verify: [exp]`).
### 3. Wire onto model routes (k8s/apps/api/llm-routes.yaml)
- Add `jwt-auth` to each model Ingress's `konghq.com/plugins` annotation
(currently e.g. `llm-rewrite-reasoning`) → becomes
`llm-rewrite-reasoning,jwt-auth`.
- Leave `/models` list route open OR protect too (decision).
## Client usage (after build)
```bash
TOKEN=$(curl -s https://authentik.riotpiao.com/application/o/token/ \
-d grant_type=client_credentials \
-d client_id=model-invoke \
-d client_secret=<secret> \
-d scope=openid | jq -r .access_token)
curl https://api.riotpiao.com/v1/chat/completions \
-H "Authorization: Bearer $TOKEN" -d '{...}'
```
## Test plan
1. No token → Kong returns 401.
2. Valid client_credentials token → 200, model responds.
3. Expired/garbage token → 401.
4. Confirm the `/models` route behaviour matches the decision.
## Open items / risks
- Authentik `client_credentials` for a *service account* may require an
**app-password / JWT-assertion** flow rather than plain client_secret POST —
verify Authentik 2026.x M2M exactly (client_credentials with client_secret vs
the SA token). Adjust step 1 accordingly before wiring Kong.
- Pinning `rsa_public_key`: Authentik key rotation would break it — document a
rotation runbook, or have the provision script re-export the cert PEM into the
Kong credential on each run (keeps them in sync, same idea as ksops secrets).
- Kong `jwt` maps token→consumer by the `iss`=`key` match; ensure the provider's
issuer is stable.
+43
View File
@@ -0,0 +1,43 @@
# Edge route for the API gateway.
#
# Lives here rather than in the central k8s/bootstrap/ingress/ingress.yaml
# because that Application syncs in wave 1, before namespace `api` exists.
#
# nginx terminates TLS with the wildcard *.riotpiao.com cert (served as its
# default-ssl-certificate, so no per-rule `tls:` block is needed) and forwards
# plain HTTP to kong-proxy. Kong then does the real routing, from Ingresses
# carrying `ingressClassName: kong`.
#
# Catch-all `/` on purpose: everything under this host belongs to Kong. Listing
# per-API paths here would duplicate Kong's routing table inside nginx, and the
# two copies would drift.
#
# In-cluster callers should prefer http://kong-proxy.api.svc.cluster.local
# directly. Resolving api.riotpiao.com sends them out to nginx and back in,
# which is a pointless hairpin unless they need TLS or the public hostname.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: api
namespace: api
annotations:
# An API gateway carries streaming responses (SSE, gRPC-web, LLM token
# streams). nginx's 60s default read timeout and its response buffering
# would truncate or stall those.
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
nginx.ingress.kubernetes.io/proxy-body-size: "0"
spec:
ingressClassName: nginx
rules:
- host: api.riotpiao.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: kong-proxy
port:
number: 80
+19
View File
@@ -0,0 +1,19 @@
# Cluster-wide Kong Prometheus plugin -- `global: "true"` label makes the
# ingress controller apply it to every route on this Kong instance, so all
# five LLM routes (ornith/reasoning/qwen/embeddings/rerank) get RED metrics
# without touching llm-routes.yaml. Scraped via kong-values.yaml's
# serviceMonitor (status listener, already on by chart default at :8100).
apiVersion: configuration.konghq.com/v1
kind: KongClusterPlugin
metadata:
name: prometheus
annotations:
kubernetes.io/ingress.class: kong
labels:
global: "true"
plugin: prometheus
config:
status_code_metrics: true
latency_metrics: true
bandwidth_metrics: true
upstream_health_metrics: true
+140
View File
@@ -0,0 +1,140 @@
# Kong Gateway — cluster-internal API gateway (namespace `api`).
#
# Chart: kong/kong 3.4.1 (appVersion 3.9). Only overrides are listed; every key
# here was checked against `helm show values kong/kong --version 3.4.1`, because
# Helm silently ignores unknown keys — a typo is a no-op, not an error.
#
# ── Topology ────────────────────────────────────────────────────────────────
# external: client -> nginx (TLS, wildcard *.riotpiao.com) -> kong-proxy:80
# internal: pod -> kong-proxy.api.svc.cluster.local:80
#
# nginx stays the single edge and the only LoadBalancer (192.168.1.160). Kong is
# the policy/routing layer behind it, so it needs no LB IP and no TLS of its own
# — hence ClusterIP and proxy.tls disabled. Giving Kong its own IP from
# homelab-pool would mean duplicating cert-manager wiring and diverging from the
# CoreDNS convention that sends every *.riotpiao.com host to nginx.
#
# ── Routing model ───────────────────────────────────────────────────────────
# Consumers publish an Ingress with `ingressClassName: kong`; the controller
# turns it into a Kong route. `nginx` remains the default IngressClass, so this
# is strictly opt-in and no existing Ingress changes behaviour.
# Without this the release name is prefixed onto everything (`kong-kong-proxy`).
# Pinning it keeps the Service name stable and independent of the release name,
# which matters because the nginx Ingress in k8s/bootstrap/ingress/ingress.yaml
# references it by name.
fullnameOverride: kong
# Two replicas so a node drain or rollout doesn't take the gateway down. Kong is
# stateless in DB-less mode, so replicas are pure redundancy.
replicaCount: 2
# Opt in to the `llm-serving-default-deny` NetworkPolicy, which admits port 8080
# only from pods carrying this label. That policy is a compensating control, not
# hygiene: vLLM v0.11.0 is frozen on Volta and will never receive patches for
# several remote/unauthenticated advisories, so it must not be broadly reachable.
#
# Without this label Cilium DROPS the packets rather than refusing them, so the
# symptom is a request that hangs until the client's timeout — not a connection
# error. /v1/models still worked while this was missing, because
# request-termination answers inside Kong and never touches an upstream.
podLabels:
llm-client: "true"
env:
# DB-less. Config comes from Kubernetes objects via the ingress controller, so
# git stays the source of truth. A Postgres-backed Kong would put live routing
# config in a database mutated through the Admin API — state outside git, plus
# migration Jobs on every upgrade.
database: "off"
# `nginx_proxy_<directive>` injects a directive into the proxy location block;
# this renders `proxy_buffering off;`.
#
# Required for LLM streaming. With buffering on (the default) nginx accumulates
# the upstream response before forwarding, so an SSE stream from
# `"stream": true` arrives in lumps or stalls until the generation finishes —
# which defeats the point of streaming. The matching setting is already on the
# nginx Ingress in ingress.yaml; both hops have to be unbuffered or the
# buffered one dominates.
nginx_proxy_proxy_buffering: "off"
# Any plugin that rewrites the request body — request-transformer on the
# llm-chat-* routes — reads it through `kong.request.get_body()`, and that
# returns nothing once nginx has spilled the body past
# client_body_buffer_size into a temp file. The plugin then re-serializes a
# body with no `messages`, and the upstream answers
# HTTP 400 {"error":{"message":"[] is too short - 'messages'"}}
# Measured on /v1/ornith/chat/completions: 10588 B -> 200, 11088 B -> 400.
# An agent request carrying tool schemas clears that in one turn, so the
# buffer has to hold a whole conversation, not a chat message.
nginx_http_client_body_buffer_size: "16m"
nginx_http_client_max_body_size: "16m"
ingressController:
enabled: true
ingressClass: kong
# The chart's ingress-class template is gated on
# `.Capabilities.APIVersions.Has "networking.k8s.io/v1/IngressClass"`, so a
# bare `helm template` renders nothing. ArgoCD passes --api-versions from the
# live cluster, so it does render there — verify `kubectl get ingressclass
# kong` after the first sync rather than assuming it.
createIngressClass: true
# Deliberately empty: setting is-default-class here would hijack every Ingress
# in the cluster that omits ingressClassName. nginx keeps that role.
ingressClassAnnotations: {}
proxy:
enabled: true
# Chart default is LoadBalancer, which would claim an IP from homelab-pool.
type: ClusterIP
http:
enabled: true
servicePort: 80
containerPort: 8000
# nginx already terminated TLS; a second handshake to the same cluster buys
# nothing and would need Kong to hold its own certificate.
tls:
enabled: false
# No Service for the Admin API. The controller reaches it over localhost inside
# the pod, so exposing it would only create an unauthenticated write path to the
# gateway's entire configuration.
admin:
enabled: false
# Kong Manager UI — chart default is `enabled: true` with type NodePort, which
# would open a port on every node. Not wanted.
manager:
enabled: false
resources:
requests:
cpu: 200m
memory: 256Mi
limits:
cpu: "2"
memory: 1Gi
podDisruptionBudget:
enabled: true
minAvailable: 1
# Status listener (metrics/health) is on by default at :8100 (chart default,
# verified via `helm show values`). This just wires the ServiceMonitor the
# chart already knows how to generate for it, so kong_http_requests_total /
# kong_latency_* / kong_bandwidth_bytes land in Prometheus. Paired with the
# cluster-wide `prometheus` KongClusterPlugin in kong-metrics.yaml.
serviceMonitor:
enabled: true
labels:
release: kube-prometheus-stack
# Spread the two replicas across nodes; `ScheduleAnyway` so a single-node
# situation degrades to co-location instead of leaving a pod Pending.
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: kong
app.kubernetes.io/instance: kong
+14
View File
@@ -0,0 +1,14 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Explicit allowlist so kong-values.yaml in this directory is NOT treated as a
# manifest — it is Helm input consumed by the chart source of the `kong`
# Application, not a Kubernetes object. Anything new added here must be listed
# or it is silently dropped with no error and no drift shown.
resources:
- ingress.yaml
- kong-metrics.yaml
- llm-routes.yaml
- model-auth.yaml
# No top-level `namespace:` transformer on purpose: ingress.yaml sets its own
# namespace, and the transformer rewrites metadata.namespace on every resource
# it builds, which is a trap for anything cross-namespace added later.
+288
View File
@@ -0,0 +1,288 @@
# LLM API surface on the Kong gateway — DeepSeek/OpenAI-shaped.
#
# These live in namespace `llm-serving`, not `api`, because a Kubernetes Ingress
# can only reference a Service in its own namespace and the predictor Services
# are there. The Kong ingress controller watches all namespaces, so the routes
# still land on the gateway. They are synced by the `kong` Application (which
# has a `path: k8s/apps/api` source) so all gateway config stays in one place.
#
# ── Model -> upstream map (verified live) ───────────────────────────────────
# reasoning -> reasoning-predictor vLLM, DeepSeek-R1-Distill-32B, 2 replicas
# ornith:35b -> ornith-predictor Ollama, 2 replicas (retired verifier-
# qwen2.5:3b-instruct -> ornith-predictor Ollama predictor's vLLM PRM slot to get
# the 2nd GPU) -- k8s Service load-balances
# across both, each replica loads both
# models, so 2 concurrent implementer-style
# calls each land on an independent instance
# nomic-embed-text-v2 -> embeddings-predictor TEI
# bge-reranker-base -> reranker-predictor TEI
#
# ── Why path-per-model, and why the body is rewritten ───────────────────────
# Kong matches routes on host, path, method and headers — never on the request
# body. So a single /v1/chat/completions endpoint that dispatches on the body's
# `model` field is not expressible in Kong OSS (`ai-proxy-advanced`, which does
# multi-target model routing, is Enterprise-only).
#
# Hence the model is in the path, and each chat route force-overwrites `model`
# in the body regardless, so a client calling /v1/qwen/... with some other
# `model` value in the body can't silently get routed to the wrong weights.
# Callers may omit `model` entirely.
#
# ── Timeouts ───────────────────────────────────────────────────────────────
# Kong's upstream timeouts default to 60000ms. A 32B model generating a long
# answer on a Volta GPU routinely exceeds that, and the client would see a
# 504 mid-generation. Raised to 1h on every LLM route. Values are milliseconds.
# ── GET /v1/models ──────────────────────────────────────────────────────────
# Served entirely by Kong via request-termination: the plugin short-circuits in
# the access phase, so the backend below is never contacted. It only exists
# because an Ingress rule requires a backend.
#
# The list is static, which means it can drift from what the engines actually
# serve — notably if the Ollama pull list in the ornith InferenceService
# changes. Verify with:
# curl -s $SVC/v1/models (against each *-predictor)
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: llm-models-list
namespace: llm-serving
plugin: request-termination
config:
status_code: 200
content_type: application/json
body: |
{"object":"list","data":[
{"id":"reasoning","object":"model","owned_by":"homelab","created":0},
{"id":"ornith:35b","object":"model","owned_by":"homelab","created":0},
{"id":"qwen2.5:3b-instruct","object":"model","owned_by":"homelab","created":0},
{"id":"nomic-ai/nomic-embed-text-v2-moe","object":"model","owned_by":"homelab","created":0},
{"id":"BAAI/bge-reranker-base","object":"model","owned_by":"homelab","created":0}
]}
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-models
namespace: llm-serving
annotations:
konghq.com/plugins: llm-models-list # model-key-auth stripped -- see model-auth.yaml
konghq.com/strip-path: "false"
konghq.com/methods: "GET"
spec:
ingressClassName: kong
rules:
- host: api.riotpiao.com
http:
paths:
- path: /v1/models
pathType: Exact
backend:
# Never actually called — request-termination answers first.
service:
name: reasoning-predictor
port:
number: 80
---
# ── POST /v1/reasoning/chat/completions ─────────────────────────────────────
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: llm-rewrite-reasoning
namespace: llm-serving
plugin: request-transformer
config:
# `add` only applies when the field is absent, `replace` only when present.
# Both are needed to force the value in either case.
add:
body:
- "model:reasoning"
replace:
body:
- "model:reasoning"
# The model lives in the path for routing; the upstream still expects the
# canonical OpenAI path.
uri: /v1/chat/completions
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-chat-reasoning
namespace: llm-serving
annotations:
konghq.com/plugins: llm-rewrite-reasoning # model-key-auth stripped -- see model-auth.yaml
konghq.com/strip-path: "false"
konghq.com/methods: "POST"
konghq.com/connect-timeout: "10000"
konghq.com/read-timeout: "3600000"
konghq.com/write-timeout: "3600000"
spec:
ingressClassName: kong
rules:
- host: api.riotpiao.com
http:
paths:
- path: /v1/reasoning/chat/completions
pathType: Prefix
backend:
service:
name: reasoning-predictor
port:
number: 80
---
# ── POST /v1/ornith/chat/completions ────────────────────────────────────────
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: llm-rewrite-ornith
namespace: llm-serving
plugin: request-transformer
config:
add:
body:
- "model:ornith:35b"
replace:
body:
- "model:ornith:35b"
uri: /v1/chat/completions
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-chat-ornith
namespace: llm-serving
annotations:
konghq.com/plugins: llm-rewrite-ornith # model-key-auth stripped -- see model-auth.yaml
konghq.com/strip-path: "false"
konghq.com/methods: "POST"
konghq.com/connect-timeout: "10000"
konghq.com/read-timeout: "3600000"
konghq.com/write-timeout: "3600000"
spec:
ingressClassName: kong
rules:
- host: api.riotpiao.com
http:
paths:
- path: /v1/ornith/chat/completions
pathType: Prefix
backend:
service:
name: ornith-predictor
port:
number: 80
---
# ── POST /v1/qwen/chat/completions ──────────────────────────────────────────
# Same upstream pod as ornith — only the forced body `model` differs. Both stay
# resident because the engine runs with OLLAMA_MAX_LOADED_MODELS=2 and
# OLLAMA_KEEP_ALIVE=-1, so this does not trigger a model swap per request.
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: llm-rewrite-qwen
namespace: llm-serving
plugin: request-transformer
config:
add:
body:
- "model:qwen2.5:3b-instruct"
replace:
body:
- "model:qwen2.5:3b-instruct"
uri: /v1/chat/completions
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-chat-qwen
namespace: llm-serving
annotations:
konghq.com/plugins: llm-rewrite-qwen # model-key-auth stripped -- see model-auth.yaml
konghq.com/strip-path: "false"
konghq.com/methods: "POST"
konghq.com/connect-timeout: "10000"
konghq.com/read-timeout: "3600000"
konghq.com/write-timeout: "3600000"
spec:
ingressClassName: kong
rules:
- host: api.riotpiao.com
http:
paths:
- path: /v1/qwen/chat/completions
pathType: Prefix
backend:
service:
name: ornith-predictor
port:
number: 80
---
# ── POST /v1/embeddings ─────────────────────────────────────────────────────
# No path-per-model and no rewrite: there is exactly one embeddings backend, so
# there is nothing to disambiguate, and TEI already serves the canonical
# OpenAI path (verified: /v1/embeddings returns 405 to GET, i.e. it exists).
# That makes an OpenAI SDK a drop-in here.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-embeddings
namespace: llm-serving
annotations:
konghq.com/strip-path: "false"
konghq.com/methods: "POST"
konghq.com/connect-timeout: "10000"
konghq.com/read-timeout: "600000"
konghq.com/write-timeout: "600000"
spec:
ingressClassName: kong
rules:
- host: api.riotpiao.com
http:
paths:
- path: /v1/embeddings
pathType: Prefix
backend:
service:
name: embeddings-predictor
port:
number: 80
---
# ── POST /v1/rerank ─────────────────────────────────────────────────────────
# Rerank is not part of the OpenAI spec, and TEI serves it at /rerank — probing
# /v1/rerank returned 404 while /rerank returned 405, so this one genuinely
# needs the rewrite that embeddings does not.
apiVersion: configuration.konghq.com/v1
kind: KongPlugin
metadata:
name: llm-rewrite-rerank
namespace: llm-serving
plugin: request-transformer
config:
replace:
uri: /rerank
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: llm-rerank
namespace: llm-serving
annotations:
konghq.com/plugins: llm-rewrite-rerank # model-key-auth stripped -- see model-auth.yaml
konghq.com/strip-path: "false"
konghq.com/methods: "POST"
konghq.com/connect-timeout: "10000"
konghq.com/read-timeout: "600000"
konghq.com/write-timeout: "600000"
spec:
ingressClassName: kong
rules:
- host: api.riotpiao.com
http:
paths:
- path: /v1/rerank
pathType: Prefix
backend:
service:
name: reranker-predictor
port:
number: 80
+45
View File
@@ -0,0 +1,45 @@
# API auth layer — Kong key-auth on the model routes.
#
# TEMPORARILY RETIRED: verified live that Kong's key-auth here does not accept
# `Authorization: Bearer <key>` the way the comment below used to claim — a
# raw `apikey: <key>` header succeeds (200), the same request with only
# `Authorization: Bearer <key>` fails (401). No OpenAI-SDK-compatible client
# (pi included) sends a raw apikey header or lets you customize the header
# name, so every such client was hard-blocked. The KongPlugin below is
# commented out and every route's `konghq.com/plugins` annotation in
# llm-routes.yaml has `model-key-auth` stripped, so the model routes are
# unauthenticated for now. Re-enable once there's a Bearer-compatible fix
# (e.g. a request-transformer that copies the Bearer token into an `apikey`
# header before key-auth runs) — do not just uncomment this as-is, that
# reintroduces the exact block every real client hits.
#
# The key itself lives in the ksops-managed Secret model-invoke-apikey
# (labelled konghq.com/credential: key-auth) and is bound to the KongConsumer
# below, which stays defined (harmless without the plugin) so re-enabling
# later is a two-line uncomment instead of a rebuild.
---
apiVersion: configuration.konghq.com/v1
kind: KongConsumer
metadata:
name: model-invoker
namespace: api
annotations:
kubernetes.io/ingress.class: kong
username: model-invoker
credentials:
- model-invoke-apikey
# ---
# apiVersion: configuration.konghq.com/v1
# kind: KongPlugin
# metadata:
# name: model-key-auth
# namespace: llm-serving
# plugin: key-auth
# config:
# key_names:
# - apikey
# - authorization
# key_in_header: true
# key_in_query: false
# key_in_body: false
# hide_credentials: true
@@ -1,17 +0,0 @@
cloudflared:
tunnelToken: ENC[AES256_GCM,data:GrZNC75M1T304m+MBbgcL9Wa6VD3Cc+joERjUWD7PsI5NlqFpVuorzub4OTaiE/F0sqDXtXdSwQP9jGTkgUnNbviUVEIBDb+zuiyzCfzuhc53oMvoUYRkRNeH5DR9TBEszspj8+mjQMDAKQFKtTmyDodgh8DdPg8LE8YTCqyx6CcpkGZ8yWY06VoKpZfFSOan/gHwRwG2500P8U+rzI676EKVUhWjBFP0iTbwDQR7spI7oz8Gon/0Q==,iv:O3i0v+M5L3i9O7SbBtDAJe5IsQDgw+alI0Y9arZNojs=,tag:/T63FivzvbLFaXEaBsn5FA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBpU2tjOGpJNk9leE9LSnlx
b2tVbFFMMkZ3OHZuU0VhcUp5Tjl4ZDh2R1U4CjFFMURDWFJtN1lETUNpSmEwbU9u
aEIxUW5qWTJGTnlobjV0emlpY00rM28KLS0tIFd0cllZeXFodFhJNTJMNkRLNyt1
cXVyNnM4Y2pXeUFTYzU1OXlaOXR2RWcKp7/M/YFfpJg4L6a0AcYTV3Rza+bzaOeD
OUIwyns8ZsPtU8ILbRYUUdD2EJFiOPnWP4yX70Ak10v12gfB7vRJ6A==
-----END AGE ENCRYPTED FILE-----
recipient: age1smu533f803gmd0jq60s2zaj9zlznajy0ca6rtewd4r37mr2hs3uqsrldfh
lastmodified: "2026-07-20T17:49:35Z"
mac: ENC[AES256_GCM,data:MhuuuInJjGbzoftxVqMZUov1tJpOt5Vb8GwTr0R12hRCVHwdV+cuC1YeXTQidYaduO7neGnYe0p8ESLEpyY06j4nSpCvGRPPaxTeSeA8IcIk71xNtA1X0FVPv51s59rpvVSjiMDCqrcOv1aJybfQnBZoc/9bfmCYGqhDkD6sg1U=,iv:ThTjUGYE9GiyIgXS+0KDLYS20RPJjMG9DKyMSoKGk/4=,tag:KqZYps5qSuKPUI4U3Iabmw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
-40
View File
@@ -1,40 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: duckdns-updater
namespace: kube-system
spec:
replicas: 1
selector:
matchLabels:
app: duckdns-updater
template:
metadata:
labels:
app: duckdns-updater
spec:
containers:
- name: updater
image: curlimages/curl:latest
command:
- sh
- -c
- |
while true; do
curl -fsS "https://www.duckdns.org/update?domains=riotpiao&token=${DUCKDNS_TOKEN}&ip="
sleep 300
done
env:
- name: DUCKDNS_TOKEN
valueFrom:
secretKeyRef:
name: duckdns-token
key: token
resources:
requests:
cpu: 5m
memory: 16Mi
limits:
cpu: 50m
memory: 32Mi
restartPolicy: Always
-4
View File
@@ -1,4 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- duckdns-corn.yaml
-95
View File
@@ -1,95 +0,0 @@
# PostSync hook to patch Homarr deployment probes
# Chart v8.23.0 doesn't support probe customization via values
apiVersion: batch/v1
kind: Job
metadata:
name: homarr-fix-probes
namespace: dashboard
annotations:
argocd.argoproj.io/hook: PostSync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
spec:
backoffLimit: 3
template:
spec:
restartPolicy: Never
serviceAccountName: homarr-probe-patcher
containers:
- name: patch
image: python:3.12-alpine
command:
- /bin/sh
- -c
- |
set -e
echo "Installing kubectl..."
wget -q -O /tmp/kubectl https://dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl
chmod +x /tmp/kubectl
echo "Patching Homarr deployment probes..."
/tmp/kubectl -n dashboard patch deployment homarr --type=json -p='[
{
"op": "replace",
"path": "/spec/template/spec/containers/0/livenessProbe/initialDelaySeconds",
"value": 60
},
{
"op": "replace",
"path": "/spec/template/spec/containers/0/livenessProbe/periodSeconds",
"value": 30
},
{
"op": "replace",
"path": "/spec/template/spec/containers/0/livenessProbe/timeoutSeconds",
"value": 5
},
{
"op": "replace",
"path": "/spec/template/spec/containers/0/readinessProbe/initialDelaySeconds",
"value": 45
},
{
"op": "replace",
"path": "/spec/template/spec/containers/0/readinessProbe/periodSeconds",
"value": 15
},
{
"op": "replace",
"path": "/spec/template/spec/containers/0/readinessProbe/timeoutSeconds",
"value": 5
}
]'
echo "✅ Probes patched successfully"
echo " Liveness: 60s initial, 30s period, 5s timeout"
echo " Readiness: 45s initial, 15s period, 5s timeout"
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: homarr-probe-patcher
namespace: dashboard
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: homarr-probe-patcher
namespace: dashboard
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: homarr-probe-patcher
namespace: dashboard
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: homarr-probe-patcher
subjects:
- kind: ServiceAccount
name: homarr-probe-patcher
namespace: dashboard
-23
View File
@@ -1,23 +0,0 @@
apiVersion: ENC[AES256_GCM,data:XZc=,iv:0Lgui0+X2oTlgCX5HEIIihEsGJD0C2+pDIOTCNi4r+g=,tag:U2DPsVJ1gCoZw1YcgmT7UA==,type:str]
kind: ENC[AES256_GCM,data:SRkjGwsv,iv:KRBfS7COsTs/l3Fhyk5FvH7IVc+r/6M6FVOo+kPDe1Q=,tag:113xdFELRoJiU4Kn8BmvPA==,type:str]
metadata:
name: ENC[AES256_GCM,data:stIxzQqOUvpbEANL4aQ=,iv:Q+DCLGtw3wihhdAgEbzYHyQhFTPWFyE2iJKl9mYaA2E=,tag:2XRGRbPQLNUGbA1dZI9xEQ==,type:str]
namespace: ENC[AES256_GCM,data:wIhhcrQi3cDF,iv:gnCCkV7GKXA2HWOOeNUGnpAycf8U7pl6F8Vfo6DhLR4=,tag:xnqvCtUdFPB5D9meJb36GA==,type:str]
type: ENC[AES256_GCM,data:1qUDnlHH,iv:gBaC+6MXvZY/bIpy/cEG/zWwLBaUFMVfxAHa1OnwfnQ=,tag:u9PcCfvA2BydFNQQKws1Og==,type:str]
stringData:
SECRET_ENCRYPTION_KEY: ENC[AES256_GCM,data:b0h8ekf7RIbQ9YTpaXPmR3aeNonk60lgltPmnRe7lVk9wlAdaXkNChINcAsarw9YhfYvfCZfS8U33/QxLGjaYA==,iv:uOmx8w3XPKnoxfasf7gqi/bljxigpLwaAC30c2TroKg=,tag:JphkeY49bQLm6cPjT6g8LA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiUW1BVmEvTnJqa3RuQ3NG
UFg3SE1SclpWd01TZnFOK0trQ1c2YThUQmpRCkdGc3FJSmRCcFIycWcvZXJCbDdj
T1M2cFFUakpOa2FST1k2UHc0a1hCRWMKLS0tIDk0UVdYRUkvTEM4TEVWRElrY3dN
SFU2bkZxUGlDL0JNSnY3UU54Z3BhbFUKnJYwwQkUveNjTF9CveVUBF8RO7QjDnYA
1+COKzvXuCIupvPcS87AtkrOfbfTu3IK78pfxZn04FmMOuAyj0ynzA==
-----END AGE ENCRYPTED FILE-----
recipient: age1smu533f803gmd0jq60s2zaj9zlznajy0ca6rtewd4r37mr2hs3uqsrldfh
lastmodified: "2026-07-22T15:59:29Z"
mac: ENC[AES256_GCM,data:uMmKD4fvu39KZc2lDoDD25g/p/eIGW/CYFIwYXcRFPdbLLLi+iW9ks/2l5tHqzlwHEtlL1RH4HLOQr25k3GAJA2FhVnyVmyZ4N2EnJfic/9XTfef9E8tKVo2cXEgYMCPbpH8sVu2OoaQ9BFw8dTM9ZxuTdGKksGOtGp56I4R8hA=,iv:QFhVAZ1jykTNLPDzqjYf9jkg5PuqYA8+s0Hn57ND3KA=,tag:Zz2qYANQP33izwg+3Ap16A==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
+35 -5
View File
@@ -1,5 +1,22 @@
# Homarr landing page with Authentik SSO
# Probes patched via PostSync hook (chart doesn't support customization)
# 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
@@ -13,10 +30,19 @@ replicaCount: 1
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"
@@ -41,8 +67,12 @@ tolerations:
resources:
requests:
cpu: 100m
memory: 256Mi
cpu: 250m
memory: 384Mi
limits:
cpu: 500m
memory: 512Mi
# 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
+4 -4
View File
@@ -1,7 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: dashboard
resources:
- fix-probes-job.yaml
# PostSync hook to patch Homarr deployment probes
# (homarr-secrets.enc.yaml managed by sops-secrets Application)
# 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: []
+56
View File
@@ -0,0 +1,56 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
annotations:
serving.kserve.io/deploymentMode: RawDeployment
labels:
app.kubernetes.io/name: llm-embeddings
app.kubernetes.io/part-of: llm-serving
name: embeddings
namespace: llm-serving
spec:
predictor:
containers:
- args:
- --model-id=nomic-ai/nomic-embed-text-v2-moe
- --port=8080
- --hostname=0.0.0.0
- --auto-truncate
env:
- name: HUGGINGFACE_HUB_CACHE
value: /mnt/models
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.8.2@sha256:4d632b76bd14cb57044a1ffb0ad48ab0ba4939e705a9a615ccc740658575c26e
name: kserve-container
ports:
- containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
resources:
limits:
cpu: '16'
memory: 8Gi
requests:
cpu: '8'
memory: 4Gi
startupProbe:
failureThreshold: 60
httpGet:
path: /health
port: 8080
periodSeconds: 10
volumeMounts:
- mountPath: /mnt/models
name: models
maxReplicas: 1
minReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
+18
View File
@@ -0,0 +1,18 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Explicit allowlist, matching k8s/apps/api. Anything added to this directory
# and not listed here is silently dropped — no error, no drift shown.
#
# These five were adopted from live state on 2026-08-15; they had been applied
# by hand and carried no ArgoCD ownership. Each was exported and verified with
# `kubectl diff -f <file>` returning empty before the Application below was
# created, so the first sync was a no-op rather than a redeploy. Re-verify that
# way after any edit here: a GPU predictor restart is a weights reload measured
# in tens of seconds, not a rolling update.
resources:
- embeddings.yaml
- ornith.yaml
- reasoning.yaml
- reranker.yaml
# No namespace transformer: every file sets its own, and the transformer would
# rewrite metadata.namespace on anything cross-namespace added later.
+113
View File
@@ -0,0 +1,113 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
annotations:
serving.kserve.io/deploymentMode: RawDeployment
# Kong reads its timeouts from the Kubernetes Service, not the Ingress —
# Ingress annotations configure Route entities (strip-path, methods,
# plugins), these configure the Service entity. They were on
# llm-chat-ornith's Ingress and therefore ignored, leaving Kong's 60s
# default in force. KServe propagates InferenceService annotations to the
# Service it generates, which is how they reach Kong from here.
#
# This was invisible while OLLAMA_KEEP_ALIVE=-1 kept the model resident: no
# request ever waited on a cold load. A pod restart flushes VRAM, and
# loading ornith:35b takes longer than 60s, so the first request after any
# restart returned 504.
konghq.com/connect-timeout: "10000"
konghq.com/read-timeout: "3600000"
konghq.com/write-timeout: "3600000"
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 pull qwen2.5:3b-instruct
ollama run ornith:35b "ok" >/dev/null 2>&1 || true
ollama run qwen2.5:3b-instruct "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: '32768'
- name: OLLAMA_KEEP_ALIVE
value: '-1'
- name: OLLAMA_NUM_PARALLEL
value: '1'
- name: OLLAMA_MAX_LOADED_MODELS
value: '2'
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 && ollama ps 2>/dev/null |
grep -q qwen2.5
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 && ollama ps 2>/dev/null |
grep -q qwen2.5
failureThreshold: 120
periodSeconds: 15
volumeMounts:
- mountPath: /mnt/models
name: models
deploymentStrategy:
type: Recreate
# 2 replicas -- each its own GPU, each loading both ornith:35b and
# qwen2.5:3b-instruct -- so 2 concurrent implementer-style calls each
# get an independent instance instead of contending on one, at the
# cost of judge/qwen traffic still sharing whichever replica an
# implementer call also lands on.
maxReplicas: 2
minReplicas: 2
nodeSelector:
kubernetes.io/hostname: worker-1
runtimeClassName: nvidia
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
+114
View File
@@ -0,0 +1,114 @@
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:
- --model=unsloth/DeepSeek-R1-Distill-Qwen-32B-bnb-4bit
- --served-model-name=reasoning
- --quantization=bitsandbytes
- --dtype=float16
- --kv-cache-dtype=auto
- --tensor-parallel-size=1
- --max-model-len=16384
- --gpu-memory-utilization=0.90
- --max-num-seqs=4
- --enable-chunked-prefill
- --enable-prefix-caching
- --reasoning-parser=deepseek_r1
# Without these, any client sending tool_choice="auto" (pi does, for
# Read/Bash/etc.) gets a 400: "auto" tool choice requires
# --enable-auto-tool-choice and --tool-call-parser to be set.
# deepseek_v3 (matching --reasoning-parser above) 400s here --
# "DeepSeek-V3 Tool parser could not locate tool call start/end
# tokens in the tokenizer" -- this checkpoint is a Qwen2.5-32B base
# distilled on R1 traces, so its tokenizer never got DeepSeek-V3's
# own special tool-call tokens registered even though it kept R1's
# <think> reasoning format. hermes parses tool calls from plain text
# patterns instead of special tokens, so it works against the
# underlying Qwen tokenizer regardless. Verified live: deepseek_v3
# 400s, hermes returns a real tool_calls response.
- --enable-auto-tool-choice
- --tool-call-parser=hermes
# vLLM 0.11.0's native OffloadingConnector -- spills KV cache blocks
# to CPU DRAM instead of discarding them on preemption (max-num-seqs=4
# + max-model-len=16384 means concurrent long sequences compete for
# the same GPU KV space). No extra dependency, built into vLLM core.
# num_cpu_blocks=2000 hung the pod at startup ("Allocating 64 CPU
# tensors..." then nothing -- 64 is this model's layer count, one
# pinned host tensor per layer, each sized for every CPU block; 2000
# was oversized enough to stall pinning that much host memory, likely
# blowing well past the pod's memory limit). Dropped to a small,
# known-safe starting point -- confirm it actually comes up healthy,
# then watch real host memory usage and raise it deliberately rather
# than guessing a round number again. block_size=128 tokens matches
# vLLM's own example.
# Note: 0.11.0 ships the original (fragmented, small-transfer-block)
# version of this connector -- 0.12.0 consolidates KV data into one
# contiguous block per request and is reported an order of magnitude
# faster for this specific feature, so this is a real but not yet
# optimal implementation until the image gets bumped.
- --kv-transfer-config={"kv_connector":"OffloadingConnector","kv_role":"kv_both","kv_connector_extra_config":{"num_cpu_blocks":32,"block_size":128}}
- --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: '1'
requests:
cpu: '8'
memory: 12Gi
nvidia.com/gpu: '1'
startupProbe:
failureThreshold: 80
httpGet:
path: /health
port: 8080
periodSeconds: 15
volumeMounts:
- mountPath: /mnt/models
name: models
- mountPath: /dev/shm
name: shm
deploymentStrategy:
type: Recreate
maxReplicas: 2
minReplicas: 2
nodeSelector:
kubernetes.io/hostname: worker-1
runtimeClassName: nvidia
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
- emptyDir:
medium: Memory
sizeLimit: 2Gi
name: shm
+56
View File
@@ -0,0 +1,56 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
annotations:
serving.kserve.io/deploymentMode: RawDeployment
labels:
app.kubernetes.io/name: llm-reranker
app.kubernetes.io/part-of: llm-serving
name: reranker
namespace: llm-serving
spec:
predictor:
containers:
- args:
- --model-id=BAAI/bge-reranker-base
- --port=8080
- --hostname=0.0.0.0
- --auto-truncate
env:
- name: HUGGINGFACE_HUB_CACHE
value: /mnt/models
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.8.2@sha256:4d632b76bd14cb57044a1ffb0ad48ab0ba4939e705a9a615ccc740658575c26e
name: kserve-container
ports:
- containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
resources:
limits:
cpu: '16'
memory: 8Gi
requests:
cpu: '8'
memory: 4Gi
startupProbe:
failureThreshold: 60
httpGet:
path: /health
port: 8080
periodSeconds: 10
volumeMounts:
- mountPath: /mnt/models
name: models
maxReplicas: 1
minReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
@@ -10,13 +10,22 @@ metadata:
name: queue-operator
rules:
- apiGroups: ["kmsvc.io"]
resources: ["queues", "temporalworkers"]
resources: ["queues"]
verbs: ["get", "list", "watch", "update", "patch"]
- apiGroups: ["kmsvc.io"]
resources: ["queues/status", "temporalworkers/status"]
resources: ["queues/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["kmsvc.io"]
resources: ["queues/finalizers", "temporalworkers/finalizers"]
resources: ["queues/finalizers"]
verbs: ["update"]
- apiGroups: ["kmsvc.io"]
resources: ["temporalworkers"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: ["kmsvc.io"]
resources: ["temporalworkers/status"]
verbs: ["get", "update", "patch"]
- apiGroups: ["kmsvc.io"]
resources: ["temporalworkers/finalizers"]
verbs: ["update"]
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
+5 -4
View File
@@ -33,7 +33,7 @@ tls:
# RWO block volume. 10Gi is generous for config data but cheap on Longhorn.
persistence:
enabled: true
storageClass: "longhorn-wffc"
storageClass: "longhorn"
size: 10Gi
resources:
@@ -52,7 +52,8 @@ tolerations:
operator: Exists
effect: NoSchedule
# Pin to az-a (talos-cp-1) — sole Longhorn storage node. Its RWO PVC can only
# attach there; without this the pod may land on cp-2/cp-3 and fail to mount.
# Pin to az-b (talos-cp-2) — sole Longhorn storage node (dedicated disks).
# Its RWO PVC can only attach there; without this the pod may land on
# cp-1/cp-3 and fail to mount.
nodeSelector:
topology.kubernetes.io/zone: az-a
topology.kubernetes.io/zone: az-b
+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"
@@ -1,18 +0,0 @@
temporal:
oidc_client_id: ENC[AES256_GCM,data:y6eSNJpCNCg=,iv:4n5fIeiVG2JZOCD4yZ4Asm/gWvRl0QNFiRGTR1bs8ws=,tag:liLSr9sadR82UPq6d3gqGA==,type:str]
oidc_client_secret: ENC[AES256_GCM,data:ebk0FVtSfq7JbIT+84cigdLg8zfN+4HTfGxlgqLw6hs1H92SFRHQFXO1l3U=,iv:qxa8aqUVoN31PpzAhs6bNMDCM6yPvOT27urIrM1rfo0=,tag:Vo25X8Znw7eh5DNq1eIJEw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArZ1pyMXZ0Y3RFQVZkcTg5
aENyemRueXpIM0FaUVhMc2ZyVDRyWWlzK1JzCjA0OTZ0aGJDNVVSM29CY1RzQWJL
eVNiSCtMSXdmbEtKeGt1L3NYMy9ibUEKLS0tIGJnU3A4MjVnZ1pCa1lPMnVoM0xo
dWs1cVM5ZCszbXp6eFltRVhGbFc0ajQKyCc8lClnSqWUxhNOr1FDCwn5V7nvjxPN
7kjQpldseaRbsy+TM5sFQ1w6Acmun9uYjzs8PtmTNaayc/AFfspufA==
-----END AGE ENCRYPTED FILE-----
recipient: age1smu533f803gmd0jq60s2zaj9zlznajy0ca6rtewd4r37mr2hs3uqsrldfh
lastmodified: "2026-07-15T23:22:55Z"
mac: ENC[AES256_GCM,data:0YuRxQHfdeKbjJzxHI4iBc7I2KnalNJmybB6bVQxvdRutsxKXW/bu99yqHkD4y/nI66Pi6d7LhrPhhGat3x2c6Drs7QRYVWnZ75thil6yWsOcpH7H1esxEWTP3cpoMzGdS0aF6zwU9H+WDvfm/t1wpeDpJly3eczcPvfbK1joDU=,iv:TgwfpI+ZVLyd2c/PKWsJbCI9yKkADpPPafJ5No0fLks=,tag:nqwQHtZ263CI+fY9iTUAzQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
-42
View File
@@ -1,42 +0,0 @@
# Wave 0 — Nginx Ingress Controller
# Foundational infrastructure required for all ingress resources and ArgoCD UI access.
# Must be wave 0 to ensure LoadBalancer IP is available before other apps deploy.
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: ingress-nginx
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: homelab
revisionHistoryLimit: 3
sources:
- repoURL: https://kubernetes.github.io/ingress-nginx
chart: ingress-nginx
targetRevision: "4.15.1"
helm:
valueFiles:
- $values/k8s/bootstrap/ingress/nginx-values.yaml
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
server: https://kubernetes.default.svc
namespace: ingress-nginx
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
retry:
limit: 3
backoff:
duration: 10s
factor: 2
maxDuration: 3m
+6 -4
View File
@@ -17,10 +17,12 @@ spec:
syncOptions:
- CreateNamespace=true
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: .
plugin:
name: sops-secrets-v1.0
# ksops decrypts every *.enc.yaml here at kustomize-build time (repo-server
# runs `kustomize build --enable-alpha-plugins --enable-exec`). Replaces the
# old sops-secrets-v1.0 CMP whose discover glob silently hijacked kustomize
# rendering of any app whose path contained a *.enc.yaml.
path: k8s/argocd/secrets
destination:
server: https://kubernetes.default.svc
+17 -5
View File
@@ -21,7 +21,7 @@ spec:
helm:
valueFiles:
- $values/k8s/bootstrap/cert-manager/cert-manager-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
@@ -57,6 +57,13 @@ spec:
helm:
values: |
reloader:
# Watch every workload — no per-Deployment reloader annotation needed
# (several charts, e.g. homarr, don't expose Deployment-level
# annotations). reloadOnCreate rolls a workload when a Secret/ConfigMap
# it references is first CREATED, not only updated — so ksops-delivered
# secrets landing after a pod started auto-restart it.
autoReloadAll: true
reloadOnCreate: true
deployment:
tolerations:
- key: node-role.kubernetes.io/control-plane
@@ -86,11 +93,14 @@ spec:
project: homelab
revisionHistoryLimit: 3
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
# A real kustomization.yaml (resources: the 3 issuer/CA files) renders these
# deterministically. The previous directory.include with bare filenames
# rendered EMPTY — ArgoCD's include glob never matched — so this app silently
# tracked 0 resources; its ConfigMaps/Issuers only existed from bootstrap
# kubectl apply, and an automated prune wiped them.
path: k8s/bootstrap/cert-manager
directory:
include: "letsencrypt-issuer.yaml"
destination:
server: https://kubernetes.default.svc
namespace: cert-manager
@@ -98,6 +108,8 @@ spec:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
---
# Consolidated: wildcard-cert + homelab-ingress → ingress-config
# Manages both the wildcard TLS certificate and all Ingress rules.
@@ -115,7 +127,7 @@ spec:
project: homelab
revisionHistoryLimit: 3
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/bootstrap/ingress
destination:
+8 -43
View File
@@ -1,43 +1,8 @@
# Wave 0 — networking policies layered on the Cilium CNI + CoreDNS that the
# cluster bootstrap already installed (substrate). These are raw manifests only.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: cilium-policy
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
targetRevision: main
path: k8s/bootstrap/cilium
destination:
server: https://kubernetes.default.svc
namespace: kube-system
syncPolicy:
automated:
prune: true
selfHeal: true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: coredns-config
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
targetRevision: main
path: k8s/bootstrap/coredns
destination:
server: https://kubernetes.default.svc
namespace: kube-system
syncPolicy:
automated:
prune: true
selfHeal: true
# Wave 0 — networking substrate is Talos-owned (terraform inlineManifests), not
# ArgoCD:
# - CoreDNS Corefile + hostname rewrites -> terraform/files/coredns/Corefile
# - Cilium LB-IPAM pool + L2 announcement -> terraform/files/cilium/*.yaml
# Both were previously ArgoCD apps here whose empty `resources: []`
# kustomizations never actually applied them (live objects came from manual
# kubectl). Managing them from ArgoCD too would let two reconcilers fight. This
# file intentionally defines no Applications now.
+15 -7
View File
@@ -17,7 +17,7 @@ spec:
helm:
valueFiles:
- $values/k8s/infra/minio/minio-operator-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
@@ -41,7 +41,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/infra/minio
destination:
@@ -66,12 +66,20 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/infra/longhorn
destination:
server: https://kubernetes.default.svc
namespace: longhorn-system
# Longhorn writes disk state back into its own Node CRs — the disk key it
# generates, storageReserved, diskType, evictionRequested. Git declares only
# allowScheduling; without this the controller's writes read as drift forever.
ignoreDifferences:
- group: longhorn.io
kind: Node
jsonPointers:
- /spec/disks
syncPolicy:
automated:
prune: true
@@ -94,7 +102,7 @@ spec:
skipCrds: true
valueFiles:
- $values/k8s/infra/monitoring/prometheus-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
@@ -144,7 +152,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/infra/monitoring/crds
destination:
@@ -175,7 +183,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/infra/monitoring
destination:
@@ -205,7 +213,7 @@ spec:
helm:
valueFiles:
- $values/k8s/infra/monitoring/blackbox-exporter-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
+3 -3
View File
@@ -19,7 +19,7 @@ spec:
helm:
valueFiles:
- $values/k8s/infra/logging/loki-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
@@ -53,7 +53,7 @@ spec:
helm:
valueFiles:
- $values/k8s/infra/logging/grafana-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
@@ -87,7 +87,7 @@ spec:
helm:
valueFiles:
- $values/k8s/infra/logging/promtail-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
+4 -4
View File
@@ -17,7 +17,7 @@ spec:
helm:
valueFiles:
- $values/k8s/infra/iam/vault-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
@@ -46,7 +46,7 @@ spec:
helm:
valueFiles:
- $values/k8s/infra/iam/authentik-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
@@ -68,7 +68,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/infra/iam
destination:
@@ -90,7 +90,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/infra/forgejo-runner
destination:
+1 -1
View File
@@ -14,7 +14,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/infra/databases
destination:
+3 -3
View File
@@ -68,7 +68,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/apps/messaging/kafka-cluster
destination:
@@ -89,7 +89,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/apps/messaging/queue-crd
destination:
@@ -110,7 +110,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/apps/messaging/management-service
destination:
+58
View File
@@ -0,0 +1,58 @@
# Wave 7 — Kong, the cluster's internal API gateway (namespace `api`).
#
# Sits between nginx and the backend services: nginx owns the edge and TLS,
# Kong owns routing policy, auth and rate limiting. Wave 7 puts it after the
# data/messaging tiers it fronts and before the wave-8 applications that
# publish routes into it.
#
# DB-less: routing config comes from Kubernetes objects (Ingress with
# `ingressClassName: kong`, plus KongPlugin/KongConsumer CRDs), so git remains
# the source of truth and there are no migration Jobs on upgrade.
#
# CRDs ship in the chart's crds/ directory; ArgoCD applies those by default
# (helm.skipCrds is left false).
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kong
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "7"
spec:
project: homelab
revisionHistoryLimit: 3
sources:
- repoURL: https://charts.konghq.com
chart: kong
targetRevision: "3.4.1"
helm:
valueFiles:
- $values/k8s/apps/api/kong-values.yaml
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
# The nginx Ingress for api.riotpiao.com. Kept in this Application rather
# than the central k8s/bootstrap/ingress/ingress.yaml because that one syncs
# in wave 1, before namespace `api` exists.
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/apps/api
destination:
server: https://kubernetes.default.svc
namespace: api
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
# The chart's CRDs exceed the annotation size limit that client-side
# apply relies on; server-side apply avoids the
# "metadata.annotations: Too long" failure CRDs commonly hit.
- ServerSideApply=true
retry:
limit: 3
backoff:
duration: 10s
factor: 2
maxDuration: 3m
+58
View File
@@ -0,0 +1,58 @@
# Wave 6 — the model servers behind api.riotpiao.com (namespace `llm-serving`).
#
# Syncs before wave 7 (Kong), so the predictor Services exist before the routes
# that point at them. KServe itself is part of the substrate; this Application
# owns only the InferenceServices.
#
# Adopted from live state on 2026-08-15. These five had been `kubectl apply`-ed
# by hand — no ArgoCD ownership, present in no repo — so every change to them
# was drift by definition. Each manifest was exported from the cluster and
# verified with `kubectl diff` returning empty before this file existed; the
# first sync therefore adopted them without restarting anything.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: llm-serving
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "6"
spec:
project: homelab
revisionHistoryLimit: 3
source:
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/apps/llm-serving
destination:
server: https://kubernetes.default.svc
namespace: llm-serving
syncPolicy:
automated:
# `prune: false` here, unlike every other Application in this repo, and it
# is not an oversight.
#
# ArgoCD tracks ownership with the `argocd.argoproj.io/instance` label
# (argocd-cm `application.instanceLabelKey`). KServe copies an
# InferenceService's labels onto the Deployment and Service it generates —
# visible today as `app.kubernetes.io/name` and `part-of` on
# `ornith-predictor`. So once ArgoCD labels an InferenceService, KServe
# propagates that tracking label to children that are not in git, ArgoCD
# reads them as extraneous, prunes them, and KServe recreates them. That
# loop churns GPU pods.
#
# Deleting an InferenceService therefore means deleting the file AND
# removing the object, rather than relying on prune.
prune: false
selfHeal: true
syncOptions:
- CreateNamespace=true
# KServe CRDs are large; server-side apply avoids the
# "metadata.annotations: Too long" failure client-side apply hits, and is
# the correct mode for adopting objects an operator also writes to.
- ServerSideApply=true
retry:
limit: 3
backoff:
duration: 10s
factor: 2
maxDuration: 3m
+41 -11
View File
@@ -1,5 +1,4 @@
# Wave 8 — end-user workloads: Temporal, Portainer, and the two network
# helpers (cloudflared tunnel, duckdns updater) that are already running.
# Wave 8 — end-user workloads: Temporal, Portainer, and the cloudflared tunnel.
# Experimental dirs (llm, forge, dev-tools, shadowsocks) are intentionally
# NOT included yet — add them here once they're production-ready.
# temporal using unified CNPG pattern (app user, temporal-db-app secret)
@@ -20,7 +19,7 @@ spec:
helm:
valueFiles:
- $values/k8s/apps/temporal/temporal-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
@@ -49,7 +48,7 @@ spec:
helm:
valueFiles:
- $values/k8s/apps/portainer/portainer-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
@@ -72,7 +71,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/apps/cloudflared
destination:
@@ -88,19 +87,19 @@ spec:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: duckdns
name: agent-pod
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "8"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/apps/duckdns
path: k8s/apps/agent-pod
destination:
server: https://kubernetes.default.svc
namespace: duckdns
namespace: agent-pod
syncPolicy:
automated:
prune: true
@@ -108,6 +107,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://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/apps/sms
destination:
server: https://kubernetes.default.svc
namespace: sms
syncPolicy:
automated:
prune: true
selfHeal: true
---
# Consolidated: homarr + homarr-patches → homarr
# Helm chart + values + PostSync hook patch (fix-probes-job.yaml)
apiVersion: argoproj.io/v1alpha1
@@ -126,10 +156,10 @@ spec:
helm:
valueFiles:
- $values/k8s/apps/homarr/homarr-values.yaml
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
- repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/apps/homarr # PostSync hook: fix-probes-job.yaml
destination:
+1 -1
View File
@@ -11,7 +11,7 @@ metadata:
spec:
description: Homelab GitOps — single-repo, in-cluster destinations only
sourceRepos:
- https://forgejo.riotpiao.com/riotpiao.com/homelab.git
- https://github.com/Riotpiaole/riotpiao.homelab.com.git
# Public Helm chart repos referenced by k8s/argocd/apps/* and bootstrap/*
- https://cloudnative-pg.github.io/charts
- https://dl.gitea.com/charts/
+1 -1
View File
@@ -12,7 +12,7 @@ metadata:
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao.com/homelab.git
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/argocd/apps
directory:
@@ -0,0 +1,17 @@
cloudflared:
tunnelToken: ENC[AES256_GCM,data:mFqJUuK8bzKnPAk4ZwBSLP7p6IiUCldmFNKSS5P75rKBgrrTrRB24Vqf6W7UwM2w+/CNzkQ0QByNw4/TzjHDgNH1UJAYXlVvm4H8/NPp4WPWlv5YO67l/mAmPVnBEmJzU0KgivCQm3psEYiEfbgiIbujqr0isgtkU856BimYHqtEdBmYRuJ5c73noaV6NWB1aTnEiCOmmNs+NCkN2GswgSi/zJLbd0HJPQT8h8+3rI8L7nC0/HJvYw==,iv:O3i0v+M5L3i9O7SbBtDAJe5IsQDgw+alI0Y9arZNojs=,tag:Tl9f0oVsBi68AyK3QwlHfg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBWlk3V1d1cjFXV3VrMUVN
LzA3NlVKbllHYkdEb2pESEJkQ2RyU3UzOVZnCk1NdGVUSVpHMHNYUGhOdEhoTi81
YVlvU2Q2WmRUdC9KTGlBaUVnU01rbzgKLS0tIEhLNzl2UmpwYjQwNTl1YllnQ09X
Y0ZwR1N3Y2VNQ3VkbkJvRG56RWE3UWMK5tv5dgjKlbHq1Rh4NC0+3b9n1yTE/7vW
TehGG7k18zclBiZD2y5l4/CeDPM/yi5kfbPuxG1kURffw1xUeac+Ig==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:DshqXuhhJo0eLMTJ+HeAEk6JOtzGQ6OyUd7fSIGWHL/rGfDPliDBdTqpWw1899kgF0LRxY87rTTod65KvINp2HHc8KBQKa5MiAoTXQBThlFYIHFDOL2XQve9p6TKzsVDsQrBsl0lGt9V+Ou+YWUGh9kx63me1wAl8aZ9ltwWvZ0=,iv:4exYbWC7f7WGUncT1KcjrTy0r8Ox3YCctShyMiiaw+4=,tag:YMYOvwm2844LC76n3QvEdQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,18 @@
temporal:
oidc_client_id: ENC[AES256_GCM,data:gyzO+wMlDrg=,iv:4n5fIeiVG2JZOCD4yZ4Asm/gWvRl0QNFiRGTR1bs8ws=,tag:MV1b/F5OOihe+K5qlkZnTA==,type:str]
oidc_client_secret: ENC[AES256_GCM,data:ND7XhSln6AVq6Qy465M6x5Hupjp+xZQKnqT+OW1U+bR7CAmsnZNom1wVpm0=,iv:qxa8aqUVoN31PpzAhs6bNMDCM6yPvOT27urIrM1rfo0=,tag:SRoQ7q7Nqn8TZGxmLxKXKg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1MEVQSkE2WmpOanp2cXY4
M3NVNmd0U056bE52dXk5YUNBcE04c0V2SW04Cm1KTDIyc08xRHR4bnU2N1ptbHFM
UXRuOVQzaU1vQStZMG9yRzYyd09Idm8KLS0tIERTNjl1Q1ZtTDlGcGFpdlAwVjhQ
U0VxMTJBRkc1bGlXZkFxZDQ4OWlwYlEKF35/ZOxDMvxhXV4rIhtNdaE7+vQ9JOs4
+ou88d2WnLD9U50fa1RC3+zS/5CwzIAOetfHeVSjPLkO9oVXuGhU3g==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:Gf9u8t515vhogURw7sOb5cAMKeEBEzuX3V6mDP4OtT6bPinyLRwvgp/j5E5lB0ANYggFHXiv4FL8Nb/pAQXxNe0v8rxwuWiTSspJrM2l2JzZ/nLDAfQiyOVUsoYY15HsGzjbmRT5VEQf/9ceSVqS2tAliZj7hH/hDsncc46vWHI=,iv:gOuSf898MMtzW6L2K+Z/yObn+9Bg42JvT0IlhGK+HSs=,tag:2MEPiaSqdedUibMuO/2RSg==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,23 @@
apiVersion: ENC[AES256_GCM,data:zdc=,iv:VvjvrS5PVNAMIaOE0LaWU+tHcUIYVQDnCANQz6myktY=,tag:xGyWDhRCwwiNny7hPllf5g==,type:str]
kind: ENC[AES256_GCM,data:2bf5Zfy8,iv:5Oz423GzUWmgdaaZHbrtedwRHIAPIuLh4iDMieLL05s=,tag:GNW1ROjlzGvO/4tIOSuH3Q==,type:str]
metadata:
name: ENC[AES256_GCM,data:7RO0Qxkc+/sA,iv:wKe9A8d7QJSx/6rlEY5H6lU8V24TJqr5IpXQCBc8QgM=,tag:iF6z1MNEXG3pCz2cQ18gLg==,type:str]
namespace: ENC[AES256_GCM,data:RcduxHHLtjH9,iv:opOVx1lL2ltDqQgsleN7NdMAq0TyFr/YQO3FFsHh5AA=,tag:fIwO31apqIatRRzBvamw6g==,type:str]
type: ENC[AES256_GCM,data:C+JjyZt5,iv:xs49Lz6zRzcf3spiPzdUKTm2HZ+VgFahN6wjIe81JI4=,tag:VllJ54MqU+klA8xAIebjrA==,type:str]
stringData:
models.json: ENC[AES256_GCM,data:FvlbdMkcngJchi0GEjIEDjxXpQjRwh+3xnlZNpApgd3v1SpMd0qD2d3TbQr39+feSfsvpxhdmmyU/PmRHZYVdr/QpIKlZBWBD6qTCaOSq62NQbR4ck336muIaWD4AmahWCvRouYEJdEsnG7nedjAivw4cls18vMfnL5pNm2t96foScSyHosWoh3RdT6CWLmJE39+kS6/fwZ1D3Z/GEm/E2zHkVtEbEw/fk1PiqsnmZWt8QcSRAeX8aIDkphV7wOKlkri11r1NuFQKMVIau07VPce1YEi5rsJqsircvDxQelQiMGQt2y2M4GGlXx2NeJgKEtP4Hf9mVGhTeN3CYLcXi8Qyg7k1GIawGVW/Em0kWWfy0GMjvOMfYozNmIpi0YQdYBbr3j6pkixtVM2dWezvld5QIYLnHoGMxw4M6L4IqVIonC3j6tk9pV1DaL5IFskEXyb6ScTrmVY4mQ+VEXvfLYA38jRauWxx/4qf4I7B0RzfGzOx67UJh2jKj0bPWNFHKt5Dz4CK+ul39d1KUPHHMoyaH5gCpOAfLSkjMm44X1Cn+O6uY+QFCugh9MI7bJg3EbovTCZxz95GCupWAvDshv59f7gpGL0T8AuRZHlNp6yZMLdviR9yf79d2rW26PIM1QuNOUWELrOcTs5IAmyuFYr0PI5W5XSf4klOdz3UdhPfMDcyRqtSzVDsSo9oC/WeWOo1+yurtdqqW0wLiFpU/kTaZ4JN5Kl080twqFEqgyPnJrbmlxwWlt4XN2SV9D7pcfE61FzUTwoi5PD/8xt94Fp3XerSgJtwhQ6X4Neo47wCFoMR4Y69mlHrFgSJA4kLFezDc2ISnyAix0W5to0cjwJqQ2EgonIhCXD4xWTvYeApHJMOC2o4B74K5efOZ7klb6PwDMvzM3LyeGzjoJh47aJGvhWN/MpQZC2lSHT8PdMxdGDc+OkEVmsvMncddWMGjQhkTo+69sUexARxLY4H22TCt58azqQGcWa5e7s9NUHb6bRVKHzXh4HMiIhQ0jBev++Jz5gIOyYRnw5xoGbo3ROv8ndczVAqXyAla23EM/VBWT0hLMjr6xxonuLQacP9dPJE9QUofjRMeYblkVvQdIw8WuKGbAlbTR/bokQwGdVp2bywSFQZj7BLlPjzCr0/LJmxYMdq6CKB049PdTlS9/UKBHApddMQ/QRXfOYUCX+lm4k4J19jd7dWk34WNUrTEPSR2d1U/2wTnMvzlrnQHx/zk4/lmI1newfjIbybIbXypX8xVEqzFWXpxUe+iFLP19KmmmHG7gGsV32k1VSGvYFI0HOokZga98QsAbJGbi1gQq8gYpMR/mBdhjJVcnAjOYpg+YaR0vCyrhBN55aSCDoUbT1JOqxvFqmlBHDxTWorD/3KGFxEH/wvSsxussFmUCWdEgYmjGO1ktnQBw1YpOB7mhDUlzvYCSxfpexsYufDYqTbrb/R5gmATC0pEN6EgTth5YqFOL7AiIsogy14RGjRym1wvjVl5SUU9bMQlWGJbQuvNRRwL/TzR/QP210jMsnAh9t8sncDnFPIbhFgeBLsU/ctYoBs0nFVWGWzcuoP9HTnvdWTT2xKmWAeFz8I3DZ5sjm2B787Q4bCrAszTvVLeLiRw/pOE32BeNfG4nNiZDvMQhUomAKpaQypWTu2wLsv3ISm00gud5sRKB6ASJANuSu+EhG9k4OYcBEzAT9PKUxclupFbmsFnXx0u9CXDj3tErrPCUs7DuFSr+fz0ehCLMH+GrcqfytjNDVoTSBRZg+lJYKjAqVCGvq8uRf5V/rdulbt3ggx/uhGSiPzYrBQ0g9iMdjQqpNGBwjlxGkhRE9D2n/PoerAAnHTfwc9Hezffncostjp09xw2CB/xOx9HZFGalQ1R4F5+0lm0iR4lMzc+se+o38foNIiMeMIwAVBJu7IO++UMuKCxpxmdAGqEYj/Ddpzfi3ZcRwe/1HhanVvKRMJsPOhnv0SB2xBrdb7QHnEzLbRTriVRBBdOI77HXWgDt9uQru0tEXCEQ0n+f0BWqHVa/RLN4Yj0dQVWLXU75ddyHnRaCiVdLQnObMvG/QXqY1k/bJTzGNgVF8/KGY51NgdfqskOXC9Fv5IGiyWq+/DL7tQcrmThkzUhZlwDBmwUzPFmzuUC7Z5PkXv2EV+eUrYF16HYqp5KgqTkBHr/JXR8ljb/5gMN929Jk2fBVEuzaEG6W9Yek+Qbx1cnXYrMBI9GWJUJsMsxsAj+1bAjtQeQwt0nXgZesTaV90dfQYq+nTGXMNjqaMhsrJhR6lU1+CDpwY+IObHSttS8AKB+Czsl0nUB44o1a/zKRDNTCGGmPFuv1Q5Ub/1myxorLDDF1mYDpre5TedygtZ/1YSz/YOpKwmWdW32FsnY+m3qjEAcfV5f923N/z8ENVxVpeiWL6mqf7h6F3qTiaSsFq2O8m8tVm70X+oRdig3hPnKqlK/KoxVmzZErhEJ+KmP5I2uvIlb4PViE6imu8kw4VqMClb7jJ7Yk+2Zy84C8RGuDCN8GCDG7FvAahA9xVGxB6rmEpzvqFBxos2EeLOlyfewf7rCw1JAL3C9hZrciA9Jqb/t8B7KqCmCOsKK7xVt1yqyG1dsa364JG/1SKSMKebTheuymZ1+VnGjRZ+pgSAkQGjCtp7B3fTlhjt+WwxW4Ml27yfjokQQ==,iv:8Z39eWukGSMePh/3Dj35e6Zahejil+eeMSqwMYf3snI=,tag:FyBg1y0IQEg/m0mgpf1ESg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBBZzB5REh6amhCNzRmY0ta
MkN3Y0ZlR29lQ3h2SWo3cW5CUThkL2RnU1NVCmt2ZkhIZTlHN1RQRkFrTjVvbjVw
RGRrTXRoYmdQcnlMSEo3ZWsrZUQ5cHMKLS0tIGU2TGJqZDRxUGJpZzRveEtZankx
MzhrT1R2akxxby9QVzd1RXB0RDY1LzQKOF+/e5z5lPX6Y1sMTAHuDj3YqW1m+sBd
u/0R0YnBonYM3wS5nJE3NZMkImaAdQlUjOzQepfBldG+lz++rlnAww==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-18T20:06:08Z"
mac: ENC[AES256_GCM,data:IM9HkpdwtQE2wCkjwDWOmHH4uP7TlIsrK4TVytiecvYz4SiLk6IRUSIu7I3a+F+dtltC2WtokoATaB69DTXPoI54amzzptirxiFD5FbaU+u2gLjo7KI7V0smYGuKqMYwnod2L/4GdlvP6xjVxFWuA01rQRaYBkFSumS4NABl/I4=,iv:al1MyBmFwni8gap7PPZxWaCwqFKCicfPq6nVmrSn9Xc=,tag:KJGTDQ8xRGF8qMOjoQ+dng==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,23 @@
apiVersion: ENC[AES256_GCM,data:seY=,iv:1q2bg1QBNDDQPYNu1S08MZ+Ix8WLthxObsrqTMJEVhY=,tag:OMh/stn4vVScOEAyviuurw==,type:str]
kind: ENC[AES256_GCM,data:QwqgPMrm,iv:tv6rUcezEgQigvHVw05Mevt6BHDD5AARuoUmQpry3AU=,tag:m9RLNezJsLYGSRWnozfkoQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:ZJtohnEDN2NBFrEhhvGuw4s=,iv:SU5LuA3Lqjh+q7nVxeVVtzeXiOxxVserNno5c7b47lM=,tag:YLprEk7oVjVsIDVANWDPrQ==,type:str]
namespace: ENC[AES256_GCM,data:LieEC+YWJzq+,iv:U00VXejzlpgOYS7Yxix/MnEW0PEvmwOuOXMUi2CWIps=,tag:QvFYZIexWhyvBYTruGMifQ==,type:str]
type: ENC[AES256_GCM,data:U41EbP+H,iv:CKDqec0jfVtbOtEyqQpV9CHJNCwhneYi0wqK8QU2vnY=,tag:CSJ04pkPslevQrpSjhXNHw==,type:str]
stringData:
id_forgejo: ENC[AES256_GCM,data:dgwezImUFb2r6MMZ2V1ZMuy32DAVZ9YMzlr9zYlXKEuzeCaWn29DuqgeP5PJQFFKwd37Y9jmzXwuKD5KrC9WCAzrS2VClxmJYHj2Bf842qtKa4x3SubHu4w6wp8v42wQYCU0Kygc5UgkCFYgOBgvpYCFKhiro6Mg/PBTWTIIgrLlSVwUWPIcaX6Sq4ZW4sy6CZ0H/pEMDAjU8drZ4KmGw7W4FshNynplMuP9ewbSs9yxtuAsKt4ezuUQBGHmAYdPHytC9HUzf67z/j6TF/zg6XgTe0AG116cNddAL8oNermCIkkKuTwb5xy1+3WRjjacC0K5Ire1fV5RyXtD/1JgEHJ8gbPK1w1V5fHZXX/ARvQFlP+uqxFYmPkCHBmK/R6/VvAghI/KYHlc76hdsAe0sd1UrPIHjVEtwgvDQl+4de9L9cHG3Qxj23ajlpISWA3tGADhwzXDxi0KYClckebrCKhizWFQLYXezALYklgY2yghG9jZHufV9jRjYSr9DLtwUmVf3oglAzKkWErNs8dWqEKYFx7HpFf0FiZh,iv:1aX4E+R1oFMm9eI1RENFIYCoatX8BdHo6PhrEGq3rtg=,tag:nwniLOOmpYd3Z6R92SvB3Q==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBvRmtrdkpMOVhRdTVucVlr
QmlENGdDN2J1MUczSmRhNUpwYlBjWXhqbERzCnpLRFl1bVVtSFE3L1l4T3Rwd2ZQ
Z1MxTzJYZWovMmd2RTA3RFJVdVRRTEUKLS0tIDFQWHh2dmQxU1FUZnUxalhTdEd3
c3dUVzg2L2VOWFlmUG9XdFNWN2RvQkkKYqSmFMkDV/T7AOjKYQNJW85gUzFraRre
GhwAuPJ9oNQAhSRa5z2p6ghoUplSXtNZ6H2OzETRfOc4N2cHLljW/Q==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-18T04:18:28Z"
mac: ENC[AES256_GCM,data:ScGMBQqGm31fWDZurmlmk+rnlvyaQkalUcJ6ds/U5JJ2niTf7Llbruuqt2EzMQO09fT2kQqAIYr4cF61Czip6rDhpG8I3H3lBc2mOqUXbeCFyRL6mRpVYPOgkSzgyfXUfe8T++54UhQ0Clv/8Z4XB48oTs9detkUDzAfxP4azRE=,iv:edhsVviyjAm48Blm5CEQ0PBhcWVX97xi2hEbgDX9Dpg=,tag:Xnn85E99LibtB/LWwi5wcA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -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
@@ -0,0 +1,23 @@
apiVersion: ENC[AES256_GCM,data:qhU=,iv:/1G8RSCBaiQ6msQEPPOO4LzlbYOzEbNvT9OwnY0E1JQ=,tag:xO1Nw12oNk6wFCmw0YldEA==,type:str]
kind: ENC[AES256_GCM,data:U/sQsZyV,iv:KMW7WL9ZjTmUz3NOQRhjsuAH2EZFXGk1P4hhl7cp8c0=,tag:0WiWyaaT1hmmVk837dFQfg==,type:str]
metadata:
name: ENC[AES256_GCM,data:tQIaIJGaBzzLqa9vjPVlyhEymNupmKl6yyhx,iv:1qXckUq4RTBZ06YNLXsuMW1UWRK9cgymDdVnmwLZles=,tag:J2e9k7fmTYhhUFzRfkNYuA==,type:str]
namespace: ENC[AES256_GCM,data:bWQZWAI3UY04rdp2,iv:YCv7jIXpkzTx9iu9ScwHme8Nc8iqJr1xn8yXK/OsHk8=,tag:Xq9mX/EUHIOClV/YUm2suw==,type:str]
type: ENC[AES256_GCM,data:lQUZvLVW,iv:iseIGnyGeVE76BORrjX/TnvrfzaRMOFrQ7oWT0jIS9M=,tag:JMGEqkIwlrB25m5E1hBH7g==,type:str]
stringData:
api-token: ENC[AES256_GCM,data:i2P1Qm062lGubNILbbpzX/AWga7s+RhZ3ODsue4oWKZ7xMHvhYaLUG3/t7YEOmu2qxvvdjQ=,iv:uFvlesWdZUbiuh8WTq1uLBRtuptV2tXxGJ6PVgu7vCI=,tag:PXpdMfGR5Yui/e3PGRUWZQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3OE5SY1Q4OFdsSkJ2Q0NK
TmJmdk1RUEZHbS9qa2hPQm1hRzg5UTFKMGpNClRNRlZ6a0hjdDU2UkRHaDJKbmdW
MlZhTFBubm1HcS9MZTltQnNOLzBmNUEKLS0tIDZaSko0VGZOSm9EcFZ2L3V6cHVU
RFJ1bFY4OGJYb3pnMTFTZ1c1NjdoazgKhwqR2KeygYCpTR8u+pmMYzjNj3XyuhKZ
EBXYOrD3nR4JSoULAUHnZto1HwVVf6/SaR9rbg5OunOlCkUdopLNig==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:loYaADf5M4WMJCruMwcBHuF+MtQGYXrYv+MTNjtYRuk0yx4DGoTh2xiHMIZF7S52OCUoX4bn9qKqKR5sZK+XOWxD2hHovf/guo94HDklRqJ7ifDRshTz5wIlav3Zl5GgxUdxea3MmebghPEEm0uHVnvKxVbKBEw5WivCJd8JOyM=,iv:rYFOxaz/Lb0loPNVmXL06BPAl8NzYHn+3Zeoh1YjB+g=,tag:N+qbLLR0YhqJ5xbgTVvLdw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,23 @@
apiVersion: ENC[AES256_GCM,data:Rec=,iv:oFWV5gLi/v/mrA+i6n1hDONVS/iLHTZDDro2l3fYxZw=,tag:zYpAitxXQNc3QcnV0XiLXA==,type:str]
kind: ENC[AES256_GCM,data:LpaVYuc0,iv:Vcmp/GhUhLK2/e39lpAXrzcD9CYstKjEojTThW+tyL8=,tag:yH47tiZkMe7Y7TKW7G+cKg==,type:str]
metadata:
name: ENC[AES256_GCM,data:qHpkh1x88s//GHx3,iv:8aS0Q+HsUdsoMvxfYrz+hU+0ceml2mx44tlwYJOWUDI=,tag:SijqQ++2IN1tYa9M3GS2YA==,type:str]
namespace: ENC[AES256_GCM,data:DYQF9g==,iv:q4XjDTdYp0b6dBaSXHTK86RZVCXHWV8I4tsq3ms5z38=,tag:25hy4cqSumcJajA3DK/7tg==,type:str]
type: ENC[AES256_GCM,data:LG+x1Pzr,iv:ULVYrO9iIoZZCQDRhVF8WaPx31MBj0kXUJpKKgkcbyg=,tag:muo6E5YPTppgK0Vc2yq95w==,type:str]
stringData:
token: ENC[AES256_GCM,data:Hwwo8E9H27nq2A1FTB+Bx3nlQzowMkqIl6ChiXot8zt4l4UDjHXZPQ==,iv:kFiwyo6QZxwAPTXT9L5uXHbLhfULuyUv0eNSUe6eHhA=,tag:bSRKlb/J49Y1dumCphU4iA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3bXptZU9CSXJKMlRJM3lZ
bEI4L01Gb0dpTGR5S1FTeXV6OXRwNTJmY0NzCkYwbjBSaXdjWm9iMUNsV0JscXc5
cXFYNkFLOFlXb0QwSkgvQjZrdS9PSVkKLS0tIE44Nko1cnVrVEN1bWpJajdFMFFG
V05lUHFiSzl1akdCUUVRdkQ2eHZEdmsKYXymkNu6pvIN0DW+3NYc95igGhzmm7MG
hH8IRVLLl1b2cW//wQsqngEnm8+UBtv09Bd7fyvfWbnoXqVAsshCcA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T23:18:57Z"
mac: ENC[AES256_GCM,data:lnokC1HjpIk94/666+PCbkkN57Rk6oDVtl6hbaM928honuyl25AU/dEbDYmz7VnaaigES95T487p0LjL0duePCoiZIigs1EKWwcPmAnfAo7aOZq8ZaOZb582KdjkAjJWVq+Ie9QMQE+AsDeKUAcE6QFdyQIKQyJK0pqL9YdXhxA=,iv:8Wd1340UGur9ABo6oC20yCSFXWwnFiN7I7fa3dg5HWA=,tag:W/pswWyRfvq0OcPh8rqSdQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,26 @@
#ENC[AES256_GCM,data:/JYNMOHrdMY2jobx67MjbJ7Eg8kHrNrkJcU535NRYLvY5nnSryk+asK8nQ==,iv:lM1jBHIxFkQriZ6BjGRXAlymUs5nX4Kvt1WNxUeZlkU=,tag:4pttiLNnIagcN4jTZ7BTwQ==,type:comment]
apiVersion: ENC[AES256_GCM,data:oRQ=,iv:0TzPcIoozs2MXJNXkzgcVtjjBUgfOHaSXQZiD37fb+Q=,tag:I1jwXteT9m2Pvdxq8zNtzg==,type:str]
kind: ENC[AES256_GCM,data:zSxoKLqf,iv:Mf2s3h8++Vxqb4JoymHXY4/WAknDZ2GGrVVtKK51JxI=,tag:MrBrvl/FjIzS/AVoIa1rBQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:zKg/8UiwWslwTcesAQ==,iv:jxbj7Qtv+DRbhzTdvtv+eJuTQPNIf497NZPYA6ld4s0=,tag:HihBcoDLi8j9PB+uC265Sw==,type:str]
namespace: ENC[AES256_GCM,data:885ZKA==,iv:4PfWZu5qVGXP3ZzRHMrh5N9dzJ3SoUPPo58ppcDTnpk=,tag:gvBc+uYOmERckrb4KG2Hkw==,type:str]
type: ENC[AES256_GCM,data:eqT421de,iv:EZHnf1h1L29G1HOBYBSBeydNe4nC8XiBOw8YEL3kxrY=,tag:ypD8X9pa/9dbyChAMSIS6g==,type:str]
stringData:
username: ENC[AES256_GCM,data:Oc6vLw==,iv:1+tLTAxrDitXJwCAEccaVQzc9I9lNRgT3FsxO2NPDDc=,tag:r8lNDb42iewK8Soo9gbPwg==,type:str]
password: ENC[AES256_GCM,data:Ckc4PBc9tqigmcXl97iFUIqcjPzuJivT0tSmGunIDszFLU3u4UqAnA==,iv:jAwDFvJfQ1GkeU/qpEVUAQ6cWqxYE8nrgs+/RouyUxg=,tag:mP6AoUJP0T78+O9krL/eCA==,type:str]
email: ENC[AES256_GCM,data:qt+Sj2rs6l+L4x+kLUSaxlrN,iv:mfkMj3u8W2ZX4N4IH39mZXfEp+xphS4shaJcFOD/LEE=,tag:dD1jpIvTvPNOTer5gsfUqA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0S3hzMFVxK3M4WVZzYWZF
a3krRjcwZlMrNDZ6d0FnZWZpdXNFc2tIYnk0CkVzZ0JBbGREUU0wL3dtSTBFTTNL
YTMya0duY1dlTWVadHAwUTB4cUJlSzQKLS0tIGFrWm9VRURmKzh1SXJPb05mSEdt
NmVUSVk5SnJGOTRhdHFKclhPMi9tSUUKCNhKWvUOExgirvRg3KaeEE+mRUPI3epI
xIYaTsiHvffmjc5mbo2sD6H2/L0h1IkLb78FJQdnTG8zl6yyFzKlUw==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:q46TmZXbALBGfexqqVtLn1YGp0GfWDzq6SqtEC0OujrCExHUyGG6Tb7BtkQDKCl68vkq0HkEwt9tKopGBJhUyC7gPNkXKZ+YmEEEdY84C3ePyvwWCAQ24R6o4Rpg8QURHSf9JA5RmoJOu77OcVMHkOB9Lan2K2dQVb+79WDx5L0=,iv:uQE/b1A1ZTgSCnk+wvwbHo7eWRW/IiKrqG1wlq1aA0I=,tag:WWXtlUi0jagSl/gKTIflqw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,23 @@
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: 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:qW2SQSsGD0YupU2SMzRaYwkLRjXAwV1OJBnhATpCJrOHWbEytw6IphOmPPd3VZKfHncekA4agXi4K3rUPHIx0Q==,iv:NCeW/UfIl0RvHV39ubN2mDfFp7GTQNuFv8KiEF+7KOE=,tag:lisx4h4PqUfLwBX8ca9Siw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBrUkxLMEV3aEpILytic0Ux
V2VEV2EvcENJVnNNRUtBbGkwT0d6eUxYeEFvCjZPSkU2MG5qME5jZ01mRVJwTUFL
M3M0V3c3QXBoRml1Vm9NWW5JTlNRSG8KLS0tIEJnNlFmdFJXb1BPaVJzTmo1RDQv
VXZOVHRHUEZJRUZ4eGZGM1RxSURyWXcK3ZyYCPhRUvpvT/pjPQGJoLIwaktZY6Tg
LS0cIDUhjPFbeu7qUATOrat6vMUi0UREbXSFZ1KyAjNAwJZaUusPMA==
-----END AGE ENCRYPTED FILE-----
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:x1g=,iv:ImbG3BggYpU11BV84Kwt1NrwoSZIrsoiZovqPM2ziWQ=,tag:V5clyseKFF/jEUX/Z/6Vmw==,type:str]
kind: ENC[AES256_GCM,data:9NYfGQuq,iv:6ICJnhTiMP0QFX2xQxqE59AkbtFXQ3UJwH/v/2KaQdw=,tag:wHeJCUeTz4CkqDE/3sDpJA==,type:str]
metadata:
name: ENC[AES256_GCM,data:vvZxPCX3S+jdtrkSZA==,iv:Aw9oxB0x3eLODe/XVGKd5A4UdKoW1ChbRM7Un0kkwFQ=,tag:1EdRix+CoNztbtXILUlHJw==,type:str]
namespace: ENC[AES256_GCM,data:ZVI3KHMJ9w==,iv:mf2ySxEaRrsPLgkwUUjmJuzklo74t8UTeCsUean2nx0=,tag:tOloglgAJIDNT1xNr//L9A==,type:str]
type: ENC[AES256_GCM,data:A3/oiYOS,iv:OMjn8JxX8g/jJSsIL911FX4autWEQDk1UGzfnnfKSjQ=,tag:p/8Rc6o7gfxoX5Lv8lhWvA==,type:str]
stringData:
admin-user: ENC[AES256_GCM,data:rjpH7gQ=,iv:UwIdUTnI2soFYGjZz6xj8boacSMYHxTg0Bl1xFLllsU=,tag:h2nX82mM2iT3tZu9SV9R5g==,type:str]
admin-password: ENC[AES256_GCM,data:hN1bHBVi9SfVzZ1uWA==,iv:4V3wlpMMfakInQoqD6gxk+0A6lWCm3aPw31gpa0sKss=,tag:na4lAI7SzbWTUB0lQ4uATg==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA3eVI0b0pIZC83Nm53dEwz
TlV0alJCL2duOEFkSlV0bXp4Vmp5V0pwWVZFClNGSDNFeVluV2R2NUpEcEdYc0t5
R0xsalVTd2F5c1EvOXJPdHJ3Rjc0N1kKLS0tIGg4c1RBVlpwdEFQd1l3Z0RQYTBY
N2t3MVRTdFlPS3BvY1FJQmZlVG5ZVmsKEZx9fdx+p9UpzLGhN8D58KlPAOtyLtVs
ldAPmMThTIWQ5GgJjLwmag2NlMyO8NmQY+dd4a//Grx/4bep9lhjTg==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:YHngoakearZDcLH7ACS6Nw2hcf19XvVkthLxoM7KrWIAsa+Izkr/kr3PGgfeuwmQL0lux2P+GPlvLE4VeovAyPOiEBVl2tQK9/pXameqynswRn5wnl/kUMPJblNBtPpKR3jHlKsSD/o1MtQofm2mFLsa0bJ+C9JomAIYMFMKpms=,iv:M20AveeOFJs9Jj0jECODihC/nxIe/rPBU5QjfSQvuvs=,tag:dIDq+yaz6u87a1BLQCw0eA==,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
@@ -0,0 +1,24 @@
apiVersion: ENC[AES256_GCM,data:GjY=,iv:OKYWMFG0fqNwzg1F6Jjoon/DReOXEE6RPM5g0Dn6bF8=,tag:qLjLi9LCOuWRjagVXpMvaw==,type:str]
kind: ENC[AES256_GCM,data:0gG77TDH,iv:9WJrkz0BDDllNZskrkqiQ2p1KYWU+EmKL+vetxIIGKE=,tag:UhU8fbO357HtxHAsVFeeBA==,type:str]
metadata:
name: ENC[AES256_GCM,data:Lwk4A9vqN3cnrI8MrQepxf1w,iv:AZUeVqCpdrYJM/chE9bzmFOK5ceqPjqsqMDcQwliyr4=,tag:vbY81yhptOmD19UITJqFmw==,type:str]
namespace: ENC[AES256_GCM,data:UTS0X0QkMssEiH1l,iv:6ubXogCM03/cTdsUP+JWOSepjQc+ZamObyxINKspHeU=,tag:jOj2q0ZgKxWMxkrgfOct2Q==,type:str]
type: ENC[AES256_GCM,data:ADb2WnTAeWRVIHdzvP/Mumc=,iv:h1CCbwf3HqYWOyTX78GMfvnn+PGfrKWuxahe7/j9aCQ=,tag:bt6xtaqHPZ+E1wEGKgKDww==,type:str]
stringData:
tls.crt: ENC[AES256_GCM,data:81dGm7VocQslAh1cLs8obpgDeCEoNPHcTkOPpgL2iHDz080+CEeCT/6mVyosvnZZ5hNLf2Zn/gD01nyvbc6Po7wRmP6yb9+MxusYLsbRy76NvCelyVe/re0VD3NrJw3RH9zHANtF41lI88tqqWrwYkea7456tLDSAGa4DgXOFyfNWBjomNU1iAQAf4IBMJ4zrSCbdWGRs2wV+8eaf1CRI5A3pJMujC9dvqNn8Zlm/dsHXibl0FBbRAYBwm0nr0UCyWn6nJH4YBuBPbAAcNwvbaxbvJ7BBHaObQASxTZhwLXASUJSTT3zIljV+4PkxHJ66gTtJgegnX6Mpk8PzRnH02I5ieETCTBpRdEjasFNS4AAO1Y2F4JWtElCnK8N0ut/nLQKiq5NKyns6ZE+t1v4V+1JrZAnpktPsKhe8+LRlibFU+g+sacG1lXAgJhR+ILZ1J/M4nfXrFStAHOwaxv/LXJk5mOwBfJ3SAq9GSuGQTiM2ByAgZMcgR+92KRUe7zl6yvzSL+QGkC1psS8e9+0KInNlH8TbW86bQFMOv8QNJPNBvu/7Ock1dBK7BX9fLD6Y9YslNgQ66KJUbOkNS3MdIegy4/DLXdkjklbBgVIE8nKiEanfzpY/CnnH+NCkZNISzbgjs4oxtaOyYSyE+JF+1NMgOHbf1eypf9KMJxIDDFCRRW0yy8k5nX5wOVh159o1Wp+jeasBltXsThNrXDFB+mzXd5Oid57gDeoI8tk8rMPqumVaxULs0eK86Rchu8Yv9QD4BWBG30SKSxiVrcDYldPe1tGhUvYk+aJhUbEOcj/8dn35vniohKXO4kC7B3a9aFkGTGG+8LD0xp07xUvjNLYZ77Bk2M+Rj4kASnPEG6KGXWE812MKwkqoMdsOrchS8FO2PK+VTBhMtW7X9yxymUZv64OzgBm9JPk1Qx048iqxzl0lfZlxy3B522uhmTVJIuwEcseU7Nk6liFcS9kRFAgJ23jTM4QXObMgI6GMPLge7JKdRUccGPJaQA7+nr771pnnFBTmlvI5p3qYs5nFW0RZqLt+qnSWcw5EA/7Py5B/VfIpyRkXMkzxD9I3DgX4XKNIuxF4N2HYkbIIoXEJSqAta90HtoLGxaOXzHA4urbGTuineZqbPI1j4DoN714QQePCwhEMSqKR0ajYQytznJd3bo9nEQnteYcRoFw8F1SbLzTZSjtPCPl6HHD2Uaxv+e9iw7LUKaoPBvOqRXLn2V3tC2rs68aFpPuAy7TwVhv8U0qIZWg0TbtA2YsEsN5TRrIMrkF6zQbfNExJYmuLDUhIbxxGlbXPOcVKWn7v4gGXEbRsvmSsV5y1GW88w1tkorQA1J6+OgrhgVwLXrg0i+G4jQEYnd2lnUq8AIbC13P9/iy8yalsB3CQTmPNmZf/xqtosJgXz3+d29MSMgj6yc6DjMxsvzFL2obNWhnSNVH2703Dp1MeYwN+pXavp+BCamUeQZYAkZaZID3h7D/Jssg9APTU6b/aYBXPacI41eyF5W1YWBEzNnCTaZp2n5IB1B+6ny6WaSyYm6AcFUy/LtpPCFWgbFryZQfUfEwGH6BPfU6Hrtsrla+AmFKqVwrs2QBYiQ/atoRFztDImvrmGy4hzS3pkRyh9K+stggCAoPQO1Vr9F4wK0jmGAaIEfqP3gomxrUVJqtAp6e8XmUGLLkoeRBh4yct7zflccrAVCUui1woQSApyceh7wif0TBK6yDF546MOzHChVy7t+58ssVsl/XEGjbWgvegjlhhO3vcSJmZq/DEN5fZiVJ1G9COUZ9i0MRB/p4ClYWwPVEMdAAczoGfLGDr1iYAF6BIiyBluojEH3fSK43y+4uqJJ349iP6JBYCmu8UG/x+RWMG85p9dt75vxsdbcb350RuNRmUgso2EsyoDuMNjmPQIbjn+MAGj3x3Qur2sKYsQ2nE/P7mD9tfdNZH2JYyuuLkkGkw3R/1UuqPmTZMUYJZKrpWjVJC6ZJUhsG4fI0BlHvjBOBL+OVbFBu8rVshhUTXjiDx1qvHdlAQ6Oq3CFfWFf0KbFcevqVYYI8vwI9mVSBdSbz00qm1ld6mIcWFd7q2eBEMYb4eimfZBjQSFKHaYc3fi79IjmGbjxLKI7Y3VSnbqVZHxWLHk9pKelxLIb2JSXN3Drj1MSadPEd9zjz4vxtWZ0VY9i5UGLg3xJjthbm5DlfYXZVV8YvdaC7H4nmpglXAQMVgnrxZmhYXqV3ywC4iywlUF0ug5VlJ/0s5Zp0I9rxEWiGjqdJ6Z8qeXL2w/bgYMVRlJ8RDPb+GIDAXAvO8cTOI9P/5R1tDj8s5/CYufNPVgnzUtggX2TMlXl7lknQ+HYPrbk3BUvL/n+ozubL3ATnoVVEZtREOUoxBVAT+q5q1bN4oagNOw==,iv:AdR04DL4zz35+2ajdPURF/Hya7YJh730XaPr0FIV9k4=,tag:SEnPSA1SrQIhoSM9mUDKdQ==,type:str]
tls.key: ENC[AES256_GCM,data:QW1SpM0wHkjtO31eHWi1CpXGCqngnuMvNgumDji9pQmXGl2rWD/Lpnc+/Dbzs+FcKjS7gEq1EMFZdNuyXOFeaHEWEA6bUfKPcET2sro4b68qyks80ggi0nclLp8t23I2IZ8EORAbhIxS2LNmgYa7CGQjZ+lIFIeMcLg8T304TyUlO05PZJJsd3+sIm/WU4EcshqQxpQVvDpZDI9VOpQBrm9nimECKJUP8f9DqV1QxCwLZKbJfd23qcNKDvvkJPDFtoYWtpygs4RX+NghNao6o6aK4b7ci642HVJCLC1nYBIZLHWaFpOdXoYq/VdYRZoY7DfNrwPhaVxJprc0h5qkAvVKvK/nvGuc5idZcCAKrxPTqconqbCcyfXfRKJJb71Ta6+cDAhN/Pt0MGbDWW8z9+1KmlFfP6PBW0NaPB5FZ/v42IQT4RtR276vJLBP0JNLM1qqBdsU6TFCdyp15zLy9fIt2F3phdD+gnwuYlQKTivraA/7IOj731pLLwytMzz61AxsBDKLoaIkaSJ8C10kWDRT5K29b+m2fLDfjJ1pBqL1Wn2baxGF5YC6GKeDwvvjuiMOPyzTIRLtQhwodcUFRz5gP/4qLn3T3umkcfwxXg76Ef/f+KKmcp6s4eproE8vnO2OgLEmjBQ+CmLhxeEMftE07wUhu3q9TPlOYLontzfc3SwTMFji7ZoJNFEAzFKcmYSmtT2bkAijXBYNhxVTz3ks5GtbEs9tw1/IYT8gsSI6N52F6yUBIydPAmAQchQO+ATO/o8+hFj81PcIJvdeGV+0/AdpLiPlb9qKmDzu1daBE0lk5oEZH0qIZ8BvDfBL4bGzryBmwnrFXEK2I9yRYs1wk6x+l6k0hEWJHs5Vg/BAMRecdp6eimbrOyvdWlkLLa1/KVPDfE6jp0c2Y3S8VBVKyWf3fBQn+UOZFMCLdZglkJZNBHC5JKsTavsTLfvp683033aHX+f1wefGiLkjzmZPUzZGRe80QRWk1Tf/+TzsCwkCwems6NTjgkjTBe5AQP0sTtFeu+56/NCGNsqrJyoljPDuvx32MUoBW7VexNatk059bz+C1i5eYqrzuXN4uEikR6IHugVJnxFI4XD/JNDhwyVzBmwn9Yf6zQwNVMh4vtFXcYd8osTgLI8lTU3RlbqixEDQZ3c1flki6ncubMQ2uYlCCaniahECL3U4lIFiILZYCaY8zqEUlLBB9KgGVq3K0WOn1gI9uC+nB6/OrvL2q9sU5r6Opetkp/J/nIPtRt8af3dHhCVJpH2VD05lxPn7rL4KQ5SLfwrbsFfyiXjAWMgiTR8PaXkcnvb1/g7YAbsEHkUiAPG2rcr+QbYD8TYnGkROh64hpTHrFjFg5Cfj0BdbNkUg2wuAWt9cJ7DuZeQctQPLj/7utzs0y+fd26vTo8fOd3mdf1fBxewauAIx1GuFgRB+4TbmyWcwb5mgE5tuiqstr4FgjhLn6Bu7+DMoaDyU3ZcsM/XzcV0GPYLWmw3xzM6oahjzA0Gpu1l9fVYotbKAhewEvq8o198JXO++z4ixsNJkRqS2Xo8f0rE9Qm4SCeYSXdBsrefFPcATd0kdHmAOl+fIK+y4crVJ0XgiJtXGjjoRwK/IrEO7LxAnVZUVaDOWVvSgcaWPZWeFcWjC7n9i9S5x771DJcW1/Cj+LGeJ5qNuSnwbqikJ2pbrR5VY6tXTgzWFLYCeYUrUWBb8Tbs2mrvqdP+Qj4llT09XjcWe2FiWf85I1hyX47c0vckLeI/8Ly46qMWIctukEQvT+ZxxsJJ2bvMTcJbSTxxf/doCqB/VuMLnPQYWroPpJizw4Y3vZY3x8se1r/cVA76UJ9p7FIeilB+SiKyRq9t0XFtFRaAQr4bvr4EEWRRy4c5o3Z5S+ik23WpSCz2E4Wagkr8w/B0MtAkCEIdWcNGLYq+bnr11PCVIjDwoCzue2HzYlbHaYdK5BR4PR82/7Ls+JXreXPnkXkPfis+H9651R22X9YodqyvvtpnzXTLmnluQzpueNPl3wUyj3tge2NguNz3zWjFIvH7zefY+Ucc7YLP7565gqKsNlzeg6fWPO+sR5kGny8WxvavJ4bo2ILYSkvDKRkiPG5ZmtbhWK3yiUJfkcr9ggWtXDB31wG5L5MKQEqqhuevPWmbyso1N9VGHesdhip0HK6hzfmCx7/eZ78y0oGFy1AW8Gi66mm7lUzyRIp2GjfWq2VMVtKlbcdjWIjRAGzJK86HQR4FPvK7zMJS7uQdpwGM8ulXrYYokraJkvujPr17JitcTO9roMeFYUlsTyzdBLEk4M2Wsc+WU/rg2nhRmoNGsGwNzh9SKJLYl6IgRRQi4hDPKxHE0EJcZNs5VRWqvf6Wfp+cewXnKoSQWcBAsNa8YUXn3G/7FOhEszm3fOLBqoHJXhIzRzVTG+1B59QCMw8PRtkCGDgaOx8OedLgLagh2YVJGWvkDteOV95joIQU0RC/THZSfY/qx27Z5A/RBqXRTqXq6+A8Z1DtNIL0z5ZWC03oSIMuuvBrxF0r8p2NhmISvgMFQf5UT9XlNZac+U5u04+SwrwGhLwSqghL4qgDGJK2UZ/s2lKRfv16azIDc7faTBwxUCC4xyRTe+Bv6PwC32XjN8L7RPvupvkjznex9cvtIA0VMFhgDgrzpN3RLLT9eMkgeTm9aM2KdZR9V7tnHusbVkS6P9U1ZaQH4K+QIcIk7DPYwYYT+3HaYvjgqFijzzhM4eCGcCET6uKootP6mW+e/1hPrWiKoAy16i0dF3v7sBmj8FifU4OGMK23jHs8kXfMBmN+ltZG+RiGb8AA4Y0zh6KQoG3+wGHZNDZWMudcr5hMNjS+b6ZaIFpYNFQnmhpdQ5OdRWGS+/BuCTm11GCtoEEmqLv/eDAMvZxhZxUe7ql8kOfM3cJivjidxguTpFfMN7+9LuXFIMlrsoTTE4ZSHHHvpoTdW5KiFvs/tjHN+MRAGUi+AfHa11JA20XK/7ce229pZMxFe24ugILv3Uqlq5GZW+RLMoSb1EfwDF8eZ8/UybcK5TUG8NS5+WEP/PDCYK4MRP+hmlS6zvEo7mpsk1Er0Z4QnyUyRRkZe8XrRCA7G5hv7I72mU9VCoVpzx4kKLiyHcIjUfWunIGodP+dXhEqulVMRpzaj/iY5CXDfOiwpXX+/4uXcXf2370OhVJWGHe/+3N+1UGXgdBDBx+hFyS+mTWZbOpZROsrHfP5bHYNXFtqvXlL4oBKttgUsCmQ6ujcIk0Gi7hKbvad76jFMvmi+cc0zBwwylGf7QWzFa0AtwZTqNgnHYrkuNqOTXOfmdMfwMVv4ObBTpG26yGg6sALYI6G50nmJesA9qMn2l2A3mQGXskbppvmtLGQSvamj+DrNbUSGbr5Q9Kn4bptHfsoidQIHt5pGBlxU5msivz5RL9wUJtEdiU9mZbGJ8KrQxgOZaOQdFHwh/k8i5667n+dUUjb3tzQ50B5NPIH8etNdUOy/nslNlaiu2daYTPYnm6GvNo9CLqUVPi0gNBs4zowW7yuMzG5xLQUcYD/mg8V7HHbnMZ0yzhIPzVa2cOPucDK5wz1SSF0p17xzuZkEzIpZ256pXif4zI4lZAunayiGoIq/elyqbhnbMyGdvmY18Xs2p7zxowebXzyJeVvO973jDNYj8aGpoiQV4k6Zg8ZU23F1MCSlobp9Dp887W6y4MZDjmkvQi5Bn4qfYGJukE5R0grd2e++bU1x1RpQmCAg6CKk8ONFYfTJCBlKnPfXf32mS6KQ/fOwzFr7RkBGjTGESeJBUtdGVfizALoFMFEOWuaxRUSRzWazOMBLHDTwK140S1XNZgNO1oLaBfb4Q/Ef3tpo5VcRGGkY1j7XhQK8UV0R+OGJJecnLvrJWAeYfg3wp/vOO/f18oVY5uR1kyduqkcRB7xygJ5lyUu9uUGcx17UzEGJt7oRRiuz+ys19ZYGZ9P02ca+3xBSd+g8ThmdwpRhbb8xqEAhktM81YPRiSPKbXCR20Wn3fg7I2Hdsh5ISIbhFD5+FKuoOPwFMmcWz2yVvsVyiTLqIzNCsJgxl9rxI4GEPbaAq3QFfMq+nMqwyrofGogjiimAAYYk3WEXFcRdXMXw7TT7eXGWfdLk2hbU9M5hvhem9+hJRZzQqt1mzoJuJf8fqdWNq38Kp6GHPsy8WBRdw6ZPlgBvkgydfJSXAmzTcJUEAn69iQmihcnD90HNP6+A9u4lLzuhV/c5+yF0gAP7DH+oVW+46wUJttWzqRbMtz3geV5H4xKkK8dECgaEJcSL6Jn8GrE7l8XrO7qSPzaE8Sfpk4NVu7aoAEw2rXErFqAWFBjBnw==,iv:tM8kJdNlKHgDmFQ85KAvt8nF9aPo9FeyQ8SoeYwQSd0=,tag:OKhz5KYHi2tCts+9MjY5KQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBMZitSSzhtNitMM0h0RUgw
NmVGT2RhVm1jcUV6WGxPbUJmMytpOUR3c1hJCmlncC9PZkRMMGZrSWViL2dVZ2V4
emNYekZHbFJ3WWVUcjhZK1hPSTQ0UDgKLS0tIE1yeEIyZHBaVDA1MzNDMnBXcDdL
VjJTY0NoaFZhT0V5RU5hTlc1b1BPY1EKSiudyVo2Tw3PTPGt59vRL8pkJlw4zFbx
OTkdkmlcjwXPahnQacMQQOn0ndo/w4yHinu3RA+/yAB2yR1BPn8uQw==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T20:55:07Z"
mac: ENC[AES256_GCM,data:DS2iyn36jrJgpWdocrloEFyXcSEPc6vXVj8XJwvEDyBAkqulKDeVQy4Lm60pMWqAt83Q99WWvTq2xd6eRrtAWGO8xmeFrKWaldUqFdXs5l7uscSAm8gQU7Mxp0/bx7rhJECoa/MJp+kv1SeDuon1b838vaY1eekMn/nJTSFbcBE=,iv:5mEWwF7ov63rA1JvFpizLhZne30hEuTcFlHu1lyAsUg=,tag:R+ig+kp2MtZMF/rXabCyBQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
+8
View File
@@ -0,0 +1,8 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# All homelab SOPS-encrypted Secrets, decrypted in-line via the ksops generator.
# Each *.enc.yaml carries its own metadata.namespace, so no namespace transformer
# here (that would rewrite every Secret into one namespace). Renders exactly the
# Secret objects — replaces the old argocd-cmp-cm SOPS plugin.
generators:
- secret-generator.yaml
+24
View File
@@ -0,0 +1,24 @@
apiVersion: ENC[AES256_GCM,data:44k=,iv:X+aWGTBFUqzlKT6iCR/LN5ldJcT0mSt9q3bMYbm/094=,tag:WDq6/nb4lzmyJ54QmrkDEQ==,type:str]
kind: ENC[AES256_GCM,data:Rzk5Z+Hy,iv:WGtDcuwwxveu72V1Ri+rh44IQL4CixKsISW6R6Ai1AA=,tag:fTqJaqQpA4yDHd8hY628rQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:XOUsgfr64/dKRSMt6g==,iv:78DvUfW9uxmVSr4LK5HS/GlTchRsaTBEMmqjtHwAwcg=,tag:EvmBXKJSX3iQyjojAX7T0w==,type:str]
namespace: ENC[AES256_GCM,data:xWSxbuMa1Q==,iv:FDj3unj17suAuxzow1iLUztImC0dT5e5QuzY6uPQXV4=,tag:kp9Qn4/uWPgIFfoEchsOgQ==,type:str]
type: ENC[AES256_GCM,data:luksVJs2,iv:gYL9JD63cgNp3mKqPc9liLdO9n/+qOK+Eb92uV5Ex7c=,tag:ECPwT8UUnLgr3OcMvhdg8A==,type:str]
stringData:
access_key_id: ENC[AES256_GCM,data:ZPX1IGcFZfxquA==,iv:9jKQl0nZYc5qfO9mdvfHhfG1a/0+mZ+tmZm20LQ2gg0=,tag:Rlq90E6Wq7el0MEiQDmIFA==,type:str]
secret_access_key: ENC[AES256_GCM,data:49BtPfJS57tifvyoO5DhK9os+O52b7igVSjdB1h86trEsXPle7KaUCkFXus=,iv:Uvy3pUpYlsZQkxMG2tKnXYD7Us4RcP1DmGWlNlUp0Ek=,tag:SrVny6/3Gt8XO4oI/VTmGQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2MFh4Y2xTelhGNXV4SG5x
TTROWS9LOXViRmRnM0FDajBYTUlnZ1R2M0RjCkp1Wk1LY1E0YXV4cWpvZDZ3S1Yz
SmNaaGxZVjdBQW55WDZqckh0aDkrNVkKLS0tIDVkZzdQeERybm0vZUQvZlhCK2hI
T2ZlWVJaMVBxanFIYStZRi82dGZxL1kKfQQoQlY3vVbU3Ys7TiEbDzCv5zFuki2m
LSJ3zaaRAm5ldnfvX6N7fiTJed7Qo/rbmMoDSCrxj5P0SA4UhCefEA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T20:43:24Z"
mac: ENC[AES256_GCM,data:qxnq1Rc2DYN8VI1V9Qbsg28dZALu1dozbkcWpGA5T8qAHoH8ORo16ZgS3RclAmKys0n05DzvNtnVh2jXWupg/OW8K4Kn7Mrcb6AmEi5XZAlkCy6tzZEKdm22h4A07wy12701nIf5Rn2dKt9PO60oDZeR/5S9oov47sDXjy578WY=,iv:fyqI/RVnvb2MsGrxNO0GU0+HYgLpAm3PXXOnDGkAaNQ=,tag:dcguaqaj+trqgBfiGdhZTw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
+72
View File
@@ -0,0 +1,72 @@
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: 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:3RIQ1i6NtY1Tv2eJY8JXSgfBEqi9px/9TJVNrPf9+brtGy9Mx3SwjKVnjGXUcA2cYcNZb1JR7temXmznXQdsRfZ9yohe4oc1vL3yPGPY9YfP7AHGYj9E7tghu/rgkHOf93RmfdKv0Rrq6miBcA==,iv:0RQVMi2mMHBRTJ1OX6IDFvq9Qr7zpqgQzt+MPPgBIHA=,tag:HNXLKf5X4P3E+hc7YbDtNQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtUVJFQ1ErNmkzd1N6b29t
L0VWR2tlcEIxeG1CR1hXRkw2cEt0Q2crMWlVClg2UlNhUzdJZGpLZjJtYlAvbk16
eHk0ZHZiNzVRTWhyQzlieTU5Y3QraGsKLS0tIHA5UVhvUXhNSDk0QlV0R2RzR1dP
azVEQzgvSFNwak9TcW1USFNOb3RvSkUKNLuW3h3cHDSSCZGe9Vyiv5m+wXihtCYX
oJtbRK+kJF8oOUo4emI+SLGrCsWq5cG2wN/wgP+ChR1zUgRgfq5oOA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
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: 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: 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:IYBfNW19cTo8WkCGT4zuCljzi2bkbYWJ8DY/yedR2MJNqEO+bMVvjx0OqSqbPp6rnwGyMQOhBN0gXPbEVRDBGg==,iv:I1aGIikbHTKvh//4Inhp7UoNfhjlIXSpcyP4RqljvqM=,tag:Zh3Vh5DQYAsTtiW59D0/QA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtUVJFQ1ErNmkzd1N6b29t
L0VWR2tlcEIxeG1CR1hXRkw2cEt0Q2crMWlVClg2UlNhUzdJZGpLZjJtYlAvbk16
eHk0ZHZiNzVRTWhyQzlieTU5Y3QraGsKLS0tIHA5UVhvUXhNSDk0QlV0R2RzR1dP
azVEQzgvSFNwak9TcW1USFNOb3RvSkUKNLuW3h3cHDSSCZGe9Vyiv5m+wXihtCYX
oJtbRK+kJF8oOUo4emI+SLGrCsWq5cG2wN/wgP+ChR1zUgRgfq5oOA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
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: 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: 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: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+IFgyNTUxOSBtUVJFQ1ErNmkzd1N6b29t
L0VWR2tlcEIxeG1CR1hXRkw2cEt0Q2crMWlVClg2UlNhUzdJZGpLZjJtYlAvbk16
eHk0ZHZiNzVRTWhyQzlieTU5Y3QraGsKLS0tIHA5UVhvUXhNSDk0QlV0R2RzR1dP
azVEQzgvSFNwak9TcW1USFNOb3RvSkUKNLuW3h3cHDSSCZGe9Vyiv5m+wXihtCYX
oJtbRK+kJF8oOUo4emI+SLGrCsWq5cG2wN/wgP+ChR1zUgRgfq5oOA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
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
@@ -0,0 +1,25 @@
apiVersion: ENC[AES256_GCM,data:894=,iv:Swg6ADUgmrqwz7wqAZHip9/qwFu0Rn8S2Lx4gBH8LJM=,tag:zbv0tFfRLtwFxBfpsuLt8A==,type:str]
kind: ENC[AES256_GCM,data:jzVtJHYw,iv:ToZ0orfJqfGF/OnAPeYu/g2f4fXAMOZQDkA1+tmIccs=,tag:pi2skbwIU8qOQVEC86MdAA==,type:str]
metadata:
name: ENC[AES256_GCM,data:t7zCeZLvAEbkJqUjWi26yD6UDA==,iv:MS1gq/bwKZdLA1itVDtsrdSOfI7e2CrhjvX5yhs0eQA=,tag:lC1gcoFMI5nfzC56U1WXrg==,type:str]
namespace: ENC[AES256_GCM,data:tp+d,iv:gnlet/60mgbSWLXEQpSlcWD98ky7NvlNCzGLTMys0JQ=,tag:PQYj9UeA50YenQESTCl7lg==,type:str]
labels:
konghq.com/credential: ENC[AES256_GCM,data:SOqQ9bLGLK0=,iv:a7En49UhRDwgHbv5NRB/XilEYIKQdaDqKH86WDrJB5I=,tag:JaDZ6Ko8ovY6AZ1hW9YMGQ==,type:str]
type: ENC[AES256_GCM,data:r1K5gvop,iv:Gjv4oG2Unyql5rY9RTTljFqyd28xI81CDWtpavuuW5E=,tag:jF8lBs3AQwHPVEu9V+mONw==,type:str]
stringData:
key: ENC[AES256_GCM,data:pm9GmSvX5MAsXO/e6ZcI4NF1Hwr3qjG6LaEEjvV0ihvSWhO23drlAEsrTtzppqgDZbMORf+5+P53mXU=,iv:5AbHNKeiMPoFQP/qTKdA0vEoYPzuaf4kIdGGcMSmfIQ=,tag:LD5w7XK+hiCS5D410+nCfQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBUTmoxQkpuYUp5UlRQMkph
a0RaazdvaU5sWkNuL2gvVjlUYXVWV0dUWVVRClZhWDZCN2hpS2hnOG9Pck9zOTkx
RGNGMEI3RHpNbzVaaWNGcTNSSEdzZHMKLS0tIFZERnVJWUpreUh3TTlwbGw0dUx4
MGlCSkxuWWVEK2RaSDZPUzhNSUlCa28KHN0IsgQc/kBqmjQ6+4sgfb9PJy/45MwN
rXaLJ1htpqPZ9MJ8iOukRi0IKnKgQWXsoZengIxGmcOnEctpoH/kyQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-14T01:07:50Z"
mac: ENC[AES256_GCM,data:nY+YVwU1GuK8Yz+EZOQKkZKN28tm2L8afflc6hsgVFCFmsep5kVT+zId7AgemvQ+qnrho5N5nqxY2knB0gusFfWNKF3V5A5GBq40WtZCMaAtcwhJSex4kK7ZyaZD6oWDW/RTUumSrivSowkWlqt1XlDKyFLqSlpWQTd7JiGmUv8=,iv:zE8+B5UVYSuuAGYyvXsAwp1N/4vGduCoGyEMCNNEUnM=,tag:6RcrjneV2dOzhmoQi+5HsA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
+26
View File
@@ -0,0 +1,26 @@
apiVersion: viaduct.ai/v1
kind: ksops
metadata:
name: sops-secret-generator
annotations:
config.kubernetes.io/function: |
exec:
path: ksops
files:
- agent-pod-models.enc.yaml
- agent-pod-ssh-key.enc.yaml
- authentik-secrets.enc.yaml
- cloudflare-secrets.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
- model-invoke-apikey.enc.yaml
- minio-secrets.enc.yaml
- vault-secrets.enc.yaml
- vault-unseal-keys.enc.yaml
+24
View File
@@ -0,0 +1,24 @@
apiVersion: ENC[AES256_GCM,data:cM0=,iv:KFms9ElUhHn+w9flx5nzLfwvfJsMN0u8+YDMqFgB6V8=,tag:7Ve53FzTefhTYFVwnkT/iA==,type:str]
kind: ENC[AES256_GCM,data:khv7iEsd,iv:rzn3Pt2pJcbDj55NYafUamNLTBrtluUPeLKorzpskGA=,tag:0PbEGGlxwz8ixBfwdOPi0w==,type:str]
metadata:
name: ENC[AES256_GCM,data:jgWuWAVI4nQMy8dkJXZI5L4=,iv:f9pwyirRf/mLqr8LhuNPE53guP5mlZmPftNhtFKSbFA=,tag:YWX+qDU1z82FGSw/Wc3nJA==,type:str]
namespace: ENC[AES256_GCM,data:j77p,iv:rkU3kw12xPeVkVRn3w8WIZlc0+11jGeg1jZBX3kRfaQ=,tag:c2MrHHI4N8CeEQYDAr5wZg==,type:str]
type: ENC[AES256_GCM,data:F4hK9q+O,iv:7gmqUtqcYzDHp99fYdinL4yYSTvjoyHlTnhHbEynGQY=,tag:Yuw+B+h81bbZbWcd1zMSsA==,type:str]
stringData:
access_key: ENC[AES256_GCM,data:SFP7ZsrqSJXbaA==,iv:QuGF69veC8FWOSAbwzv7vhKYkF0yOp0ty/U0hdmroAA=,tag:5VNvfVONHySMbYcYnwSajw==,type:str]
secret_key: ENC[AES256_GCM,data:WxvqVZJWTckEb3paILxUJ51hkg0aNzRJzziuopsCpY/Amita3zXl91VJtGE=,iv:dyKnNC8255FCmOd61xczuk0Ww9MYZmHCDc+JY60AjIQ=,tag:rzem3XDOj7VGaYuhk0dc2g==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkaFFHVDhicUVPWEZHaXJn
eGc3WmdUYk9mdHp4RFExazRhWHBDa3VLN3gwCmZCUFBJaHRaSmtPRGhsQkRHSFJt
U2NUMUh4azR3NllLWDg5ek94ZlFSYU0KLS0tIE1BOU1IMVAveWtKaEE3QWhFeXp6
ZGd0TVZxbldTLzhHK1YrQUNCWUJVZlkKzK/aF0+qohgujyKHiRpQ+YYsnfVkvzDr
+3VnWcU1dPH63wqtf10LeYnBq8ewesfkibOVlJx0v1z6nQ9ZTMXwqQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T18:02:01Z"
mac: ENC[AES256_GCM,data:7JKz0tx066VLgwnpgA2/aUK/A7KD5y2PZJUqRNPMfEmEFqpkvm3Z5Ejj/kxmqYHwsXL+P92SrvMnbqq0v5I6PolR3fPW5fT/xcLibBmvoh8HF4wrRoaElK4ojTNDYJHginy/NE34FwL6sgDCbtT8QAJl9k8HEwxHlfBeyzzryaw=,iv:oTuLhckDQlvLHhGYNrpU0CSQB4Bunv2XvSsD53Z+yZQ=,tag:7CEOFC5t7vbFUQvencL/sQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,25 @@
apiVersion: ENC[AES256_GCM,data:Gn8=,iv:DmWiGvlY/rBzp1Yh6yKw9pXjFO7smSB1fKfe7agSJz8=,tag:X6KSIbxlZWYYXKKxOfuThw==,type:str]
kind: ENC[AES256_GCM,data:lsjz9hz4,iv:vLq5BRMjNRl2ZGDQSrgff7NiZQsBEMSl6OTZ5dAMisk=,tag:ug62YjRZXbN1Y7PoMihsIw==,type:str]
metadata:
name: ENC[AES256_GCM,data:TkkvNs86o2+hC8MAi8lEeU8=,iv:SaxeZgpWekHMJeoHKaoZYGPEVGw42XUk/sT6SaKGVN0=,tag:mtA9SwNKiXh3NrC5rdasRg==,type:str]
namespace: ENC[AES256_GCM,data:wK6m,iv:KtA31Bo8aGE1HU8H9KWbMwt2NfywWfy77G/LaReaI1E=,tag:V9SSlPawRQw0x2utFNN1aw==,type:str]
type: ENC[AES256_GCM,data:6UPZ1ZTR,iv:FxN1ebrlJ4IO3eDGEYSvktSpMgAebcl0DO1WHh5O0+0=,tag:2bZN4zdbHzx0oR+bTJPPJg==,type:str]
stringData:
key1: ENC[AES256_GCM,data:dV2HOh7W1Pl0QDJaGvtEKpBppybQMiK5kxzyk05zkA352ZmJvE8Ppm5yWKk=,iv:grQ8v2o/LHpJnIZonjtgTHKcLUQIH8xl1FtWtEs4rEs=,tag:lANcccYT6Ouo/0IcoLw4uA==,type:str]
key2: ENC[AES256_GCM,data:YSdpYL8h56PfUrvMFhBXhmBG2en0toLKEpYlJqwjAk/vI0jm7gq/TqGn034=,iv:GxthkNLhm3qHxjkZetiYl58Qa/K7G2ib6E+LWK16H8Q=,tag:q79UUoh6uCcMZJ0U18Ireg==,type:str]
key3: ENC[AES256_GCM,data:sO7Lao9qkmIOdARL+6FBcLo+e4z8LzMEzNrwcy5hv9uRuxppwAXrP0MO1vw=,iv:wSWe9h1HovRhV5YCZrmvcB2VEfRYQ+gasGO3uh8IpmQ=,tag:Bc5yaROQDTz1AhVnFDm/BA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBtekFMUWlnb2tiUVhCODE1
TkNCclVTa0dVUFhlc0l2elUrVi9MUytSM3pRCkdtYlRFL1cwTHNPZnFicHZwUnZU
eENhS0dIek1JbzZEODFSY2tyem91MUUKLS0tIFIzQzNmcERZM0hBbGI0TndEeGlB
MFk1RTBFbHRqempyYlQ2U05YVko2R1EKVvk1Rd9ZU0G1GHX+3mmlHAfQOPzmoPsk
7RltNZF2SCxjIv5C2pqf3CgmRBaQGWgMybRRH5gdB87PLBKkPL3+HQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-12T20:52:24Z"
mac: ENC[AES256_GCM,data:kLjoBWmJ2bZ13EbuzgppahHKmCY/xtCi1R7xCEUbCP4FQfiVaC4qAbVftE4lTNlXa2mqCKsGPYZ/A3HV/4+a+it/pg0a+rj7E7DszhieWbZufHMJs+w4/Le8l8wFFE5aNW0wdvGTJZ0n4HBOdAkE1qN9ReaiaQgr0rk5ggOPG9g=,iv:MiDVUgcoKrP/gj4keqomo6OB12dmz9VoesTiHQTnBFM=,tag:cLK67Pym4BuvXLF1RUA8Gw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -1,23 +0,0 @@
apiVersion: ENC[AES256_GCM,data:AYk=,iv:/1G8RSCBaiQ6msQEPPOO4LzlbYOzEbNvT9OwnY0E1JQ=,tag:Gq2I92mI3c1FFsbv2ZE+BQ==,type:str]
kind: ENC[AES256_GCM,data:PbHIh8xS,iv:KMW7WL9ZjTmUz3NOQRhjsuAH2EZFXGk1P4hhl7cp8c0=,tag:9vJiqOtV3EhVfXWjJr4ZhA==,type:str]
metadata:
name: ENC[AES256_GCM,data:mF7O9mz8+KGfU6DJ8ifP974at95QOPH7yIaC,iv:1qXckUq4RTBZ06YNLXsuMW1UWRK9cgymDdVnmwLZles=,tag:v2dzEB+J23h4MxxkcJThIg==,type:str]
namespace: ENC[AES256_GCM,data:7omPsuwSfF1IwvCN,iv:YCv7jIXpkzTx9iu9ScwHme8Nc8iqJr1xn8yXK/OsHk8=,tag:5zslu/jpTpMHi2XvCP92Ig==,type:str]
type: ENC[AES256_GCM,data:nF1pv2+3,iv:iseIGnyGeVE76BORrjX/TnvrfzaRMOFrQ7oWT0jIS9M=,tag:qTpF2hYVmnCwL8EQA9Ot8w==,type:str]
stringData:
api-token: ENC[AES256_GCM,data:Evzvkz7ywKLsTXA0/ZF09Y+uKWDQ16SbAsogo4t9aAd+zsvFAnsyVifQc0SUNxzjSv8FXws=,iv:uFvlesWdZUbiuh8WTq1uLBRtuptV2tXxGJ6PVgu7vCI=,tag:thpyhOHtKWNn0bi1Xi1q6w==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwM0ovVUZmZVFmbjlwOXgx
U2h6aDNVVmV6NC8xckZtN3lwVytneHZvQ0VFCld3YjlhTFU5aVd0T3NpOUU3NW16
ak10VVFiMnNsNG9GS0tIN1ViZjRrcG8KLS0tIFNScE5ibkYvQWszeDl2UkF2VHpG
Rzd2NlNSYlRYY0pkaDY1YklGS1NaWFUKTmYqAOF+iPbgl8Qv1qEyGLSDn3Yqw3Sd
iYpwVUPMrrLG9FiaSTUavmkv96DCEnof3GStaYsd62MXFWHW9eFAtA==
-----END AGE ENCRYPTED FILE-----
recipient: age1smu533f803gmd0jq60s2zaj9zlznajy0ca6rtewd4r37mr2hs3uqsrldfh
lastmodified: "2026-07-20T19:49:33Z"
mac: ENC[AES256_GCM,data:6d8l+mal0zlwSyK1zkvpE8Y2cknXM+deaiIdzc2nySEm2pkC2ryY6exmPOsiyjDFrH9vnvwpvWxUqOZc1GPJ7/Pe6lDsdgT7ZPKrzJwfG07lXCZTNMTKruQ745mPgMzoJ82OjC8dXrXRXAqbFMw7SJeCajlfwL2Odmqr0reTnYs=,iv:FEcmRP5KYbI6QBZDLvcgF4ko4g5j2I8+eFrzPay80fw=,tag:cdUfwl+8Zwjyvu/p8htS1w==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,273 @@
# Public root cert for the self-signed homelab-ca, distributed so internal
# services (Authentik, forgejo-runner, management-service, blackbox-exporter)
# can trust certs cert-manager issues off the homelab-ca ClusterIssuer.
# Not sensitive — public cert only, no private key. One copy per consuming
# namespace since ConfigMaps aren't cluster-scoped.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: homelab-ca
namespace: iam
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-----
homelab-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-----
---
apiVersion: v1
kind: ConfigMap
metadata:
name: homelab-ca
namespace: cicd
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-----
homelab-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-----
---
apiVersion: v1
kind: ConfigMap
metadata:
name: homelab-ca
namespace: monitoring
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-----
homelab-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-----
---
apiVersion: v1
kind: ConfigMap
metadata:
name: homelab-ca
namespace: sqs
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-----
homelab-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-----
@@ -0,0 +1,13 @@
# k8s/bootstrap/cert-manager/homelab-ca-issuer.yaml
# Self-signed internal CA, for services that only need to trust each other
# on the homelab LAN (not exposed to the internet, no public ACME needed).
# Root cert+key live in homelab-ca-secrets.enc.yaml (cert-manager namespace).
# The public cert is separately distributed via homelab-ca-configmap.yaml so
# non-cert-manager pods (Authentik, forgejo-runner, etc.) can trust it too.
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: homelab-ca
spec:
ca:
secretName: homelab-ca-keypair
@@ -1,5 +1,11 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: cert-manager
resources: []
# cert-manager deployed via ArgoCD Helm source (see layer-2-bootstrap app)
# Issuers + CA trust-bundle ConfigMaps for the cert-manager-issuers Application.
# Each resource sets its own metadata.namespace (the ConfigMaps target iam/cicd/
# monitoring/sqs; ClusterIssuers are cluster-scoped) — so NO namespace transformer
# here (that would rewrite them all into one namespace). cert-manager itself is a
# separate Helm Application; cert-manager-values.yaml here is only its $values ref.
resources:
- letsencrypt-issuer.yaml
- homelab-ca-issuer.yaml
- homelab-ca-configmap.yaml
-5
View File
@@ -1,5 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: kube-system
resources: []
# Cilium deployed via Helm chart
@@ -1,39 +0,0 @@
# k8s/cilium/l2-announcement-policy.yaml
# CiliumL2AnnouncementPolicy — without this, LB-IPAM (lb-ipam-pool.yaml)
# assigns IPs to LoadBalancer Services but nothing ARPs for them on the LAN,
# so they're unreachable from outside the cluster even though `kubectl get
# svc` shows a real EXTERNAL-IP. Confirmed both forgejo's .165 and
# shadowsocks' .166 were 100% packet loss / incomplete ARP before this.
#
# loadBalancerIPs: true makes Cilium announce every Service's LB-IPAM IP via
# ARP from whichever node currently holds the lease for it (one node per IP,
# decided by leaderElection — not all nodes simultaneously, which would
# otherwise cause ARP flapping/duplicate-IP confusion on the LAN).
#
# externalIPs/loadBalancerIPs split exists because Cilium also supports
# announcing Service externalIPs (a different field, unused in this repo);
# we only need loadBalancerIPs since every exposed Service here is type
# LoadBalancer via lb-ipam-pool.yaml.
#
# requires kube-proxy replacement (already the case — see
# k8s/talos-iam or helmfile.yaml.gotmpl kubeProxyReplacement=true) and a
# Cilium build with L2 announcements enabled (default since v1.14).
#
# Apply once after cluster bootstrap, alongside lb-ipam-pool.yaml:
# kubectl apply -f k8s/cilium/l2-announcement-policy.yaml
#
# Verify:
# kubectl get ciliuml2announcementpolicy
# ping 192.168.1.165 && ping 192.168.1.166 # both should now respond
# arp -a | grep 192.168.1.16 # should resolve to a real MAC
apiVersion: cilium.io/v2alpha1
kind: CiliumL2AnnouncementPolicy
metadata:
name: homelab-l2-announce
spec:
loadBalancerIPs: true
interfaces:
- eno1
# No nodeSelector restriction — all 3 nodes already run workloads
# (allowSchedulingOnControlPlanes: true in controlplane.yaml), and with
# 3 zone-labeled nodes, redundancy for per-IP leader election is maintained.
-36
View File
@@ -1,36 +0,0 @@
# k8s/cilium/lb-ipam-pool.yaml
# CiliumLoadBalancerIPPool — tells Cilium LB-IPAM which IPs it can assign
# to LoadBalancer services in this cluster.
#
# CIDR 192.168.1.160/28 covers .160.175 on the LAN:
# .160 talos-cp-1 (node — not assignable to services)
# .161 reserved
# .162 talos-worker-1 (node — not assignable to services)
# .163.175 free for LoadBalancer services
#
# Current service IP assignments (via io.cilium/lb-ipam-ips annotation):
# 192.168.1.165 forgejo-gitea-http (cicd)
# 192.168.1.165 forgejo-gitea-ssh (cicd) — same IP, different ports
# 192.168.1.166 shadowsocks (vpn)
#
# Apply once after cluster bootstrap:
# kubectl apply -f k8s/cilium/lb-ipam-pool.yaml
#
# Verify assignment:
# kubectl get svc -n cicd forgejo-gitea-http forgejo-gitea-ssh
# # EXTERNAL-IP should change from <pending> to 192.168.1.165
apiVersion: "cilium.io/v2alpha1"
kind: CiliumLoadBalancerIPPool
metadata:
name: homelab-pool
spec:
blocks:
- cidr: "192.168.1.160/28"
# DO NOT add any 10.6.0.0/24 block here. That is the WireGuard subnet
# (10.6.0.1 = talos-cp-1 tunnel IP, 10.6.0.2 = DNS — see
# cluster-config/controlplane.yaml). A 10.6.0.x block let Cilium LB-IPAM
# auto-assign the CP's own tunnel IP to a Service, which broke the
# WireGuard tunnel and locked out the default kubectl context. It also
# can't work over WireGuard anyway — L2 announcements only ARP on eno1
# (the LAN interface), not wg0. Keep this pool LAN-only.
@@ -1,78 +0,0 @@
# k8s/coredns/coredns-configmap.yaml
# Patches the CoreDNS Corefile to rewrite homelab hostnames to internal services.
#
# Why this is needed:
# Grafana v10+ does OIDC auto-discovery by fetching
# /.well-known/openid-configuration from Authentik. When Grafana reaches
# Authentik via the external hostname (authentik.riotpiao.com), the
# HTTP Host header is preserved and Authentik returns external URLs in the
# discovery response. Without this rewrite, the hostname doesn't resolve
# inside the cluster and Grafana falls back to the internal service DNS,
# causing all OAuth redirects to go to authentik-server.iam.svc.cluster.local.
#
# Applied by helmfile presync hook on the ingress-nginx release.
apiVersion: v1
kind: ConfigMap
metadata:
name: coredns
namespace: kube-system
data:
Corefile: |
.:53 {
errors
health {
lameduck 5s
}
ready
log . {
class error
}
prometheus :9153
# VPN Access: Map api-server.cluster.local to cluster API IP
# Allows secure cluster access via WireGuard tunnel (Shadowrocket/Talos)
rewrite name api-server.cluster.local kubernetes.default.svc.cluster.local
# Forgejo: route through nginx ingress like every other host below. nginx
# terminates TLS (wildcard-tls) on :443 and routes both /v2/ (container
# registry) and web/git to forgejo-gitea-http:3000.
# Do NOT point this at forgejo-gitea-http directly: that service only serves
# port 3000, so containerd image pulls (which use https/:443) get
# `dial tcp <clusterIP>:443: i/o timeout`. SSH stays on its own LB service.
rewrite name forgejo.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name git.riotpiao.com forgejo-gitea-ssh.cicd.svc.cluster.local
# Rewrite homelab hostnames to the nginx ingress controller so in-cluster pods
# hit nginx TLS termination (cert-manager cert) and preserve the Host header.
# Routing through nginx — not directly to the backend service — is critical:
# direct rewrites to the backend bypass nginx TLS and expose each app's own
# self-signed cert, which nothing in the cluster trusts.
rewrite name authentik.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name grafana.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name minio.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name minio-api.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name argocd.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name vault.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name loki.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name prometheus.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name homarr.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name portainer.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name longhorn.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
kubernetes cluster.local in-addr.arpa ip6.arpa {
pods insecure
fallthrough in-addr.arpa ip6.arpa
ttl 30
}
forward . 8.8.8.8 1.1.1.1 {
max_concurrent 1000
}
cache 30 {
disable success cluster.local
disable denial cluster.local
}
loop
reload
loadbalance
}
@@ -1,130 +0,0 @@
# CoreDNS is deployed by Talos's bootstrap manifests (not a helm release we
# own). This tracks the full Deployment spec as the single source of truth
# for any changes we apply on top of the Talos default — currently just
# topologySpreadConstraints so the 2 replicas don't land on the same node.
apiVersion: apps/v1
kind: Deployment
metadata:
name: coredns
namespace: kube-system
labels:
k8s-app: kube-dns
kubernetes.io/name: CoreDNS
spec:
replicas: 2
revisionHistoryLimit: 10
selector:
matchLabels:
k8s-app: kube-dns
strategy:
rollingUpdate:
maxSurge: 25%
maxUnavailable: 1
type: RollingUpdate
template:
metadata:
labels:
k8s-app: kube-dns
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- podAffinityTerm:
labelSelector:
matchExpressions:
- key: k8s-app
operator: In
values:
- kube-dns
topologyKey: kubernetes.io/hostname
weight: 100
containers:
- args:
- -conf
- /etc/coredns/Corefile
env:
- name: GOMEMLIMIT
value: 161MiB
image: registry.k8s.io/coredns/coredns:v1.14.2
imagePullPolicy: IfNotPresent
livenessProbe:
failureThreshold: 5
httpGet:
path: /health
port: 8080
scheme: HTTP
initialDelaySeconds: 60
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 5
name: coredns
ports:
- containerPort: 53
name: dns
protocol: UDP
- containerPort: 53
name: dns-tcp
protocol: TCP
- containerPort: 9153
name: metrics
protocol: TCP
readinessProbe:
failureThreshold: 3
httpGet:
path: /ready
port: 8181
scheme: HTTP
periodSeconds: 10
successThreshold: 1
timeoutSeconds: 1
resources:
limits:
memory: 170Mi
requests:
cpu: 100m
memory: 70Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
add:
- NET_BIND_SERVICE
drop:
- ALL
readOnlyRootFilesystem: true
terminationMessagePath: /dev/termination-log
terminationMessagePolicy: File
volumeMounts:
- mountPath: /etc/coredns
name: config-volume
readOnly: true
dnsPolicy: Default
nodeSelector:
kubernetes.io/os: linux
priorityClassName: system-cluster-critical
restartPolicy: Always
schedulerName: default-scheduler
serviceAccount: coredns
serviceAccountName: coredns
terminationGracePeriodSeconds: 30
tolerations:
- effect: NoSchedule
key: node-role.kubernetes.io/control-plane
operator: Exists
- effect: NoSchedule
key: node.cloudprovider.kubernetes.io/uninitialized
operator: Exists
topologySpreadConstraints:
- labelSelector:
matchLabels:
k8s-app: kube-dns
maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
volumes:
- configMap:
defaultMode: 420
items:
- key: Corefile
path: Corefile
name: coredns
name: config-volume
-5
View File
@@ -1,5 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: kube-system
resources: []
# CoreDNS deployed via Helm chart
@@ -1,22 +0,0 @@
# Service alias for CoreDNS compatibility
# CoreDNS rewrites *.riotpiao.com → ingress-nginx-controller.ingress-nginx.svc
# But bootstrap deployed as ingress-nginx-bootstrap-controller
# This alias makes both names work
apiVersion: v1
kind: Service
metadata:
name: ingress-nginx-controller
namespace: ingress-nginx
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: ingress-nginx
app.kubernetes.io/instance: ingress-nginx-bootstrap
app.kubernetes.io/component: controller
ports:
- name: http
port: 80
targetPort: http
- name: https
port: 443
targetPort: https
+4
View File
@@ -300,6 +300,10 @@ spec:
port:
number: 8080
---
# NOTE: api.riotpiao.com (Kong) 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 with Kong itself.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
+7 -1
View File
@@ -2,6 +2,12 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# No top-level namespace - resources declare their own namespaces
resources:
- ingress-nginx-controller-alias.yaml # Service alias for CoreDNS compatibility
# ingress-nginx-controller-alias.yaml REMOVED — it was a ClusterIP Service named
# ingress-nginx-controller with a stale selector (instance: ingress-nginx-bootstrap,
# a release that no longer exists). ingress-config's selfHeal kept re-applying it
# over the helm release's real LoadBalancer Service of the same name, reverting it
# to a ClusterIP with zero endpoints -> LB IP .160 unannounced -> cluster-wide
# outage. CoreDNS rewrites *.riotpiao.com to ingress-nginx-controller.ingress-nginx
# .svc, which is the helm Service directly — no alias needed.
- riotpiao-com-cert.yaml # Certificate for *.riotpiao.com (ingress-nginx namespace)
- ingress.yaml # Ingress rules for all services (multiple namespaces)
@@ -1,26 +0,0 @@
#ENC[AES256_GCM,data:85lNBL02TeroUW8dissgxEwjnOZnb0fg7jndGim3/QSi3/hFEF8FQa9nrQ==,iv:lM1jBHIxFkQriZ6BjGRXAlymUs5nX4Kvt1WNxUeZlkU=,tag:wm/D5qVcrcu3VXcZkEj70A==,type:comment]
apiVersion: ENC[AES256_GCM,data:Vrs=,iv:0TzPcIoozs2MXJNXkzgcVtjjBUgfOHaSXQZiD37fb+Q=,tag:CabTlLwtz6RcBF/gr4Ri4g==,type:str]
kind: ENC[AES256_GCM,data:22Y5w+Df,iv:Mf2s3h8++Vxqb4JoymHXY4/WAknDZ2GGrVVtKK51JxI=,tag:k5DHUqWGMBPpLkQXADaMlw==,type:str]
metadata:
name: ENC[AES256_GCM,data:ItXKquY5a7gu8CJZog==,iv:jxbj7Qtv+DRbhzTdvtv+eJuTQPNIf497NZPYA6ld4s0=,tag:XdaaSavFBxOjvxna2kr/Tg==,type:str]
namespace: ENC[AES256_GCM,data:VH6NMg==,iv:4PfWZu5qVGXP3ZzRHMrh5N9dzJ3SoUPPo58ppcDTnpk=,tag:Vr8E+WF0Z9ym3GyTGbdi2g==,type:str]
type: ENC[AES256_GCM,data:DmbHZRIk,iv:EZHnf1h1L29G1HOBYBSBeydNe4nC8XiBOw8YEL3kxrY=,tag:pFhUSMAIEqJET3NmaajO1g==,type:str]
stringData:
username: ENC[AES256_GCM,data:ZTrmFA==,iv:1+tLTAxrDitXJwCAEccaVQzc9I9lNRgT3FsxO2NPDDc=,tag:69Wn4a8pjhdUycc/MXsn3Q==,type:str]
password: ENC[AES256_GCM,data:w7Vn8XaC1ykNrwPpJjVYg8J5KXkUbaPspu0CoceqHVdai6BFNW5rtA==,iv:jAwDFvJfQ1GkeU/qpEVUAQ6cWqxYE8nrgs+/RouyUxg=,tag:foGmYika/9uNBdsKVrspEA==,type:str]
email: ENC[AES256_GCM,data:pIOq80OjVERFCXJmx8+qTJEw,iv:mfkMj3u8W2ZX4N4IH39mZXfEp+xphS4shaJcFOD/LEE=,tag:FOSq0E7VzzOM69Bn77v2Dw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAwSi84U3FQZXJrYzA0WCtS
c1g1Sm04eFhha2huSEVCd2x3OGVUT08yejFjCmpPV0NsTVRyWTRVUkx6WktKaU1k
Vk12VGRIejRWbjQxeDZxdFUxWkgzaVUKLS0tIDRZdFNxY1AvcFhOREMxU01zTzhX
RzFmMjdjOFd4ZkxscnBIa1E2NWtRaVEK7qZXtq2VwZBwsLAulRh93TpCXCos0Vu7
fX+/oMEN6gF5VxDJ/e5C644EKUY+tSLAoh75xA5DAytOxEVdhe2ozA==
-----END AGE ENCRYPTED FILE-----
recipient: age1smu533f803gmd0jq60s2zaj9zlznajy0ca6rtewd4r37mr2hs3uqsrldfh
lastmodified: "2026-07-25T17:45:22Z"
mac: ENC[AES256_GCM,data:gi8smimG00EQbF492BVzsWOEubGdT0zSG39D+r3fgLL/wgtDqF2mgtFA0iDiy1pInt1Viu8yYpu1l/eBSD8BNW4bfrglIHOJdb1I5+19u8SMebhWCwbEcbiHaXNmVUKKapGsNjbApCApq00NXoOZuOUSUzZlh19JPvg/vd7nl2M=,iv:n8Kni4RQhj/VgIruqptVa6m0YwFJF6v2+p2boJsLT5c=,tag:Tc4gkhH7hSrJKEOJ3X3SjQ==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -1,6 +1,13 @@
# Forgejo Helm Values — Single Source of Truth
# Chart: https://codeberg.org/forgejo-contrib/forgejo-helm
# Recreate (not RollingUpdate): the gitea data volume is a single RWO PVC. With
# RollingUpdate the new pod tries to attach the PVC while the old pod still holds
# it -> "Multi-Attach error", new pod stuck Init forever, rollout wedged. Recreate
# terminates the old pod first so the PVC detaches before the new one starts.
strategy:
type: Recreate
# Disable bundled dependencies (use external CNPG + Redis instead)
postgresql-ha:
enabled: false
@@ -14,6 +21,18 @@ valkey-cluster:
redis:
enabled: false
# External SSH access for git over the LAN. The chart's ssh Service becomes a
# LoadBalancer with a stable IP from the Cilium homelab-pool (192.168.1.160/28,
# L2-announced) so `git clone ssh://[email protected]:2222/...` works from the
# LAN. gitea's sshd listens on 2222 in-pod; port 2222 is exposed directly to
# avoid needing privileged :22.
service:
ssh:
type: LoadBalancer
port: 2222
annotations:
lbipam.cilium.io/ips: "192.168.1.161"
gitea:
admin:
existingSecret: forgejo-admin
@@ -22,8 +41,10 @@ gitea:
server:
DOMAIN: forgejo.riotpiao.com
ROOT_URL: https://forgejo.riotpiao.com
SSH_DOMAIN: forgejo.riotpiao.com
SSH_PORT: 22
# SSH clone URLs advertise git.riotpiao.com:2222 (the LoadBalancer above).
SSH_DOMAIN: git.riotpiao.com
SSH_PORT: 2222
SSH_LISTEN_PORT: 2222
database:
DB_TYPE: postgres
+7 -5
View File
@@ -3,8 +3,10 @@ kind: Namespace
metadata:
name: cicd
labels:
# Baseline allows most workloads while blocking clearly dangerous configurations
# Redis needs some relaxed settings but doesn't need full privileged access
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/audit: baseline
pod-security.kubernetes.io/warn: baseline
# privileged: the forgejo-runner's dind (docker-in-docker) sidecar requires
# securityContext.privileged=true, which baseline/restricted PSS reject
# (the ReplicaSet silently creates 0 pods). gitea, redis and CNPG here are
# already privileged-tolerant.
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/audit: privileged
pod-security.kubernetes.io/warn: privileged
@@ -1,34 +0,0 @@
# ArgoCD CMP plugin for SOPS secret decryption
apiVersion: v1
kind: ConfigMap
metadata:
name: argocd-cmp-cm
namespace: argocd
data:
sops-secrets-v1.0.yaml: |
apiVersion: argoproj.io/v1alpha1
kind: ConfigManagementPlugin
metadata:
name: sops-secrets-v1.0
spec:
version: v1.0
init:
command: [sh, -c]
args:
- |
# Install sops if not present
if ! command -v sops &> /dev/null; then
wget -qO- https://github.com/getsops/sops/releases/download/v3.9.3/sops-v3.9.3.linux.amd64 > /usr/local/bin/sops
chmod +x /usr/local/bin/sops
fi
generate:
command: [sh, -c]
args:
- |
# Find all .enc.yaml files and decrypt them
find . -name '*.enc.yaml' -type f | while read -r file; do
sops -d "$file"
done
discover:
find:
glob: "**/*.enc.yaml"
+84 -21
View File
@@ -6,23 +6,11 @@ global:
# Server configuration
server:
# Ingress is managed declaratively in k8s/bootstrap/ingress/ingress.yaml
# (ssl-passthrough) instead of here — two Ingress objects for the same
# host caused undefined nginx routing behavior (502s). Do not re-enable.
ingress:
enabled: true
ingressClassName: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-passthrough: "true"
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
hosts:
- argocd.riotpiao.com
tls:
- secretName: argocd-server-tls
hosts:
- argocd.riotpiao.com
# Allow insecure mode (terminate TLS at ingress)
extraArgs:
- --insecure
enabled: false
resources:
requests:
@@ -45,7 +33,7 @@ repoServer:
cpu: 100m
memory: 256Mi
limits:
cpu: 500m
cpu: 1000m
memory: 1Gi
# Tolerations for control-plane
@@ -54,6 +42,52 @@ repoServer:
operator: Exists
effect: NoSchedule
# SOPS decryption is now done natively inside kustomize via the ksops exec
# generator (see k8s/argocd/secrets/), not a CMP sidecar. The repo-server's
# own `kustomize build --enable-alpha-plugins --enable-exec` runs ksops, which
# shells out to sops using the age key at SOPS_AGE_KEY_FILE. Install sops +
# ksops into a shared emptyDir on PATH; the repo-server container mounts them.
env:
- name: SOPS_AGE_KEY_FILE
value: /sops-age/key.txt
- name: XDG_CONFIG_HOME
value: /.config
initContainers:
- name: install-sops-ksops
image: alpine:3.20
command: [sh, -c]
args:
- |
set -e
apk add --no-cache curl tar
curl -sSL -o /custom-tools/sops \
https://github.com/getsops/sops/releases/download/v3.9.3/sops-v3.9.3.linux.amd64
chmod +x /custom-tools/sops
curl -sSL https://github.com/viaduct-ai/kustomize-sops/releases/download/v4.5.1/ksops_4.5.1_Linux_x86_64.tar.gz \
| tar -xz -C /custom-tools ksops
chmod +x /custom-tools/ksops
volumeMounts:
- mountPath: /custom-tools
name: custom-tools
volumeMounts:
- mountPath: /usr/local/bin/sops
name: custom-tools
subPath: sops
- mountPath: /usr/local/bin/ksops
name: custom-tools
subPath: ksops
- mountPath: /sops-age
name: sops-age
readOnly: true
volumes:
- name: custom-tools
emptyDir: {}
- name: sops-age
secret:
secretName: sops-age
# Controller configuration
controller:
resources:
@@ -95,12 +129,41 @@ configs:
cm:
admin.enabled: "true"
application.instanceLabelKey: argocd.argoproj.io/instance
params:
server.insecure: true
# Let every kustomize build run the ksops exec generator.
kustomize.buildOptions: --enable-alpha-plugins --enable-exec
# External URL — required so OIDC redirect URIs are built correctly.
url: https://argocd.riotpiao.com
# Local accounts (in addition to Authentik SSO):
# rock — human admin; can log in with a password AND issue API tokens.
# cicd — automation-only; apiKey (token) for the CD pipeline, no UI login.
accounts.rock: apiKey,login
accounts.cicd: apiKey
# Authentik OIDC. clientSecret pulled from the argocd `oidc-secret` Secret
# (created by authentik-provision). The groups claim drives RBAC below.
oidc.config: |
name: Authentik
issuer: https://authentik.riotpiao.com/application/o/argocd/
clientID: argocd
clientSecret: $oidc-secret:client-secret
requestedScopes:
- openid
- profile
- email
- groups
requestedIDTokenClaims:
groups:
essential: true
# RBAC (allow admin full access)
params:
server.insecure: false
# RBAC. local `admin` + `rock` + the `cicd` pipeline account all get role:admin;
# the Authentik `homelab-admins` group (rock is a member) maps to admin so SSO
# logins are admin too.
rbac:
policy.default: role:readonly
policy.csv: |
g, admin, role:admin
g, rock, role:admin
g, cicd, role:admin
g, homelab-admins, role:admin
@@ -3,8 +3,8 @@
# GitHub seed so ArgoCD can deploy everything after the control plane. After
# Forgejo is healthy + mirroring GitHub, cut over with phase5-cutover/root-app-forgejo.yaml.
#
# repoURL is the SSH form — must match the `url` in the seed-repo deploy-key Secret
# (see seed-repo-secret.example.yaml). Apply that Secret before this.
# repoURL is anonymous HTTPS — the seed repo is public, so no deploy key and no
# repository Secret are needed. Nothing to apply before this.
---
apiVersion: v1
kind: Namespace
@@ -44,7 +44,7 @@ metadata:
spec:
project: homelab
source:
repoURL: git@github.com:Riotpiaole/riotpiao.homelab.com.git # GitHub seed (SSH)
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git # GitHub seed (SSH)
targetRevision: main
path: k8s/argocd/apps
destination:
@@ -1,28 +0,0 @@
# ArgoCD repo credential for the PRIVATE GitHub seed — deploy key (read-only).
# Apply at Phase 0 BEFORE root-app-github.yaml. This is a TEMPLATE: never commit
# the real private key.
#
# ssh-keygen -t ed25519 -C "argocd@homelab" -f argocd_seed -N ""
# # add argocd_seed.pub → GitHub repo → Settings → Deploy keys (Read-only, no write)
# kubectl create ns argocd --dry-run=client -o yaml | kubectl apply -f -
# kubectl -n argocd create secret generic seed-github-repo \
# --from-literal=type=git \
# [email protected]:Riotpiaole/riotpiao.homelab.com.git \
# --from-file=sshPrivateKey=argocd_seed
# kubectl -n argocd label secret seed-github-repo argocd.argoproj.io/secret-type=repository
#
# url MUST match root-app-github.yaml's repoURL (SSH form).
apiVersion: v1
kind: Secret
metadata:
name: seed-github-repo
namespace: argocd
labels:
argocd.argoproj.io/secret-type: repository
stringData:
type: git
url: [email protected]:Riotpiaole/riotpiao.homelab.com.git
sshPrivateKey: |
-----BEGIN OPENSSH PRIVATE KEY-----
REPLACE-WITH-READ-ONLY-DEPLOY-KEY-PRIVATE-HALF — DO NOT COMMIT THE REAL KEY
-----END OPENSSH PRIVATE KEY-----
@@ -104,5 +104,7 @@ spec:
- name: docker-certs
emptyDir: {} # DinD regenerates mTLS certs on each start
- name: homelab-ca
secret:
secretName: homelab-ca
# homelab-ca is a ConfigMap (public CA trust bundle), not a Secret.
# The volumeMounts use subPath: ca.crt to project the single cert file.
configMap:
name: homelab-ca
-20
View File
@@ -1,20 +0,0 @@
authentik:
secret_key: ENC[AES256_GCM,data:55ne/khf01ZD3FP2Zek+0Ar9C+GP/GPf33PwOwgXdnOXF4kLrEwwJ/E7yrH0nkBKGO221ClGBjlo7kb5vGMmmmg8TVBa1upQbk8b0s723n8=,iv:ZkDp4gOHY1eH44cDhGaZILi8dFDTNuYf1CVE+qrapzE=,tag:JTE4tfMJSowsn9YZ0xKFng==,type:str]
bootstrap_password: ENC[AES256_GCM,data:vBk/ivCG4x2TlvURrDxHAiaZlxrF+PeHBAuoZR6muWw=,iv:ccA703HMomR58cD2/6wK1W0IKJb7U1hNws6fmwG15E8=,tag:MdhTx9ebXLjRCnDpUxpJ0g==,type:str]
bootstrap_token: ENC[AES256_GCM,data:NwGZzaL8JufXYp6sjeeN1etdgaHT8IuaZFLZJegzs+fPXMvVCGPKKlStythIOa3gwgCXkhA+kV8+jyxYKr1UMw==,iv:EJCetXnkNe+UuhWk4f3vr3kIEbCuVplVKHPne3aVIQ8=,tag:kwlE+9O6DczO+mn9Rqx+Rw==,type:str]
postgresql_password: ENC[AES256_GCM,data:+WeZKo+24awwWfTAt5q6KJRkqaHD7YmlnKm4oGaUNvw=,iv:VSJ3d8p4f5SQM2lVdhSLY5dqdQTKwcfzOCATIM5M/cw=,tag:6K8mQmyLEg7KJi3bg+zPig==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJSDJSYmRQNDdjWjlORmJX
Zm9yd2xRNi9XKy95Y2NQWUMyTGxjb1NjRTNzCnlNbko5NVJVSitiaE1DQ2QvMVoy
czRya01EaFZmZzAzcjRESG5NY1JIVTAKLS0tIFZ1UkREaGc4VUczMjFITWR6N0Ez
NStJZmpuWEw1dlRFcExZUlFLWkpBSFkK9r0NG3IKV7+AU00VXVCuHu+aBOOLydD0
ncioyDrJWgkyDxn9+BNZh4vX8vEERANYh+1/3P99Ubz28tifhmi2vg==
-----END AGE ENCRYPTED FILE-----
recipient: age1smu533f803gmd0jq60s2zaj9zlznajy0ca6rtewd4r37mr2hs3uqsrldfh
lastmodified: "2026-07-15T23:22:55Z"
mac: ENC[AES256_GCM,data:+MqbhlVnhriigDEj/AMJj4yLjcIjxkQc4QANcVeAXefcljHS4yOVtrwh2js3l9WVyIgVdl0MdlW+ycykBU4Pi3u4sdJoiJd945wZDsHvFCREYZIZdsVW/EHmKjIroqxQfeEtbWDStkaE+WTF/yGrZ2HLLztrXIafxZq7KhbXlo0=,iv:PCzhd8j539BxvpQQCds1vMXm3gfmlvSnrbtfOpFaKrY=,tag:NiHwSfQRvf//lpjFAKA1Jg==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2

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