Author SHA1 Message Date
Story Crater Bot 98c4391ca8 feat: add paperless-ngx with OIDC, CNPG db, cp-3 HDD media, MinIO backup
Fixes controlplane.tftpl's install.wipe:true (should be false, live CPs already run false) and syncs coredns Corefile back to what's actually deployed (drops an unrolled-out, stale Kong-era rewrite).
2026-08-25 11:11:37 -07:00
Story Crater Bot 7270c15169 (fix): fix sigV4 oidc issue when sign-in with authentik 2026-08-25 08:15:43 -07:00
Story Crater Bot c61443a1b6 Clean up template files 2026-08-23 16:15:31 -07:00
Story Crater Bot 0292560621 Add Forgejo registry PAT secret (encrypted with SOPS, ksops managed) 2026-08-23 16:15:26 -07:00
Story Crater Bot 1b91d2d327 Add extra disks to talos-cp-2 via PostSync job, downsize memory-db to 2 instances 2026-08-22 23:53:01 -07:00
Story Crater Bot d1a39e95a9 Fix: memory-db use default longhorn (3 replicas), 20Gi 2026-08-22 23:40:13 -07:00
Story Crater Bot 96ca165061 Fix: use longhorn-imessage-local (WaitForFirstConsumer) for stable volume binding 2026-08-22 23:36:27 -07:00
Story Crater Bot f654e9e6aa Track all poimen-* repos in AppProject for flexible service onboarding 2026-08-22 23:20:58 -07:00
Story Crater Bot 8209e8b44d Remove separate memory app, bundle into wave 2 databases 2026-08-22 23:16:40 -07:00
Story Crater Bot b100a20ba0 Add Poimen Memory to ArgoCD wave 2 deployment (namespace: poimen) 2026-08-22 23:09:02 -07:00
Story Crater Bot 5150730fe8 fix(argocd): update poimen repoURL after rock/poiman rename to rock/poimen
The poiman repo was renamed to poimen on Forgejo; the stale repoURL made
poimen-root fail with a 301 redirect ComparisonError (ArgoCD's git
client doesn't follow redirects on smart-HTTP fetch), blocking sync for
poimen-root and everything under it.
2026-08-21 21:57:36 -07:00
Story Crater Bot b9c86d699e fix(argocd): poimen-root point to k8s/argocd directory 2026-08-21 21:28:18 -07:00
Story Crater Bot 4f5747b060 fix(argocd): poimen-root point to k8s/argocd/apps like kmsvc-root 2026-08-21 21:27:38 -07:00
Story Crater Bot 119c16cea9 fix(argocd): poimen-root use single source from poiman, remove workflows 2026-08-21 21:25:55 -07:00
Story Crater Bot a192b58e0c feat(argocd): enable poimen-root Application for poiman orchestration 2026-08-21 21:24:12 -07:00
Story Crater Bot 7141922f19 chore(argocd): track main branch instead of prod for auto-sync on every commit 2026-08-21 20:55:20 -07:00
Story Crater Bot 21e741051a chore(argocd): update api-gateway to track homelab-frontend prod branch 2026-08-21 20:47:38 -07:00
Story Crater Bot 3ea45b7c2e chore(argocd): add poimen application placeholder 2026-08-21 20:44:49 -07:00
Story Crater Bot b13d7280b8 chore(argocd): add kmsvc-manage bootstrap application 2026-08-21 20:44:35 -07:00
Story Crater Bot 87ea0f1147 feat(forgejo-runner): split into golang/node/rust runners, retire generic docker runner 2026-08-21 16:49:26 -07:00
Story Crater Bot d3b6ecfb62 Add Temporal worker for production task queue 2026-08-21 16:44:59 -07:00
Story Crater Bot 76d00bcfc3 fix: restore YaRN rope-scaling for reasoning-predictor (GPTQ requant dropped it, checkpoint's own ceiling was 40960 not 131072) 2026-08-21 16:42:39 -07:00
Story Crater Bot 76d5078611 fix: swap reasoning-predictor to Qwen3-32B-GPTQ-Int4, 131072 context (bnb-4bit decode too slow, GPTQ is Volta-native) 2026-08-21 16:39:33 -07:00
Story Crater Bot 892700b38c chore(forgejo-runner): arm for cascading delete ahead of 3-runner migration 2026-08-21 16:35:01 -07:00
Story Crater Bot 3b4e6684f1 refactor(forgejo-runner): template PVC names off Release.Name for multi-instance reuse 2026-08-21 16:30:41 -07:00
Story Crater Bot 93128a104e fix: retire one reasoning-predictor replica, run PP=2 across both V100s (Qwen3.5 MoE swap abandoned, moving to Ollama) 2026-08-21 16:23:07 -07:00
Story Crater Bot 98c5429a9d fix(forgejo-runner): job containers must use host network to reach dind 2026-08-21 16:22:19 -07:00
Story Crater Bot 37a7c37945 fix(forgejo-runner): egress to ingress-nginx by namespace, not a stale LB IP 2026-08-21 16:22:19 -07:00
Story Crater Bot a6051e025b fix(forgejo-runner): allow job containers to mount /docker-certs/client so docker login/build/push work 2026-08-21 16:22:19 -07:00
Story Crater Bot c956ac1465 chore: retire TemporalWorker CRD — agent-harness-worker and Forgejo build workflow removed 2026-08-20 23:10:06 -07:00
Story Crater Bot 886f546a02 fix(forgejo): enable Actions globally so workflow runs are created
Every workflow in the cluster has been silently dead. app.ini carried no
[actions] section, so Forgejo never created a run: the API returns
total_count: 0 for rock/homelab and rock/homelab-frontend alike, despite both
repos reporting has_actions: true, cluster-ci.yaml and build.yaml sitting on
their default branches, and forgejo-runner having registered successfully.

Registration does not go through the dispatcher, which is why the runner looks
healthy -- it logs "declared successfully" and "[poller 0] launched" and then
picks up nothing, forever. That reads like a runner or label problem and is
neither.

This also explains why the api-gateway images in the registry were all built
by hand: the pipeline that was supposed to build them has never once run.

Forgejo restarts on this values change; git and the container registry are
briefly unavailable.
2026-08-20 21:40:43 -07:00
Story Crater Bot 8f277adf19 stage1: A1-A2 AppProject and projects Application
A1: Replace per-repo Forgejo entries with https://forgejo.riotpiao.com/rock/*
    wildcard so onboarding never requires touching AppProject.

A2: Add wave -1 Application for k8s/argocd/projects/ so it syncs before
    any Application references the AppProject.

Also add kustomization.yaml to k8s/argocd/projects/ to make it renderable.

Enabled by Stage 1 (A1, A2).
2026-08-20 21:31:05 -07:00
Story Crater Bot 43483da902 pi-models: fix baseUrl to match homelab-frontend gateway contract
Kong was retired 2026-08-19, replaced by the rock/homelab-frontend Go
gateway (single /v1/chat/completions endpoint, model routed via the
request body's "model" field per API.md). Old per-model baseUrls
(/v1/ornith, /v1/reasoning, /v1/qwen) all 404 against the new gateway.
Also flipping reasoning's supportsTools to true -- confirmed working via
live test now that reasoning runs Qwen3-32B instead of DeepSeek-R1.
2026-08-20 00:26:52 -07:00
Story Crater Bot cf6c4f4d7d chore: drop the Kong key-auth credential secret, unused now that Kong is gone 2026-08-19 23:40:50 -07:00
Story Crater Bot 5167656445 feat: point api.riotpiao.com at the gateway ahead of Kong removal
Kong is being deleted, so the backend cannot stay kong-proxy. Gateway serves
404 on API routes until tasks 2.1/2.2 land.
2026-08-19 23:34:33 -07:00
Story Crater Bot 6eeac820a0 fix: resolve forgejo.riotpiao.com to the ingress LB on nodes
The pinned ClusterIP died when the rev-6 upgrade recreated the Service, timing
out every node image pull.
2026-08-19 23:25:41 -07:00
Story Crater Bot abd0af3ea9 revert: point api.riotpiao.com back at kong-proxy
Gateway pods are ErrImagePull — nodes resolve forgejo.riotpiao.com to a
ClusterIP and time out, so the Service had no endpoints and the host was
returning 503. Kong is still running; this restores it.
2026-08-19 22:56:22 -07:00
Story Crater Bot 98d8276476 feat: cut api.riotpiao.com over to the Go gateway and retire Kong
Ingress api/api now backs onto api-gateway:8080; the kong Application, its
Helm values, plugins and llm-routes are removed. Gateway image v0.0.0 is in
the Forgejo registry and the pull secret is in the api namespace.
2026-08-19 22:51:53 -07:00
Story Crater Bot 5af38a3f59 fix: ignore Reloader's injected env var on the Forgejo Deployment
Argo would otherwise strip STAKATER_* on each sync and fight Reloader for it,
recreating the forge pod every reconcile.
2026-08-19 22:43:29 -07:00
Story Crater Bot 832add824d feat: manage Forgejo with Argo instead of the bootstrap Helm release
Values changes were inert as a bootstrap release, so the proxy-body-size fix
never reached the live Ingress. First sync is manual — the chart owns the
Forgejo PVC.
2026-08-19 22:37:56 -07:00
Story Crater Bot 78c1fa3fb3 fix: set proxy-body-size 0 on the Forgejo chart Ingress
Two Ingresses claim forgejo.riotpiao.com and nginx honours the older chart one,
so the annotation on the other never applied and OCI pushes over 1m got 413.
2026-08-19 22:25:51 -07:00
Story Crater Bot 92d80173b4 feat: let the runner build and the cluster pull from the Forgejo registry
- Runner egress: allow 192.168.1.160/32:443. forgejo.riotpiao.com resolves to
  the ingress LB, inside the 192.168.1.0/24 block the NetworkPolicy denies, so
  docker push hung until timeout.
- dind CA: also mount homelab-ca at /etc/docker/certs.d/forgejo.riotpiao.com/,
  the path dockerd actually reads for per-registry trust.
- Pull secret: dockerconfigjson for the api namespace; /v2/ answers 401.
- AppProject: allow the Forgejo repo as a source for api-gw.
2026-08-19 21:48:01 -07:00
Story Crater Bot bd6a21e7e1 coordinator: make gitignore/PLAN.md setup idempotent, run every phase
Old i===0 && !resuming gate meant this only ran on a fresh start -- every
run this session was a resume, so poiman's branch never got the harness
gitignore rules, and portfolio's PLAN.md stayed tracked from before the
rule existed (gitignore doesn't affect already-tracked files). Now checks
and fixes both on every phase instead of once at genesis.
2026-08-19 21:12:33 -07:00
Story Crater Bot 5589bcd56a coordinator: detect+respawn dead pool sessions, bound resolver call
Dead sessions were only caught after a full 10-min stall timeout; now
polled via agent-manager status and respawned (retry once). spawnPi had
no timeout and could hang a repo's whole pipeline forever -- bounded to
5 minutes now.
2026-08-19 20:27:17 -07:00
Story Crater Bot fd7b714f09 reasoning: raise num_cpu_blocks 32->256 for real DRAM KV offload capacity
32 blocks was a ~1GB safety-valve leftover from the num_cpu_blocks=2000
hang incident, not meaningful offload capacity. This model's KV cache is
~32MB/128-token block (64 layers, 8 KV heads x 128 head_dim, fp16) --
256 blocks gives ~8GB of real DRAM offload (32,768 tokens), comfortably
under the pod's 36Gi limit alongside the ~20GB bnb-4bit weights.
2026-08-19 18:42:37 -07:00
Story Crater Bot 6291dd5afb reasoning: swap to dense Qwen3-32B-bnb-4bit for reliable tool calling
DeepSeek-R1-distill's tool_choice=auto narration bug needed a real fix,
not a workaround -- Qwen3's native tool-call format (hermes-compatible
chat template) solves it at the source instead of parsing around it.
Dense Qwen3-32B avoids the MoE arch/quantization pitfalls hit by the two
prior swap attempts (Kimi-distilled Qwen3.6 MoE, AWQ Qwen3-30B-A3B) --
same bnb-4bit path already proven working on this sm70 (V100) node.
2026-08-19 18:40:15 -07:00
Story Crater Bot 648388554d reasoning: revert to DeepSeek-R1-Distill-32B, retire Kimi/Qwen3 swap attempt
Three straight failures on worker-1: Kimi-K2.6-distilled Qwen3.6-35B-A3B
had an unrecognized model type (qwen3_5_moe); the AWQ-4bit fallback needed
compute capability 80+ (marlin INT4 kernels) but this node's GPU is sm70
(V100); on-the-fly bitsandbytes against the full-precision Qwen3-30B-A3B
kept crash-looping. Reverting to the last known-good config (ac1849d) --
tool-call narration bug on judge remains open, to revisit separately.
2026-08-19 18:34:15 -07:00
Story Crater Bot 6ce46b9ad5 reasoning: switch to on-the-fly bnb quant, worker-1 GPU is sm70 (V100)
cpatonn's pre-quantized build failed with a real hardware constraint:
"Quantization scheme not supported for current GPU. Min capability: 80.
Current capability: 70." AWQ/GPTQ/compressed-tensors marlin INT4 kernels
all need sm80+ -- this node's GPU can't run any of them. Only bitsandbytes
or full precision work here. Switching to the official full-precision
Qwen/Qwen3-30B-A3B-Thinking-2507 with --quantization=bitsandbytes
on-the-fly, and bumping the memory limit (36Gi->48Gi, request unchanged)
for the transient bf16-shard staging during load.
2026-08-19 18:30:24 -07:00
Story Crater Bot d3a059a6b4 reasoning: fix quantization flag mismatch (compressed-tensors, not awq_marlin)
cpatonn's "AWQ-4bit" repo is actually quantized via llm-compressor --
config.json declares compressed-tensors. Passing awq_marlin explicitly
conflicted with the checkpoint's own declared format and 400d at
config-validation time.
2026-08-19 18:23:15 -07:00
Story Crater Bot f015e4577b reasoning: fall back to official Qwen3-30B-A3B-Thinking-2507 AWQ-4bit
Kimi-K2.6-distilled Qwen3.6-35B-A3B crashed on boot -- model type
qwen3_5_moe unrecognized by transformers/vLLM 0.11.0, a genuinely
unsupported architecture, not a config issue. Using cpatonn's pre-quantized
AWQ-4bit build of the official Qwen3-30B-A3B-Thinking-2507 instead: native
vLLM support confirmed, no Kimi distillation but Qwen3's own tool-call
format is natively supported (the actual root problem being solved).
Restored max-num-seqs=4 since AWQ-4bit weight footprint leaves more KV
headroom than the bnb attempts did.
2026-08-19 18:20:34 -07:00
Story Crater Bot 1877f94bf6 reasoning: halve max-num-seqs to 2 for Kimi swap's first boot
New model's weight footprint (35B total MoE at on-the-fly bnb-4bit) leaves
less confirmed KV-cache headroom on the 32GB card than the old one had --
reducing concurrent-sequence worst case until real memory use is verified.
2026-08-19 18:14:22 -07:00
Story Crater Bot c5ff86d6dc reasoning: swap DeepSeek-R1-Distill-32B for Kimi-K2.6-distilled Qwen3.6-35B-A3B
R1-family tool_choice=auto is a documented vLLM architecture conflict --
the model narrates fake tool_calls in <think> instead of emitting real
ones, regardless of parser (deepseek_v3 400s, hermes parses but the model
still doesn't call out). Qwen3's native tool-call format sidesteps this.

No pre-quantized AWQ/GPTQ/bnb checkpoint exists for this specific distill
(only GGUF, llama.cpp/Ollama-only) -- using on-the-fly bitsandbytes
quantization against the full bf16 checkpoint instead.
2026-08-19 18:11:45 -07:00
Story Crater Bot 1bf611739b fix(agent-pod): force judge to actually call tools instead of narrating
Observed live: phase-judge (on homelab-reasoning) wrote a full page of
'I should check X, then Y' reasoning, declared VERDICT: PASS, and showed
the touch command as a fenced code block in its own text -- never ran
git diff, never wrote the result file, never touched the sentinel.
Coordinator timed out waiting on a file that was never going to appear.
2026-08-19 17:13:49 -07:00
Story Crater Bot 9fbfce7963 fix(agent-pod): install rust+gcc toolchain, symlink go, drop brave-search skill
poiman is Rust, portfolio is Go -- neither toolchain was reachable from an
interactive kubectl exec session (go's PATH export was local to its own
install script; rust was entirely absent, and cargo needs gcc as a linker
which also wasn't present).

brave-search was just a curl one-liner wrapped in its own skill file --
inlined the same curl command directly into info-collector/investigator's
instructions instead of dispatching to a separate skill for it.
2026-08-19 15:59:11 -07:00
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
157 changed files with 6656 additions and 1093 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
+49
View File
@@ -0,0 +1,49 @@
# Exposes agent-hub at api.riotpiao.com/console (WebSocket) and /run
# (trigger a new session) -- both are routes on the same hub.js service.
#
# Was ingressClassName: kong until Kong was retired on 2026-08-19. Pointed
# straight at nginx rather than through the replacement Go gateway because that
# gateway has no WebSocket upgrade support yet -- routing /console through it
# would break the console outright. nginx handles the upgrade natively.
#
# Path precedence: the nginx Ingress api/api catch-alls `/` on this same host
# to the gateway. nginx matches longest prefix first, so these three paths win
# over `/` and the rest of the host still reaches the gateway.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: console
namespace: agent-pod
annotations:
# A console WebSocket stays open across a whole agent session; nginx's 60s
# default read timeout would drop it mid-run.
nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
nginx.ingress.kubernetes.io/proxy-buffering: "off"
spec:
ingressClassName: nginx
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,982 @@
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");
// agent-manager's own session-state DB -- used to detect a session that has
// actually died (process crashed/exited, status flips to "errored"/"dead")
// instead of one that's merely slow. Read-only introspection plus the one
// UPDATE in killDeadSession below, same class of operation as the tmux
// nudges already done directly against agent-manager's internals.
const AGENT_MANAGER_DB =
process.env.AGENT_MANAGER_DB || path.join(require("node:os").homedir(), ".config", "agent-manager", "state.db");
// 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. ";
// judge (routed to homelab-reasoning) has been observed narrating an
// entire review in prose -- "I should run git diff, then check X..." --
// and then writing a verdict based on that narration without ever calling
// a real tool. Live example: a phase-judge call produced a page of
// "I would check..." reasoning, declared VERDICT: PASS, and showed the
// touch command as a fenced code block IN ITS OWN TEXT rather than
// executing it. Coordinator just timed out waiting on a sentinel that was
// never going to appear, since nothing was ever actually run. Spelled out
// explicitly since "use the judge skill" alone apparently isn't enough to
// rule this out.
const REQUIRE_REAL_TOOL_CALLS =
"Do not narrate what you would check -- actually run the commands via a real tool call and read their real " +
"output before writing anything. A verdict based on describing checks instead of executing them is invalid. " +
"Writing the verdict file and touching the sentinel are themselves tool calls you must execute, not text to " +
"display in your response. ";
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.
//
// Bounded by SPAWN_PI_TIMEOUT_MS -- unlike runOnPool's pooled sessions
// (which now have status polling to catch a dead session fast, see
// waitForSentinel/killDeadSession), this is a raw child_process with no
// equivalent escape hatch. Observed live: a resolver call shared the
// default backend with a concurrently-busy repo's implementer and sat for
// 6+ minutes producing nothing -- with no timeout here, that blocks the
// entire calling repo's pipeline forever, since askResolver is always
// awaited before the next stage can run.
const SPAWN_PI_TIMEOUT_MS = 5 * 60 * 1000;
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) => {
let settled = false;
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill("SIGKILL");
resolve({ code: null, lastText, stderrTail, timedOut: true });
}, SPAWN_PI_TIMEOUT_MS);
child.on("close", (code) => {
if (settled) return;
settled = true;
clearTimeout(timer);
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, same as
// before), as soon as target's agent-manager status flips to "errored" or
// "dead" (polled -- state.db has no watch mechanism), or after limitMs with
// neither. A session that has actually crashed will never touch the
// sentinel, so without the status poll this just burns the full STAGE_
// TIMEOUT_MS waiting on a file that was never coming, same as a genuine
// stall -- polling status catches that in ~pollMs instead.
function waitForSentinel(filePath, target, limitMs, pollMs = 5000) {
return new Promise((resolve) => {
if (fs.existsSync(filePath)) return resolve({ ok: true });
const dir = path.dirname(filePath);
const id = target.replace(/^am_/, "");
let settled = false;
let watcher;
let poller;
let timer;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
clearInterval(poller);
if (watcher) {
try {
watcher.close();
} catch {
// already closed
}
}
resolve(result);
};
try {
watcher = fs.watch(dir, () => {
if (fs.existsSync(filePath)) finish({ ok: 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({ timedOut: true });
}
// Closes the race between the existsSync check above and the watcher
// actually being attached.
if (fs.existsSync(filePath)) return finish({ ok: true });
poller = setInterval(async () => {
const { out } = await runCmd("sqlite3", [AGENT_MANAGER_DB, `SELECT status FROM sessions WHERE id='${id}'`]);
const status = out.trim();
if (status === "errored" || status === "dead") finish({ dead: true, status });
}, pollMs);
timer = setTimeout(() => finish({ timedOut: true }), limitMs);
});
}
// Kills a session that's actually crashed (not just slow) and archives it
// in agent-manager's own DB so it stops showing up as a live, unattended
// pane -- otherwise every crash leaves an orphaned tmux session + state.db
// row behind permanently, identical to the manually-cleaned-up poiman-
// planner ghost session found earlier this same run.
async function killDeadSession(target) {
await runAmTmux(["kill-session", "-t", target]);
const id = target.replace(/^am_/, "");
await runCmd("sqlite3", [AGENT_MANAGER_DB, `UPDATE sessions SET archived=1 WHERE id='${id}'`]);
}
// 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.
// A dead/errored session gets one respawn-and-retry (same prompt, fresh
// session) before this stage is abandoned -- matches resolver-SKILL.md's
// own documented contract of retrying a failed stage at most once.
const DEAD_SESSION_RETRIES = 1;
async function runOnPool(pool, cwd, repoId, role, prompt, sentinelFile) {
fs.rmSync(sentinelFile, { force: true });
const label = `${repoId}-${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`;
const spawnFresh = async () => {
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);
return spawned.code === 0 ? amSessionName(spawned.out) : null;
};
let target = pool[role];
if (!target) {
target = await spawnFresh();
if (!target) return { ok: false, crashed: true, error: "spawn failed", sessionName: label };
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"]);
}
for (let deadRetries = 0; ; deadRetries++) {
const outcome = await waitForSentinel(sentinelFile, target, STAGE_TIMEOUT_MS);
if (outcome.ok) return { ok: true, sessionName: label };
if (outcome.dead) {
await killDeadSession(target);
if (pool[role] === target) delete pool[role];
if (deadRetries >= DEAD_SESSION_RETRIES) {
return { ok: false, crashed: true, error: `session died (status: ${outcome.status})`, sessionName: label };
}
target = await spawnFresh();
if (!target) return { ok: false, crashed: true, error: "respawn after death failed", sessionName: label };
pool[role] = target;
continue;
}
// Plain stall -- session still alive, just slow. Ask resolver once,
// nudge if it says worth it, and stop here either way (this is not
// the death path, so no respawn/retry loop).
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?`
);
let ok = false;
if (resolution === "RETRY") {
await runAmTmux(["send-keys", "-t", target, `Please wrap up now and run: touch ${sentinelFile}`, "Enter"]);
const nudged = await waitForSentinel(sentinelFile, target, NUDGE_TIMEOUT_MS);
ok = nudged.ok === true;
if (nudged.dead) {
await killDeadSession(target);
if (pool[role] === target) delete pool[role];
}
}
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 (
`${REQUIRE_REAL_TOOL_CALLS}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");
}
// Idempotent and run every phase, NOT gated on a fresh (non-resumed)
// start -- every run this session was a resume, so the old i===0 &&
// !resuming gate meant this setup permanently never ran on poiman's
// branch, and portfolio's PLAN.md stayed tracked from before this rule
// ever existed (gitignore has no effect on an already-tracked file --
// observed live: it kept getting swept back in by every `git add -A`
// regardless of the ignore rule). Check-and-fix on every phase instead
// of once-at-genesis so a repo that's missing either self-heals on its
// very next run rather than carrying the gap forever.
const gitignorePath = path.join(cwd, ".gitignore");
const currentGitignore = fs.existsSync(gitignorePath) ? fs.readFileSync(gitignorePath, "utf8").split("\n") : [];
const requiredGitignoreLines = [
"*.tar.gz",
"*.tgz",
"*.crate",
"*.zip",
"*.bin",
"*.whl",
"vendor/",
"node_modules/",
".task-result-*",
".phase-result-*",
".stage-done-*",
"PLAN.md",
];
const missingGitignoreLines = requiredGitignoreLines.filter((line) => !currentGitignore.includes(line));
if (missingGitignoreLines.length > 0) {
fs.appendFileSync(
gitignorePath,
"\n# agent-harness: build artifacts, vendored archives, and harness bookkeeping never belong in source control\n" +
missingGitignoreLines.join("\n") +
"\n"
);
await runGit(cwd, ["add", ".gitignore"]);
await runGit(cwd, ["commit", "-m", "chore: broaden .gitignore for agent-run artifacts"]);
}
const trackedFiles = await runGit(cwd, ["ls-tree", "-r", "HEAD", "--name-only"]);
if (trackedFiles.out.split("\n").includes("PLAN.md")) {
await runGit(cwd, ["rm", "--cached", "PLAN.md"]);
await runGit(cwd, ["commit", "-m", "chore: untrack PLAN.md (already gitignored, was committed pre-rule)"]);
}
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",
`${REQUIRE_REAL_TOOL_CALLS}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 };
+168
View File
@@ -0,0 +1,168 @@
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 gcc build-essential
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 .)
# Language toolchains for whatever repos the implementer/investigator/
# judge roles actually build and test -- go was already fetched above
# only for building agent-manager itself, and its PATH export above is
# local to this script, invisible to `kubectl exec` sessions into the
# already-running container. Symlinking both into /usr/local/bin (on
# PATH for every exec session, interactive or not) instead of relying
# on shell rc sourcing, which pi's non-interactive tool calls don't do.
ln -sf /usr/local/go/bin/go /usr/local/bin/go
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
ln -sf /root/.cargo/bin/cargo /usr/local/bin/cargo
ln -sf /root/.cargo/bin/rustc /usr/local/bin/rustc
ln -sf /root/.cargo/bin/rustup /usr/local/bin/rustup
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
- 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
+122
View File
@@ -0,0 +1,122 @@
apiVersion: v1
data:
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) — search the web (`curl` against `https://api.search.brave.com/res/v1/web/search`, header `X-Subscription-Token: $BRAVE_API_KEY`, `--data-urlencode "q=<query>"`) 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), search the web (`curl` against `https://api.search.brave.com/res/v1/web/search`, header `X-Subscription-Token: $BRAVE_API_KEY`) 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
+46
View File
@@ -0,0 +1,46 @@
# 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 the gateway.
#
# Backend was kong-proxy:80 until Kong was retired on 2026-08-19; it is now the
# Go gateway's Service, api-gateway:8080, deployed from rock/homelab-frontend.
# Reverting the cutover is a change to these two lines and nothing else.
#
# Catch-all `/` on purpose: everything under this host belongs to the gateway.
# Listing per-API paths here would duplicate the gateway's routing table inside
# nginx, and the two copies would drift.
#
# In-cluster callers should prefer http://api-gateway.api.svc.cluster.local:8080
# 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: api-gateway
port:
number: 8080
+14
View File
@@ -0,0 +1,14 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# Explicit allowlist. Anything added to this directory and not listed here is
# silently dropped — no error, no drift shown.
#
# Down to a single Ingress since Kong was retired (2026-08-19). The Kong Helm
# values, the KongClusterPlugin for Prometheus, the six KongPlugin CRs behind
# the path-per-model LLM surface, the KongConsumer and the key-auth plan all
# went with it.
resources:
- ingress.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.
@@ -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.
+108
View File
@@ -0,0 +1,108 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
annotations:
serving.kserve.io/deploymentMode: RawDeployment
# The konghq.com/{connect,read,write}-timeout annotations that used to live
# here went with Kong (retired 2026-08-19). They existed because Kong read
# its upstream timeouts off the Kubernetes Service, and its 60s default cut
# off the first request after any pod restart — a restart flushes VRAM and
# reloading ornith:35b takes longer than that. OLLAMA_KEEP_ALIVE=-1 hid the
# problem in steady state.
#
# The equivalent budget now belongs to the Go gateway's per-route timeout
# config in rock/homelab-frontend, not to an annotation on this object.
labels:
app.kubernetes.io/name: llm-ornith
app.kubernetes.io/part-of: llm-serving
name: ornith
namespace: llm-serving
spec:
predictor:
containers:
- command:
- /bin/sh
- -c
- 'set -e
ollama serve &
SERVE_PID=$!
until ollama list >/dev/null 2>&1; do sleep 2; done
ollama pull ornith:35b
ollama 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
+122
View File
@@ -0,0 +1,122 @@
apiVersion: serving.kserve.io/v1beta1
kind: InferenceService
metadata:
annotations:
serving.kserve.io/deploymentMode: RawDeployment
labels:
app.kubernetes.io/name: llm-reasoning
app.kubernetes.io/part-of: llm-serving
name: reasoning
namespace: llm-serving
spec:
predictor:
containers:
- args:
# bnb-4bit retired: no int4 tensor cores on sm70/V100, dequant-then-
# matmul is two slow kernel launches instead of one fused int4 GEMM,
# decode crawled at 2.5-10 tok/s regardless of TP/PP. Switched to
# JunHowie/Qwen3-32B-GPTQ-Int4 -- same dense Qwen3-32B weights, same
# hermes/qwen3 parser stack (no narration-bug risk, same as before),
# only the quant format changes. Plain (non-Marlin) GPTQ kernel is
# confirmed Volta-compatible; Marlin needs sm80+ and vLLM would try
# to auto-upgrade to it, so --quantization is pinned explicitly to
# `gptq` to force the plain kernel. Verified checkpoint size: 19.34GB
# (summed from the real safetensors index, not bits-per-param math).
# max-model-len=131072 is Qwen3-32B's real ceiling (config.json YaRN:
# factor=4.0, original_max_position_embeddings=32768) -- 200k was
# asked for but exceeds this architecturally regardless of VRAM.
# KV cache math: 256KB/token total (64 layers, 8 KV heads, 128
# head_dim, fp16), PP=2 splits both weights and KV load ~evenly, so
# each GPU carries ~9.67GB weights + ~128KB/token KV. At
# gpu-memory-utilization=0.90 (28.8GB/GPU usable), that leaves
# ~19.1GB/GPU for KV cache -> ~156k tokens/GPU capacity, comfortably
# above the 131072 target with room to spare -- the old
# OffloadingConnector CPU-DRAM spillover (tuned for the previous
# model's much smaller 16384 context) is no longer needed and is
# dropped. Staying on PP=2 and vLLM 0.11.0 (no version bump needed,
# this checkpoint only requires vllm>=0.9.2) -- plain GPTQ has no
# TP>1 restriction unlike bnb, so tensor-parallel-size=2 is worth
# trying later, but not risking a parallelism-strategy change in the
# same rollout as the quant+context-length change.
# This GPTQ requant's own config.json ships max_position_embeddings=
# 40960 and rope_scaling=None -- confirmed directly (curl'd the raw
# config.json), the base Qwen3-32B repo's YaRN block did NOT carry
# over during quantization. Re-applying it explicitly here restores
# the same math the base model documents (32768 * 4.0 = 131072);
# without this, --max-model-len=131072 fails ModelConfig validation
# against the checkpoint's own (unscaled) 40960 ceiling.
- --model=JunHowie/Qwen3-32B-GPTQ-Int4
- --served-model-name=reasoning
- --quantization=gptq
- --dtype=float16
- --kv-cache-dtype=auto
- --rope-scaling={"rope_type":"yarn","factor":4.0,"original_max_position_embeddings":32768}
- --tensor-parallel-size=1
- --pipeline-parallel-size=2
- --max-model-len=131072
- --gpu-memory-utilization=0.90
- --max-num-seqs=4
- --enable-chunked-prefill
- --enable-prefix-caching
# qwen3 is vLLM's dedicated reasoning parser for this family's <think>
# blocks.
- --reasoning-parser=qwen3
# hermes is the documented tool-call parser for general (non-Coder)
# Qwen3 models -- native chat template support, not narrated text.
- --enable-auto-tool-choice
- --tool-call-parser=hermes
- --host=0.0.0.0
- --port=8080
env:
- name: VLLM_USE_FLASHINFER_SAMPLER
value: '0'
- name: VLLM_ATTENTION_BACKEND
value: TRITON_ATTN
- name: HF_HOME
value: /mnt/models
image: vllm/vllm-openai:v0.11.0@sha256:014a95f21c9edf6abe0aea6b07353f96baa4ec291c427bb1176dc7c93a85845c
name: kserve-container
ports:
- containerPort: 8080
protocol: TCP
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 10
resources:
limits:
cpu: '16'
memory: 36Gi
nvidia.com/gpu: '2'
requests:
cpu: '8'
memory: 12Gi
nvidia.com/gpu: '2'
startupProbe:
failureThreshold: 80
httpGet:
path: /health
port: 8080
periodSeconds: 15
volumeMounts:
- mountPath: /mnt/models
name: models
- mountPath: /dev/shm
name: shm
deploymentStrategy:
type: Recreate
maxReplicas: 1
minReplicas: 1
nodeSelector:
kubernetes.io/hostname: worker-1
runtimeClassName: nvidia
volumes:
- name: models
persistentVolumeClaim:
claimName: llm-models
- emptyDir:
medium: Memory
sizeLimit: 2Gi
name: shm
+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,13 @@ 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: ["coordination.k8s.io"]
resources: ["leases"]
@@ -27,9 +27,6 @@ rules:
- apiGroups: [""]
resources: ["pods", "nodes"]
verbs: ["get"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
@@ -1,62 +0,0 @@
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: temporalworkers.kmsvc.io
spec:
group: kmsvc.io
names:
kind: TemporalWorker
plural: temporalworkers
singular: temporalworker
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
required:
- spec
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
type: object
description: Temporal worker specification
properties:
namespace:
type: string
description: Temporal namespace
taskQueue:
type: string
description: Task queue name
workflowTypes:
type: array
items:
type: string
description: List of workflow types to execute
activityTypes:
type: array
items:
type: string
description: List of activity types to execute
concurrency:
type: integer
minimum: 1
description: Worker concurrency level
status:
type: object
description: Temporal worker status
properties:
ready:
type: boolean
lastHeartbeat:
type: string
format: date-time
error:
type: string
+94
View File
@@ -0,0 +1,94 @@
# Nightly: pg_dump the paperless DB + mirror the media PVC into the scoped
# `paperless` MinIO bucket (see minio-provision-paperless-job.yaml). This is a
# BACKUP target, not live storage - paperless-ngx has no native S3 backend, it
# only ever reads/writes the local media PVC directly.
#
# Pinned to talos-cp-3, same as deployment.yaml: media is a ReadWriteOnce
# Longhorn volume with a single replica physically on that node's disk -
# mounting it read-only here from a different node would conflict with the
# live webserver's attachment.
apiVersion: batch/v1
kind: CronJob
metadata:
name: paperless-backup
spec:
schedule: "0 3 * * *" # 03:00 daily, low-traffic window
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: talos-cp-3
initContainers:
- name: pg-dump
image: postgres:16-alpine
env:
- name: PGHOST
value: paperless-db-rw
- name: PGDATABASE
value: paperless
- name: PGUSER
valueFrom:
secretKeyRef:
name: paperless-db-app
key: username
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: paperless-db-app
key: password
command:
- sh
- -c
- pg_dump --format=custom --file=/backup/paperless-db.dump
volumeMounts:
- name: backup
mountPath: /backup
containers:
- name: mc-mirror
image: minio/mc:latest
env:
- name: ACCESS_KEY
valueFrom:
secretKeyRef:
name: paperless-minio-creds
key: ACCESS_KEY
- name: SECRET_KEY
valueFrom:
secretKeyRef:
name: paperless-minio-creds
key: SECRET_KEY
- name: BUCKET
valueFrom:
secretKeyRef:
name: paperless-minio-creds
key: BUCKET
- name: ENDPOINT
valueFrom:
secretKeyRef:
name: paperless-minio-creds
key: ENDPOINT
command:
- /bin/sh
- -c
- |
set -e
mc alias set b "$ENDPOINT" "$ACCESS_KEY" "$SECRET_KEY"
mc cp /backup/paperless-db.dump "b/$BUCKET/db/paperless-db-$(date +%Y%m%d).dump"
mc mirror --overwrite /media "b/$BUCKET/media"
echo "Backup done."
volumeMounts:
- name: backup
mountPath: /backup
- name: media
mountPath: /media
readOnly: true
volumes:
- name: backup
emptyDir: {}
- name: media
persistentVolumeClaim:
claimName: paperless-media
readOnly: true
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: paperless-config
data:
PAPERLESS_URL: "https://paperless.riotpiao.com"
PAPERLESS_TIME_ZONE: "America/Los_Angeles"
PAPERLESS_OCR_LANGUAGE: "eng"
PAPERLESS_DBHOST: "paperless-db-rw"
PAPERLESS_DBNAME: "paperless"
PAPERLESS_REDIS: "redis://paperless-redis:6379"
# django-allauth generic OIDC provider. The client_id/secret/server_url
# bundle itself lives in the paperless-oidc Secret
# (SOCIALACCOUNT_PROVIDERS_JSON key, composed by authentik-provision.py) -
# env vars can't be split across a ConfigMap + Secret for the same key, so
# this whole value is sourced from the Secret in deployment.yaml instead.
PAPERLESS_APPS: "allauth.socialaccount.providers.openid_connect"
+86
View File
@@ -0,0 +1,86 @@
# Single container runs webserver + consumer + scheduler (paperless-ngx's
# stock entrypoint does this internally) - no need to split into separate
# Deployments. replicas: 1 only: paperless-media is ReadWriteOnce, and the
# consumer polling the media dir doesn't benefit from horizontal scaling here.
#
# Pinned to talos-cp-3: paperless-media's disk physically lives there. Longhorn
# RWO volumes can only be attached from one node at a time, and the nightly
# backup-cronjob.yaml also mounts this same PVC (read-only) to mirror it into
# MinIO - pinning both to the same node avoids a cross-node attach conflict,
# and keeps the 3.5Ti read/write path off the network entirely.
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperless
spec:
replicas: 1
strategy:
type: Recreate # ReadWriteOnce media PVC - avoid two pods fighting over it
selector:
matchLabels:
app: paperless
template:
metadata:
labels:
app: paperless
spec:
nodeSelector:
kubernetes.io/hostname: talos-cp-3
containers:
- name: paperless
image: ghcr.io/paperless-ngx/paperless-ngx:2.13
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: paperless-config
env:
- name: PAPERLESS_DBUSER
valueFrom:
secretKeyRef:
name: paperless-db-app
key: username
- name: PAPERLESS_DBPASS
valueFrom:
secretKeyRef:
name: paperless-db-app
key: password
- name: PAPERLESS_SECRET_KEY
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_SECRET_KEY
- name: PAPERLESS_ADMIN_USER
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_ADMIN_USER
- name: PAPERLESS_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: paperless-secrets
key: PAPERLESS_ADMIN_PASSWORD
- name: PAPERLESS_SOCIALACCOUNT_PROVIDERS
valueFrom:
secretKeyRef:
name: paperless-oidc
key: SOCIALACCOUNT_PROVIDERS_JSON
resources:
requests: { cpu: "500m", memory: "1Gi" }
limits: { cpu: "2", memory: "4Gi" }
volumeMounts:
- name: media
mountPath: /usr/src/paperless/media
- name: data
mountPath: /usr/src/paperless/data
- name: consume
mountPath: /usr/src/paperless/consume
volumes:
- name: media
persistentVolumeClaim:
claimName: paperless-media
- name: data
persistentVolumeClaim:
claimName: paperless-data
- name: consume
emptyDir: {}
+24
View File
@@ -0,0 +1,24 @@
# Direct nginx ingress to the paperless Service - not routed via the Go
# api-gateway (api.riotpiao.com), which has no WebSocket upgrade support and
# paperless-ngx keeps a long-lived /ws/ connection open for live task status.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: paperless
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "0" # large scanned PDF uploads
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
spec:
ingressClassName: nginx
rules:
- host: paperless.riotpiao.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: paperless
port:
number: 8000
+15
View File
@@ -0,0 +1,15 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: paperless
resources:
- pvc.yaml
- configmap.yaml
- redis.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- backup-cronjob.yaml
# postgres: paperless-db CNPG Cluster, deployed by k8s/infra/databases (wave 2,
# before this app at wave 8) - not duplicated here. Same for the paperless-oidc
# and paperless-minio-creds Secrets, written by PostSync provisioning Jobs in
# k8s/infra/iam and k8s/infra/minio respectively.
+33
View File
@@ -0,0 +1,33 @@
# Two volumes, deliberately separate storage classes:
#
# - media: the actual documents (originals + OCR'd archive PDFs + thumbnails).
# Grows to multi-TB, lives on the cp-3 USB HDD, single replica (see
# k8s/infra/longhorn/longhorn-paperless-storageclass.yaml). Sized 3500Gi to
# leave headroom on the 4TB disk rather than claiming it to 100%.
# - data: the SQLite classification model + search index. Small (low GB),
# frequently rewritten, and disposable (rebuilds from the DB + media on
# next consume) - stays on the default 3-replica pool instead of the
# single-disk HDD.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-media
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn-paperless-media
resources:
requests:
storage: 3500Gi
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: paperless-data
spec:
accessModes:
- ReadWriteOnce
storageClassName: longhorn
resources:
requests:
storage: 5Gi
+37
View File
@@ -0,0 +1,37 @@
# Task queue broker + websocket channel layer for paperless-ngx. No PVC:
# queued/scheduled task state is disposable - a lost queue on restart just
# means re-triggering consumption, not data loss (documents themselves live
# on paperless-media).
apiVersion: apps/v1
kind: Deployment
metadata:
name: paperless-redis
spec:
replicas: 1
selector:
matchLabels:
app: paperless-redis
template:
metadata:
labels:
app: paperless-redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
resources:
requests: { cpu: "50m", memory: "64Mi" }
limits: { cpu: "250m", memory: "256Mi" }
---
apiVersion: v1
kind: Service
metadata:
name: paperless-redis
spec:
selector:
app: paperless-redis
ports:
- port: 6379
targetPort: 6379
+10
View File
@@ -0,0 +1,10 @@
apiVersion: v1
kind: Service
metadata:
name: paperless
spec:
selector:
app: paperless
ports:
- port: 8000
targetPort: 8000
+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
+18
View File
@@ -0,0 +1,18 @@
apiVersion: kmsvc.io/v1
kind: TemporalWorker
metadata:
name: worker-production
namespace: temporal
spec:
namespace: production
taskQueue: worker-production
concurrency: 10
workflowTypes:
- HelloWorldWorkflow
- GreeterWorkflow
- ProcessOrderWorkflow
activityTypes:
- GreetActivity
- ValidateOrderActivity
- ProcessPaymentActivity
- NotifyCustomerActivity
+25
View File
@@ -0,0 +1,25 @@
# Wave -1 — AppProject definitions (must sync before any Application that references them).
# Syncs k8s/argocd/projects/ which was previously applied by hand.
# Enabled by Stage 1 (A2).
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: argocd-projects
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
annotations:
argocd.argoproj.io/sync-wave: "-1"
spec:
project: homelab
revisionHistoryLimit: 3
syncPolicy:
automated:
prune: true
selfHeal: true
source:
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/argocd/projects
destination:
server: https://kubernetes.default.svc
-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:
+119 -6
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:
@@ -79,18 +79,83 @@ spec:
prune: true
selfHeal: true
---
# Forgejo runner (local chart). Forgejo itself is Phase 0 (bootstrap).
# Forgejo itself. Was a bootstrap Helm release (phase 3) until it was brought
# under Argo, because values changes there were inert — a proxy-body-size fix
# sat committed while the live Ingress kept nginx's 1m default and rejected
# every OCI push with 413.
#
# Wave 3: after databases (wave 2) — Forgejo needs CNPG and Redis up first.
#
# Retiring the Helm release: Argo adopts the existing objects on first sync.
# Delete the release secrets afterwards so helm stops claiming ownership:
# kubectl -n cicd delete secret -l owner=helm,name=forgejo
#
# automated sync is deliberately absent. This chart owns the Forgejo PVC and
# the git forge itself; the first sync is manual so its diff can be read before
# anything is applied. Turn on automated+selfHeal once that diff is clean.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: forgejo-runner
name: forgejo
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3"
spec:
project: homelab
sources:
- repoURL: https://dl.gitea.com/charts/
chart: gitea
targetRevision: 12.7.0
helm:
valueFiles:
- $values/k8s/bootstrap/phase3-forgejo/forgejo-values.yaml
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
ref: values
destination:
server: https://kubernetes.default.svc
namespace: cicd
# Reloader injects a STAKATER_* env var carrying a hash of the config Secret,
# so the pod rolls when that Secret changes. The chart does not render it, so
# Argo would strip it on every sync — and with selfHeal on, Argo and Reloader
# would fight over the field and Recreate the forge each round.
ignoreDifferences:
- group: apps
kind: Deployment
name: forgejo-gitea
jqPathExpressions:
- '.spec.template.spec.containers[].env[] | select(.name | startswith("STAKATER_"))'
syncPolicy:
syncOptions:
# Adopt the objects the bootstrap Helm release already created rather
# than failing on "already exists".
- ServerSideApply=true
---
# Forgejo runners (local chart, one instance per language), replacing the
# single generic "docker"-labeled runner. Each instance is a full standalone
# Deployment with its own dind sidecar, own PVCs (registration + layer
# cache) and own registered label -- there is no shared generic runner
# anymore, so each instance also builds and pushes images for the repos it
# serves (the chart's ConfigMap/NetworkPolicy fixes for that -- valid_volumes,
# network: host, egress to ingress-nginx -- apply identically to all three).
#
# `values.yaml` is the chart's default and doubles as the golang instance's
# config; node and rust layer a small values-<lang>.yaml override on top for
# just runner.name/runner.labels. All three share one runner-token Secret
# (Forgejo registration tokens are reusable across multiple runners, unlike
# GitHub's one-time tokens) -- if that assumption is ever wrong, registration
# will fail loudly in the register initContainer's logs, not silently.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: forgejo-runner-golang
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3"
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:
@@ -100,3 +165,51 @@ spec:
automated:
prune: true
selfHeal: true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: forgejo-runner-node
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3"
spec:
project: homelab
source:
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/infra/forgejo-runner
helm:
valueFiles:
- values-node.yaml
destination:
server: https://kubernetes.default.svc
namespace: cicd
syncPolicy:
automated:
prune: true
selfHeal: true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: forgejo-runner-rust
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "3"
spec:
project: homelab
source:
repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/infra/forgejo-runner
helm:
valueFiles:
- values-rust.yaml
destination:
server: https://kubernetes.default.svc
namespace: cicd
syncPolicy:
automated:
prune: true
selfHeal: true
+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:
+63
View File
@@ -0,0 +1,63 @@
# Wave 7 — api-gw, the cluster's API gateway (namespace `api`).
#
# Replaces Kong OSS 3.4.1, removed 2026-08-19. Kong existed to route
# `api.riotpiao.com`, but Kong OSS cannot dispatch on a request body, so the
# LLM surface had to be expressed as one path per model
# (`/v1/reasoning/chat/completions`, `/v1/ornith/...`, `/v1/qwen/...`) with a
# `request-transformer` plugin forcing the body's `model` field on each. The Go
# gateway reads the body and picks the upstream, so a single canonical
# `POST /v1/chat/completions` covers every model. See
# docs/adr/ADR-0001-retire-kong-for-go-gateway.md in the frontend repo.
#
# UPDATED 2026-08-22: Tracks main branch of homelab-frontend (auto-syncs on each push).
# Image built on every main commit with tag <commit-sha>.
# ArgoCD auto-pulls the latest image (live reconciliation ~3min).
#
# Two sources:
# 1. rock/homelab-frontend on the in-cluster Forgejo (prod branch) — the gateway's own
# kustomization (Deployment, Service, ConfigMap, RBAC, NetworkPolicy). It
# sets `namespace: api` itself, so no transformer is needed here. The
# Forgejo host must stay listed in the `homelab` AppProject sourceRepos or
# this Application is rejected with "is not permitted in project".
# 2. k8s/apps/api in this repo — the nginx edge Ingress for
# api.riotpiao.com, inherited from the retired `kong` Application. It
# cannot move to k8s/bootstrap/ingress/ingress.yaml because that syncs in
# wave 1, before namespace `api` exists.
#
# No resources-finalizer: deleting this Application leaves the workload running
# rather than cascading the delete.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: api-gw
namespace: argocd
labels:
app.kubernetes.io/name: api-gateway
app.kubernetes.io/component: gateway
annotations:
argocd.argoproj.io/sync-wave: "7"
spec:
project: homelab
revisionHistoryLimit: 3
sources:
- repoURL: https://forgejo.riotpiao.com/rock/homelab-frontend.git
targetRevision: main
path: k8s
- 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
retry:
limit: 5
backoff:
duration: 5s
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 (api-gw), so the predictor Services exist before the
# gateway that routes to 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
+73 -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,9 +19,12 @@ 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
- repoURL: https://github.com/Riotpiaole/riotpiao.homelab.com.git
targetRevision: main
path: k8s/apps/temporal
destination:
server: https://kubernetes.default.svc
namespace: temporal
@@ -49,7 +51,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 +74,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 +90,79 @@ 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
selfHeal: true
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
---
# Document management. Raw manifests (no Helm): postgres is the dedicated
# paperless-db CNPG cluster in k8s/infra/databases (wave 2), redis is
# in-cluster only (no PVC), media lives on the cp-3 USB HDD (see
# k8s/infra/longhorn/longhorn-paperless-storageclass.yaml). OIDC via
# Authentik provisioned by k8s/infra/iam's PostSync job; MinIO backup bucket
# creds provisioned by k8s/infra/minio's PostSync job.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: paperless
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/paperless
destination:
server: https://kubernetes.default.svc
namespace: paperless
syncPolicy:
automated:
prune: true
@@ -126,10 +188,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:
+28
View File
@@ -0,0 +1,28 @@
# kmsvc-manage bootstrap — manages itself and its supporting services
# (Strimzi/Kafka, Redis, queue-operator, message-plane server) from
# the kmsvc-manage repo's own k8s/argocd/ structure on the main branch.
# Image built on every main commit, auto-deployed to sqs namespace.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: kmsvc-root
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "6"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/rock/kmsvc-manage.git
targetRevision: main
path: k8s/argocd/apps
directory:
recurse: false
destination:
server: https://kubernetes.default.svc
namespace: sqs
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
+39
View File
@@ -0,0 +1,39 @@
# Poimen project collection — manages poimen-memory, poimen-workflows, and poiman
# Each repo tracks its own main branch (no prod branch). Poiman is the primary
# orchestrator with k8s/argocd/ containing the AppProject and deployment structure.
#
# CI: All three repos trigger on main branch pushes (no image builds yet).
# Future: Add build workflows for poiman once container runtime needs are clear.
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: poimen-root
namespace: argocd
labels:
app.kubernetes.io/name: poimen
app.kubernetes.io/component: orchestrator
annotations:
argocd.argoproj.io/sync-wave: "7"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/rock/poimen.git
targetRevision: main
path: k8s/argocd
directory:
recurse: false
destination:
server: https://kubernetes.default.svc
namespace: poimen
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
retry:
limit: 5
backoff:
duration: 5s
factor: 2
maxDuration: 3m
+8 -1
View File
@@ -11,7 +11,14 @@ 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
# Poimen services (GitHub)
- https://github.com/Riotpiaole/Poimen-memory.git
- https://github.com/Riotpiaole/Poimen-workflows.git
- https://github.com/Riotpiaole/poimen*.git
# In-cluster Forgejo wildcard — all rock/* repos can be onboarded without
# touching this AppProject. Enabled by Stage 1 (A1). Includes poimen-* repos.
- https://forgejo.riotpiao.com/rock/*
# Public Helm chart repos referenced by k8s/argocd/apps/* and bootstrap/*
- https://cloudnative-pg.github.io/charts
- https://dl.gitea.com/charts/
+10
View File
@@ -0,0 +1,10 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
metadata:
name: argocd-projects
# AppProject definitions for ArgoCD. Synced by wave -1 Application
# (k8s/argocd/apps/-1-projects.yaml) so they exist before any Application
# references them. Enabled by Stage 1 (A2).
resources:
- homelab-project.yaml
+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:C3U=,iv:J6yvL9HYwzrR4AidMrxmTQZAA1AqtAO/nn9AQnS40JY=,tag:WkqaErH6Xfrpd68+4QrfrQ==,type:str]
kind: ENC[AES256_GCM,data:BJsIWY38,iv:/8AGCpKvtaKoi+iuQNkJbKCSo/jSKi6WRs0f1tJj6d0=,tag:6/Rja8mrZu25kSqg0IzjYQ==,type:str]
metadata:
name: ENC[AES256_GCM,data:lTfngGBtsNA+,iv:shwcjVXeWhFRE+IMYlW6ffPyY4JqVzw5yySqcfyU+4I=,tag:qMmCemQcBjLwubaTnnKqTg==,type:str]
namespace: ENC[AES256_GCM,data:/VJI5GUu+jSX,iv:XCgFTLvytlYl8K09JyhGSsmVaTVCyztY5Xvw4TSkfEg=,tag:3eWHcCiedFHLvcKC8WVnUg==,type:str]
type: ENC[AES256_GCM,data:aibi62c0,iv:MJQRYJ27pTgtaRUKoJI2nb1qKZP47c4Ma+PvjIrCiE0=,tag:jB6mYfAPSnWnZUnY+rC+zQ==,type:str]
stringData:
models.json: ENC[AES256_GCM,data:SZNtmDYpM+ivOATbvUcOGylj5i7RIu6sps3tp63jQcPwrEjM9bNVCcIEdfC8owq3JU2yT3mUMdC5c/ViNuCMhIilLSGf35Da/uTnOTe4e3URNQ2r1LXTBYr5R4ESt6uZrI7llzgc68C7j+k1JEldbPeoyAef0sHHSIGdbQHaVzd/+j6RSnmDcpwNw5fopyeBkZivdnKgY1vW0bz/IEgMUHRpsIeZkENSJvp63pUwsmv7NZ0qaFWk2rgzZiZszIBFYP8K/AcDAaTid8T7F6Ro9A3ClRdHW9K8Zary2TSXMrZJ+Mo9Rcqzcrw5LBiCRCn+lSmILovZfohPHUN6atKbB5YAewo7XBVlJJa5Wl+V7sq3FHZH7+rbLSFQEPovxt8z8SW2k1W/ifz+0HYWLRrtJLADZ3iV6eDKCrkSnDiCvi9pAczwD4jrbQYDdnVb9Djgp+8qhUyjEZT3C7ONs/ZaTAawX434KqsUN4O5LSEKgNlITnvHgmoxMd6eR1Xc48Gb3CBmI9ChZNpXvtgzZX0t9EuKCR0HBoBg9IPz6vADdbEVRGNLVVT1ucDZU7Sp+vMXnaM9ZIw3jDgWnmX+L4sBBpe2H2mC71muYVpF0swHt7+H0o7Nc7vxNsNIqnqWJjTQt/w4joENvbKB4dBs5lNRCeXuWuwyh6/f+MZbz4ZfqtPfvoEsWqiXglGs0eQQttDlXS36R3lsF1brFKbHdO4j7rS7YEeZdKgJIALqp3I+w/v9hAyLh+bu+R6tqXz3QoKWQkDl10Jjip0I/GIkkw9W8ZDBgsy9YcVf9J0ZOa+wm0GZXhBYWyfrE55FiMkCL1IYP2GABb5HdAbMyccIB1dkx9/TAGWeYxsMPmjCvBghf3LawZNa3nDNMnfH7+MxQFfvlHlGJBWbgO7M4V/rOLQu0RhoMIplb1ZyucHSVMWDNt71kteKR7Fme3VdFES+nIoDi8usfUYSQraW99XMB9IsIkPMCz9nFAUoNrXhd/ADkyRmaXe+gb8UohY++7zBy6YllTICHzZFVvucU1YniZLR9i5lVBv16Gcpe0PxnLuCGFwQi+RdgIPglyXTEFh5Woo7ahrr0H/9knrG7p2cqpfJs777ZSWexQL7ncN40kk73fZyAQZFbYw6vhkRGdTHb5Dq2hrLC4FSPev33lWN9v1V4uMo/wa0CGXkxbjTcHeb2NkzfXWFM0+OlqSYTev8fnbZoBGvh/N/UcmdadAG20wPoD4O7kRaZMqZe3vf5s5AsirZtKGMB+FlpBw8f3BQ8GGV9X9bBcFgq42hhcXzGrelqdCwPKFqPpz5ItEasDAdU48dHBFnAn3OWYiKrDn4/uc0XURcMKg5JR0cwJEaAWd6ZPvPy58qYQwWoFA+MVqk1/fql2GjUxW10hTPNMePLq70FYEl5pRqcWAzS2v/9LSM53E/UD/tlyjm61llqPhirDt7QhYmnEawJeQxFzzrgizAdx4jp4SGCpa3envp1oPySJdEyTJkedf/zYxXHLKZ/sc9uuhM/Bl103SeXeuAQrXrFE/kJazdUBgEstOPiDiLSWq9FjMxgMIr9SMcChYCFm7U4irsjWCdboVWKb6iMZH96AFnm2AwP+QvXUSN6dNoRAGY9E7yeiebuAnvCMGQIZ4rxYJ+xsQvRDpIwZZhs1oDAKsvMaozp5o8zZXwU+UCoRfiHVQ+LmAKhDWW0PfLhMVRSsBA9tHDnigwwRNth+A+117IBwnOgpMf/vS2xOTPszAyso24+4np4etTRMWBZLai/sScnkWflGlEwTD2lb38IeCVfTH4Vp1wPfViI3bRGvpj8je6aBg5buRaUO/M2NPXdK9eroM3OQewEtv1QTtuGy7ihsXukeoll8GvOor+1m8qhLeK4/pMH2kEMvE7fFjiG7cVxUYczN1RPl3353BIh65BdSqi8ZbxezHbPuPnoST3teoBdiwVoHcrz80by76n5kIiLKrunuzDVugq3H7NgE8xpnIpttCADiYHL2jtx7Tg7ft3rSMEjep6OSThUWyKyJUUCgyoENmrR7zyWhk50Z1YCkFW/1hOma+chdPY012aGz6nhDBxOOYrI54YlD61jKsheE51IYWKdQXAKfsp0UCGR3Q+t69QuCm+lclZgatuoBWO+tt/WKekO3T4Y1HMx1aoDk/kAKYZwfZXPyCPQ/cIK0GUpS3H8Fa8uDIpaf5KqTj3DTV9qCcrtvrmrf4Qj8YiyN+TBp/wtLiMLtofjI+apTxBqQSGss7oB/JsuH1wacA5EGoKwO8tZAuMWLM6wuPjaSgVRlBezbGsYqKUAiDZkcXLOHco6AbEptMbK91t5Mgw0V8ActHRg+3LbuHo5P/tWwzdF+1Ht7J7EvJIzvz4lfuVGyUA4fh6uVHHeeJG2OrNtjrqr2zq5iSEECJ4tc7hNDnGR7wqHc3TPEi0YIicUNU5701v3wcKgxqiPrrfRS1ONR7SLqiTHijpdFBOZyFrh/bd0216w/6vUk8uZoq/3u0PjUkVG10ofb3farN4GUfOwvQBgSRkRjIe5K4DH1ZKnE0SQtV9UonzWPqU6jWGepPsOlTMiBkdoTeUhaeomAygbJjRuBsKzGGTDDaZwBpVvB0rBVIV5xge1NKVCSTnOyGYi2gYkmEcWgSdgWk+OOI=,iv:9PDXlSUYz+vl2EzVcwMHZTgyamXLNZU7C+XdcAEi9j4=,tag:bRkH77im+qHjDewMW6PsbA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4aENRQllpaWh3bnRJYjEw
eTFkM250eXBSVUFrQWJXSzJUTGgrNmJqL1NVCklRT0tRWTlRU0duYWNzVFExQllS
bWo0TndLVWl2VGllb00zR1c5ZERpWmsKLS0tIDh6Uk5hUFV4bmRkK0lHWWN6L2Jk
dlU3cXJlVFZYYi8yMm5kVUJveU91OGcKyin8Tr7OkCocRxf1dzWl/QsC4l2XW4dn
g/it6hJQx1P+23STw9pDZVPqEj4fOdqjnNoRqVCkM8wH4SXJfwrnlA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-20T07:26:38Z"
mac: ENC[AES256_GCM,data:YIN51aoCGuFrJwxJIGbCf7vY/+S4uHR4HwQ6un084hMKnInh1uK96FxWrQBlcheqBDfoaXHXXmHAd14LVhrVEsj3R1cFPpxiQpqjwt+d+mON4YBeOrC/VcStAU8joKcLsc8H0PF41PGMcBmflQVDX30D/+am60hZ7FMnGavZqgA=,iv:N6tv/3gh9BJvZdWXAQqTwaceR5nLbiA4YwOz01uwbtg=,tag:6VG9ZkEVpE9xLCNy0HLYcw==,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:ECE=,iv:bISz4HovH++X7DW1Qj8Cw0L6+EvPB0+68hh7tfyW5C0=,tag:w+SjD7MsfeIuSf62n+Zl7Q==,type:str]
kind: ENC[AES256_GCM,data:AxXaV3Mb,iv:xfJb354Rrjz3zLctW2i6hl40yh9EsfIqKxDvmn8jqnU=,tag:1xQ/RR3KhPLKCTWCYjebJg==,type:str]
metadata:
name: ENC[AES256_GCM,data:gdQ4OoMYKUPEbsUeA5OI4i1xnh8=,iv:IA2kaCyqQmkYpLolvcTF4aleh+yd/ImXJMhRMvpGCgo=,tag:8qyYYw4EhBKKPzEmpepAeQ==,type:str]
namespace: ENC[AES256_GCM,data:/mOSyXWmRg==,iv:cpeHUTlMqlJzJttGtuR3DoiMtvVqFmDS0/5Tl7K7c2M=,tag:q4GxWgWn2wQJxJHqnq4WQA==,type:str]
type: ENC[AES256_GCM,data:92OcrsuV,iv:Z1XBy6iZ6unGrK4/SSdDa58pbPL3022gU/f1AOM4uvc=,tag:cd4wWRq/ohm6BJ/Eo6HOIw==,type:str]
stringData:
REGISTRY_PAT: ENC[AES256_GCM,data:zDKruOXhzsIcFZTTp4r4r9rwHih9uf1cCp06KZ6eIXSIZDvR6aQY4A==,iv:5Vch5Z7xn1KkRxrgOs2p/M58nd6WhtcuUhoZDCi8LSY=,tag:14SAZIWGOxJMqLBtOorjYQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBIaG5QVUYyNjhvaENPcUFy
K3RSSVM2N2hTeFhNK0M5YWZmODhzZU1HaHlFCmNGZWdFcFEvanVwcXpGSmVvRHVx
aG45c2lBY1RuSTYwbDZTVk1QeHNWOTQKLS0tIDg4TnVMNjNtaU1VQk5zQjUvU2hM
R245ZVdqc0ZWWVhJb3dOZ3lpU3JjUlUKawSg09ZPq8FKx5tvOVZZ+K4yh7eTQsUp
be8mWUpS0+eEmNqh35BwU3HrETMQFA6a1kjVp30JOMtqa5rbYlzF7w==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-23T23:14:53Z"
mac: ENC[AES256_GCM,data:yzi6woUaUCi6w9pG/eKnU7k/VfZgoXg/tW8p9joG8p7iabLWMdlXlx23m/CItw/NE0zeGWA5iZPPFZXOit2vN36VzK3kQNoFW6QbhvYLZi+78C/RWIdcpZkxB/tPIRG0vq8Q1SA+rwGjG/0xeAFh+R7k+YBTMd2XWBH3P7EI5T4=,iv:kf6MDaAVrtDvPIEjHMMLxXDSDRC3I1GpsPeJFUYppiw=,tag:NukVbGLa9EoMYmRsa4nBtA==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
@@ -0,0 +1,24 @@
apiVersion: ENC[AES256_GCM,data:D9Y=,iv:EH+zD6bogxh/h/Oe+RxDCtfO96tkc56ou14V+68nK7k=,tag:xTeuhSzqZyrS/ltqvtHcgw==,type:str]
data:
.dockerconfigjson: ENC[AES256_GCM,data:P8x3bhPbJTFvFIKE8WQY1P7KqPwrxNTYTNR+4/Z7nZSr2YQab/Db0SRcvFO9lr4ImdO6waZ8EZ6CI1RzIYx1WcjGXs8FzX1jpBXSw9EG69vRTZCqRRJR8c2yg7qmNFfSvcPAUhK9tciYCzzFWRHZEDAmQ52cox/sMdxE/YR61dhYCy1E9kPwTnaDmDj5Hu72mbLJDxIYpN3T2hVWlw02lHYHgFnuKsPb0Tf1lxn174j/gMMxUT6ynVhWSEExzHvTqQ0RZzuP+VXAh3N9HasbtfcAabt3FtEjYNZqYJ+OE8oiX/A6YjgPDqUOR3AJFZE3,iv:6YsyIHQc8xp8T8XUWhN/pBeaYVI/VdIHOe/w/hb5e6U=,tag:nmGFdkv2hLgj8Dp8/0MOkw==,type:str]
kind: ENC[AES256_GCM,data:hOA36Sjr,iv:y0XHfUOUnut8z0yM2g7Beo3qiqxJhLLhffPpNlUhaec=,tag:45s3FjYCr83vYCSXDXu2/g==,type:str]
metadata:
creationTimestamp: null
name: ENC[AES256_GCM,data:yD8Fo/fncAA4qkaY7RjhRg==,iv:qwc4WId/kGwradgxFUwG5B5XIVYRlMY2HxsaGJAzrjw=,tag:O8GwNdDHMJreL609G/gykA==,type:str]
namespace: ENC[AES256_GCM,data:zg2o,iv:KDpM2L71/LDI4JLTQUtbcv5SV0IraAbKpNEBzSFn/rE=,tag:uFm+3EkgZN3NC2hUPXo9Pg==,type:str]
type: ENC[AES256_GCM,data:5/a+6lRp9Ea5rU/+gEMIgoDYs72xRWkfwefMcy+h,iv:jYirTXlb4rFwvb+nLcgB5X4x1Q/S+3LsVL+7ypS7mkQ=,tag:5Wc0DrMZxIpC0e39MzlAlw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2ekxNWW9VQ09RTUlMUzVy
QlVrRm12Smt2akRaYmkvMHRPRmJvOTdBTzE0CmdhVFNyWDJnWDV3eTFFUm1majk0
L01ZWTNZdmcwc1MwMHgzQmdVZy9KcjgKLS0tIHZLVGg5VmNRQ2ZNY1lIRHlzaUlo
TWVNelZRcHFveGRiNTVvNzJCNWtDSGMKyV3Puscgx3RqK65KSL6SYaTauxsBY3qd
CeFU928hcB86DwAG/Atq2Qtd7S9pzuzOVQmXRZxwpCDTTyRVhU7eVA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-20T04:43:53Z"
mac: ENC[AES256_GCM,data:Leus5j38xJwJz3Ge9WggBjQSAh2ESlIMUnX9SylE4oIcAt71f8WadtSCOmnqT3ZK+uN8f+Huq6WetGHWUdLfZjZUngHQHWLoRP1xpTVvB5HwJK4F1ASvmL+u1rC88AG3JsZc3Kc2N1G+m6QruQK/9HSmkMi/HGXy658ijzErEzk=,iv:1ga2dKDqTalj9WnjVT6AubXsL7130CuJp3SbBkTb/64=,tag:YsD9metJdYiJmdVXpPuIsQ==,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:wGA=,iv:Z2Gfzq3aJ9j4fYaeLQolgLb/XELTHrKX9at3vUsMLIw=,tag:yZLZFnltJln5jFoVsyyZ2A==,type:str]
kind: ENC[AES256_GCM,data:bJkV4pNj,iv:0ZT7l0kw9qSoiEMZioRw1aBzzlxBkoXR+hOxoPI7zPU=,tag:EQczPq508Qw1vi/oLCeQpw==,type:str]
metadata:
name: ENC[AES256_GCM,data:sZwTVM41PPaidMwFNRo5RvU=,iv:BHfuwIHng7rkeLK3a69t8cI9QeSfD/3FEXqxby+gBxM=,tag:3zQx8APnE2ZpBKf/ZYCxOA==,type:str]
namespace: ENC[AES256_GCM,data:i7lpoEaZ1oXS,iv:jUYyDPYhf1TV51he/S5MlKPD19Vmz6wfE98y8qFEg3U=,tag:zgHDVlplmW/XPUAKdncfYQ==,type:str]
type: ENC[AES256_GCM,data:2khs1uIg,iv:ET6HcBGyv33fGFlFAl3dkJQB83naHGeaYuWvW1IFhvw=,tag:qIfMeaSwv//7cNWvy8O5dg==,type:str]
stringData:
PAPERLESS_SECRET_KEY: ENC[AES256_GCM,data:kj9DrQYL3cQGJz87FHYlFKy6Muu84Oy/6wnsWAh0w/3MmcTAsCzQvUtKLKKf1U61JTQ=,iv:UffVv78vMHDEWHRFdSKZ/6qyrVD02Nlk0CNxwWd1jTo=,tag:LNMKnFWw7QCjfcKzI1s8ig==,type:str]
PAPERLESS_ADMIN_USER: ENC[AES256_GCM,data:a3bx7z0=,iv:nrU6VXZE74PNXM+Lgg4K/3mhusaQhA2GAgl0GzqAQt8=,tag:x8nswTfkFHR6Ou+JSI9P3Q==,type:str]
PAPERLESS_ADMIN_PASSWORD: ENC[AES256_GCM,data:+SWyAq7D1mNt1TlFOlo2Z0biK8kEn097,iv:an2ZZuXNSig+7mJP7ogHFc5+QvUb+z9pzqcV4xvFLbA=,tag:OaRiVZsnajEjAlc9mDWo9w==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA1Q2V6bVBUYmVRR1N5SCtF
eXdTV2dmWjhtMS9lRFEzS0wzbkd6Y3JLMVRVCncydFBjdmJkRCtVUXphR2w0SDlJ
cng2bi9MWlJzTEN2amJrYjRJN2VFcEEKLS0tIEY5cmw2RGhnbzUxZW9FaFJjQmVN
WWcvNlNiYWdwbnNSR1Q4alpDZmFqTFkKh9TOw8ERP9fpx2pKi/Q0b7+OkEv0UC7o
aAIK4Tzvi5dp6y9IWcu9l6PjDLeYWOJ5wr7QABaFNOz82hngxUleDA==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
lastmodified: "2026-08-25T16:04:48Z"
mac: ENC[AES256_GCM,data:OXPQXUyn/SDftKH5nRzhqEtnaOb4Gp7etmGojqV4Z01kHnABZ42PPIyiBb8z997eLNZsK6/1bi7nUNyVp4fIuXG4F52aEdMewofOocCHokRnNmz7jzhooK1gScJb2u0eHG3FL5iLONMaGgVpk7BLYO3e0xiDytWGe8BxcuDukPg=,iv:kh0jUwFvO6AUICy2Us1E7YOTEcp3L+ptrGdDWBSpWyc=,tag:qUPFmZ/rpljln37f/NRjjw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
+28
View File
@@ -0,0 +1,28 @@
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-registry-pat.enc.yaml
- forgejo-registry-pull.enc.yaml
- forgejo-runner-token.enc.yaml
- forgejo-secrets.enc.yaml
- grafana-oidc-secrets.enc.yaml
- grafana-secrets.enc.yaml
- homarr-auth-oidc.enc.yaml
- homarr-db-encryption.enc.yaml
- homarr-secrets.enc.yaml
- homelab-ca-secrets.enc.yaml
- loki-secrets.enc.yaml
- minio-secrets.enc.yaml
- paperless-secrets.enc.yaml
- vault-secrets.enc.yaml
- vault-unseal-keys.enc.yaml
+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

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