Compare commits

..
25 Commits
Author SHA1 Message Date
rock 7a1213d504 Merge pull request 'fix(longhorn): retire the failed 2TB PERC disk on talos-cp-2' (#62) from fix/retire-longhorn-disk4 into main 2026-10-13 00:10:01 +00:00
rock d21ee9630f fix(longhorn): retire the failed 2TB PERC disk on talos-cp-2
The disk behind /var/lib/longhorn-disk4 (sde1, 2TB PERC H710P virtual disk)
failed: 54 million SCSI I/O errors and climbing at ~7,000/minute, against 3 on
every other device on the node. Its XFS filesystem hung, so five Longhorn
replica processes and the longhorn-manager blocked in uninterruptible D state
in openat and unlinkat. D-state processes cannot be killed, so they held the
node's three replica rebuild slots permanently: no new replica could start on
talos-cp-2 and every new volume there failed to attach with DeadlineExceeded.
That is what stopped the azhi database from coming up.

Removed from all three declarations so it cannot come back:
- terraform.tfvars.local.bak, longhorn_disks for talos-cp-2
- the Longhorn add-disks Job, which would otherwise re-register it
- the Job's disk-count guard, 5 -> 4, or it would fire on every run once the
  disk is gone

The running cluster already has the disk fenced
(allowScheduling=false, evictionRequested=true); the replicas are evicted and
the device retired at the controller after that.
2026-10-13 08:09:42 +08:00
rock 40aa23dfdf Merge pull request 'test(argocd): point azhi-riotpiao at the CNPG branch' (#61) from feat/azhi-cnpg-test into main 2026-10-12 13:41:38 +00:00
rock 71fce30506 test(argocd): point azhi-riotpiao at the CNPG branch
The StatefulSet database could not attach its Longhorn volume, so the branch
runs the database as a CNPG Cluster instead. This pins the Application to that
branch for the test; it returns to the default branch once the branch merges.
2026-10-12 21:24:47 +08:00
rock 0666715a5e Merge pull request 'feat(argocd): reconcile azhi-infra from its private GitHub repo' (#60) from feat/azhi-riotpiao-argocd into main 2026-10-12 12:54:43 +00:00
rock 949ae5cb38 feat(argocd): let ArgoCD reconcile azhi-infra from its private GitHub repo
The homelab AppProject allowlist had no azhi repository, so an Application
pointing at ToyoGlobal/azhi-infra fails admission with 'repository not
permitted'. Adding the URL is the whole gate.

The Application itself targets the repository default branch
(harness/staging-slice), not main, and path k8s/overlays/azi-riotpiao. That
overlay renders postgres and the API into namespace azi-riotpiao.

The two Secrets the namespace needs stay out of Git: acr-pull carries the ACR
pull credential and azhi-riotpiao-app-env carries the database credentials,
the JWT secrets and the AES key.
2026-10-12 20:54:00 +08:00
rock e150969070 Merge pull request #59 from fix/etcd-defrag-main 2026-10-12 12:27:28 +00:00
rock abfdda5f57 fix(etcd): add a working 15-day defrag CronJob under the backend quota
etcd's backend file only grows: the apiserver compacts every 5m, but compaction
marks superseded revisions free inside the file, and only defrag returns that
space to the filesystem. Nothing ran defrag, so on 2026-10-07 the file reached
2,148,843,520 B, crossed the 2 GiB quota on talos-cp-1 and talos-cp-3, and etcd
raised NOSPACE. From then on etcd rejected every write cluster-wide: kubelets
could not post node status (3 nodes NotReady), apiserver writes returned 500 and
the tunnel API path returned 502. Live data was 15,943 keys, 69 MB after
compaction - the rest was dead pages. Compaction had also stalled while the
alarm was up, so dbSizeInUse read 100% of the file; defrag alone would have
reclaimed nothing. Compact must come first.

An earlier revision of this CronJob never ran, for two independent reasons, both
fixed here:
- its image quay.io/coreos/etcd ships no /bin/sh, so command: [/bin/sh, -c]
  failed with exec: "/bin/sh": no such file or directory
- it existed only on a side branch while the ArgoCD application
  cluster-maintenance tracks main, so the cluster never had it

Contents:
- CronJob etcd-defrag (kube-system): daily at 03:00, acts only when the UTC
  epoch day is a multiple of 15. That is an exact 15-day interval; */15 in the
  day-of-month field fires on the 1st, 16th and 31st, putting two runs one day
  apart.
- Image bitnamilegacy/etcd:3.6.4 pinned by digest, ships bash and etcdctl 3.6.4
  against the 3.6.11 servers.
- Compaction uses the MINIMUM revision across members, so a lagging member is
  never compacted past its own revision. Measured 2026-10-07: talos-cp-2 sat
  ~1.2M entries behind the leader with a frozen raft term and needed a reboot
  to rejoin.
- Defrag skips any member whose applied index lags the leader by 100k+, and
  runs one member at a time since defrag pauses that member's apply loop.
- Secret etcd-defrag-creds (SOPS/ksops, kube-system) carries the etcd CA plus a
  client certificate signed by the cluster's etcd CA, valid to 2036-10.
2026-10-07 17:12:39 +08:00
rock e4caa7e841 fix(etcd): make the defrag CronJob runnable and 15-day
The committed CronJob could never run, for two independent reasons:
- quay.io/coreos/etcd ships no /bin/sh, so command: [/bin/sh, -c] died with
  exec: "/bin/sh": no such file or directory
- it existed only on branch fix/etcd-defrag-cron, while the ArgoCD application
  cluster-maintenance tracks main (targetRevision: main), so the cluster never
  had the CronJob or its Secret

This revision fixes both and records the 2026-10-07 incident:
- image bitnamilegacy/etcd:3.6.4 pinned by digest (ships bash and etcdctl 3.6.4,
  matching the 3.6.11 servers)
- 15-day cadence by UTC epoch-day modulo; */15 in day-of-month fires on the 1st,
  16th and 31st, which puts two runs one day apart
- compacts to the MINIMUM revision across members, so a lagging member is never
  compacted past its own revision (measured: talos-cp-2 froze ~1.2M entries
  behind the leader and needed a reboot to rejoin)
- skips defrag on a member whose applied index lags the leader by 100k+
- documents that compact must precede defrag: compaction had stalled while the
  NOSPACE alarm was up, so dbSizeInUse read 100% of the file and defrag alone
  would have reclaimed nothing
2026-10-07 17:11:35 +08:00
harness 1ce9f86635 feat(etcd): weekly defrag CronJob to stay under the etcd backend quota
etcd's 2 GiB quota was reached on 2026-10-07 (backend 2.1 GiB, ~73 days of
churn). etcd raised NOSPACE and refused all writes, so kubelets could not post
node status: three nodes went NotReady and the tunnel origin pods on them
disappeared. Recovery was compact + defrag + alarm disarm per member.

The apiserver compacts every 5m, but compaction never shrinks the backend file -
only a defrag returns space, and nothing scheduled it. This adds:

- CronJob etcd-defrag (kube-system, Sundays 03:00): compact to the current
  revision, defrag each member sequentially, disarm NOSPACE, print before/after.
- Secret etcd-defrag-creds (SOPS/ksops, k8s/argocd/secrets): etcd CA plus a
  CN=etcd-defrag client cert signed by the cluster's etcd CA (expires 2036-10).

Applied by the existing apps: cluster-maintenance (directory) and 00-secrets
(ksops generator).
2026-10-07 13:37:45 +08:00
rock 2d9e3abee8 fix(oidc): match /proc/<pid>/comm instead of cmdline when signalling the API server
Exit code 143 proved the previous job killed itself: the script's own cmdline contains the
string 'kube-apiserver', so it matched its own process. Matching comm (== 'kube-apiserver')
and skipping $$ removes that failure mode. Staggered 2 minutes apart.
2026-10-07 04:07:37 +00:00
rock 9fa8d9cf60 fix(oidc): restart the apiservers by signalling the process, not by deleting the mirror pod
Static pods survive mirror-pod deletion, so the first bootstrap job had no effect.
These jobs share the host PID namespace and SIGTERM the kube-apiserver process itself,
staggered by 100s so only one control plane restarts at a time.
2026-10-07 03:59:22 +00:00
rock 2e4fad3bf7 fix(oidc): use curlimages/curl for the bootstrap job (bitnami/kubectl tag did not exist)
The first job could not pull its image; this one deletes the apiserver pods via the
in-cluster API with the mounted service-account token instead of a kubectl image.
2026-10-07 03:52:10 +00:00
rock 5d22691c3e chore(oidc): one-shot bootstrap - restart apiservers to initialise the OIDC authenticator
k8s/argocd/apps/99-oidc-bootstrap.yaml
2026-10-07 03:46:01 +00:00
rock 4eee161e0f chore(oidc): one-shot bootstrap - restart apiservers to initialise the OIDC authenticator
k8s/infra/oidc-bootstrap/bootstrap.yaml
2026-10-07 03:45:56 +00:00
rock 719fa77521 Merge pull request 'fix(secrets): populate the empty sops placeholders and drop the unused one' (#55) from fix/secrets-placeholders into main 2026-10-06 04:48:51 +00:00
rock cc62f74404 feat(storage): add local-path-provisioner as the fleet's persistent-storage backend 2026-10-06 12:45:12 +08:00
rock 64cd1f2c4e fix(minio): freeze the community image by digest and alert on pull failure (#57)
Co-authored-by: rock <[email protected]>
2026-10-06 03:43:12 +00:00
rock d7b96fb29b fix(minio): freeze the community image by digest and alert on pull failure
MinIO deleted the community edition images upstream. Docker Hub reports
{"message":"object not found"} for minio/minio, and an anonymous pull of
quay.io/minio/mc returns HTTP 401. talos-cp-1 dropped the cached image during
the DiskPressure event on 2026-09-22. The tenant pod was recreated on
2026-09-23 and has been in ImagePullBackOff since. MinIO served no traffic for
13 days, which put vault-0 and loki-0 into CrashLoopBackOff and left the
paperless backups stuck.

Pin the tenant image to the digest of the last community release, so a retag or
a deletion on the mirror cannot break the pull again. Repoint the two
quay.io/minio/mc references at the archived Bitnami client image, also by
digest. Alert on ImagePullBackOff and on a pod that stays not ready, because
neither case raised any alert.

Evidence: https://hub.docker.com/v2/repositories/minio/minio/tags -> {"message":"object not found"}
2026-10-06 11:42:51 +08:00
rock f780e31f9c Merge origin/main into fix/secrets-placeholders (rebuild ksops file list, fix #54 indent) 2026-10-06 10:05:45 +08:00
rock c93a24f786 fix(secrets): populate the empty sops placeholders and drop the unused one
(cherry picked from commit f6207ff2b0)
2026-10-06 10:03:04 +08:00
rock b6e317b34c feat(poimen): pi sandbox workspace volume, encrypted api keys, cluster dns rewrite (#54)
Adds the pi coding-agent sandbox's cluster side:
- `k8s/infra/poimen/pi-agent-workspace-pv.yaml` — static hostPath PV + PVC for the sandbox /workspace (Longhorn cannot attach new volumes in this cluster; this gives real persistence and survived a pod delete/recreate test: marker file + 38 KB session.jsonl intact, the session answered from its own history afterwards).
- `k8s/argocd/secrets/pi-sandbox-secrets.enc.yaml` — the sandbox's API-key Secret, SOPS-encrypted (recipient from .sops.yaml, payload only: `encrypted_regex ^(data|stringData)$`), registered in the ksops generator. Round-trip verified against the live Secret.
- `terraform/files/coredns/Corefile` — `rewrite name pi.riotpiao.com ingress-nginx-controller...` so the hostname resolves in-cluster.

Note: the keys currently in that Secret are the ones leaked earlier and must be rotated; re-encrypt with `sops -e -i` after rotating.Reviewed-on: #54
Co-authored-by: rock <[email protected]>
2026-10-06 01:49:12 +00:00
rock f3015522e3 fix(storage): pin minio tenant to a published image release (#53)
## Why

`minio/minio:RELEASE.2025-07-23T15-54-02Z` no longer exists. The Docker Hub repository
`minio/minio` returns **404 for every tag** (checked: the pinned tag and `latest`), and
MinIO has moved its images behind registry authentication (`quay.io/minio/minio` → 401,
`registry.min.io/minio/minio` → auth required, `dl.min.io` binaries → 410 Gone).

The tenant therefore sat in `ImagePullBackOff` — 44,229 attempts over 6d23h — and the chain
from there is:

```
minio tenant pod not ready (1/2)
  -> headless Service minio-cluster-hl has no endpoints
  -> CoreDNS: lookup minio-cluster-hl.storage.svc.cluster.local -> no such host
  -> Vault S3 storage init fails at startup
  -> vault-0 CrashLoopBackOff, 1943 restarts, ~7 days
```

## Control checks (so this is not a network story)

| check | result |
| --- | --- |
| `alpine:3.20` pulled on talos-cp-1 | Succeeded — docker.io works |
| `quay.io/prometheus/node-exporter:v1.8.2` on the same node | Succeeded — public quay works |
| `minio/minio` (docker.io) | HTTP 404, repo removed |
| old tag `RELEASE.2024-06-13T20-48-48Z` with `IfNotPresent` | not cached on the node |

## Change

Pin `spec.image` to `coollabsio/minio:RELEASE.2025-10-15T17-29-55Z` — a public mirror, and a
newer upstream release than the previous pin. Verified by pulling it on talos-cp-1 and
running it: the image carries `/usr/bin/docker-entrypoint.sh` (the layout the operator
expects, which runs `minio server … --console-address :9090`) and reports
`minio version RELEASE.2025-10-15T17-29-55Z`.

## Caveat

This is a community mirror, not MinIO's official image, because the official ones now
require credentials. If MinIO registry credentials are available, add an imagePullSecret
and switch back to `quay.io/minio/minio`. Supply-chain decision, flagged deliberately.

## After merge (ArgoCD, not kubectl)

The storage app tracks `k8s/infra/minio`, so the sync rolls the tenant; then:

```
kubectl get pods -n storage          # minio-cluster-az-a-0 -> 2/2
kubectl get pods -n iam | grep vault  # vault-0 -> Running (storage init succeeds)
```

Residual risk, unrelated to this fix: the tenant's 4 x 25Gi volumes are Longhorn-backed, and
Longhorn currently fails to create engines for new volume attaches cluster-wide. If the
restarted pod cannot attach, that is a second, separate fault.Reviewed-on: #53

Co-authored-by: rock <[email protected]>
2026-10-06 01:48:57 +00:00
rock d60b520968 Merge pull request 'fix(llm-serving): V1 prefill scheduling + admission observability (#51)' (#52) from harness/51-vllm-prefill-scheduling into main 2026-09-30 11:49:52 +00:00
rock c7d52b5971 fix(llm-serving): V1 prefill scheduling + admission-control observability (#51)
The reasoning engine starves every other request while one long prompt
prefills. Measured: 262 tok/s contended prefill, ~250s for a 65k prompt,
with Avg generation throughput 0.0-0.1 tok/s at Running: 2-4 reqs.

Root cause is NOT what the issue first diagnosed. The issue proposed
--max-num-partial-prefills / --max-long-partial-prefills, which are V0-only
knobs. Verified in the live container: vLLM 0.11.0 ships V1 only (vllm/core/
is absent, so there is no V0 to fall back to), and EngineArgs.
_is_v1_supported_oracle returns False whenever either flag differs from its
default, calling _raise_or_fallback("Concurrent Partial Prefill") ->
"Falling back to V0" -> AssertionError. With strategy Recreate that is an
outage, not a slow rollout. Both flags are therefore NOT set, and a comment
records why so they are not reintroduced.

V1 needs no such flag: it has no prefill/decode phase at all. Every step it
walks self.running and hands out tokens from one shared budget
(vllm/v1/core/sched/scheduler.py). The real defect is that the per-request
cap is disabled by default (long_prefill_token_threshold=0), so one long
prompt takes the WHOLE budget, token_budget hits 0, the `while ... and
token_budget > 0` loop exits, and every other request is skipped.

The cap is applied before the budget clamp, so threshold >= budget never
binds. Replaying the loop: 8192/8192 serves 1 request per step (still
starved); 8192/2048 serves 4, and two long prompts advance together, which
is the issue's actual acceptance criterion.

  --max-num-batched-tokens=8192      (was unset -> default 2048)
  --long-prefill-token-threshold=2048 (was unset -> 0, cap disabled)
  --max-num-seqs=5, --gpu-memory-utilization=0.95

The ~166k KV figure is marked as a projection; the live reading is
num_gpu_blocks=9527 x block_size=16 = 152,432 tokens and is authoritative
post-boot. A preemption alert covers the accept-and-watch risk.

Observability for the companion admission gate (homelab-frontend#64, now
deployed at 64f2bfc1):
  - four dashboard panels: parked, rejected by reason, engine state stale,
    and gateway-view vs engine-truth. Each carries a description, because
    an unexplained blank panel reads as a broken dashboard.
  - alerts on observer_degraded and engine_state_stale_total, both with an
    absent() guard: the gate fails OPEN, so a missing or renamed metric must
    alert rather than look healthy. Verified live that the guards do not
    false-fire (both series present, 3 each).
  - LLMPreemptions and LLMPrefillStarvation: the acceptance metric and the
    regression direction this change introduces.
  - max(vllm_requests_running), not sum(): one engine-global gauge is read
    by all three gateway replicas, so sum() read 3x and could never agree.

Also corrects comments that were factually wrong. An earlier revision of
this branch claimed llm_ttft_seconds/llm_itl_seconds/llm_tokens_total were
dead code; homelab-frontend#63 wired them, verified live as 3 series each.
request_duration_seconds is the genuinely dead one (0 samples, zero
non-test call sites) and is now marked as such instead of being listed as
working.

Closes #51
2026-09-30 19:49:05 +08:00
30 changed files with 1152 additions and 78 deletions
+37 -5
View File
@@ -28,9 +28,14 @@ spec:
# KV cache math: 256KB/token total (64 layers, 8 KV heads, 128 # 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 # head_dim, fp16), PP=2 splits both weights and KV load ~evenly, so
# each GPU carries ~9.67GB weights + ~128KB/token KV. At # each GPU carries ~9.67GB weights + ~128KB/token KV. At
# gpu-memory-utilization=0.90 (28.8GB/GPU usable), that leaves # gpu-memory-utilization=0.95 (30.4GB/GPU usable), that leaves
# ~19.1GB/GPU for KV cache -> ~156k tokens/GPU capacity, comfortably # ~20.7GB/GPU for KV cache. NOTE this is a projection, not a
# above the 131072 target with room to spare -- the old # measurement: the live reading at 0.90 is num_gpu_blocks=9527 x
# block_size=16 = 152,432 tokens (vllm:cache_config_info). Re-read that
# metric after the rollout -- max_num_batched_tokens=8192 also raises
# activation memory out of the same budget, so the post-boot
# num_gpu_blocks is the authoritative number, and --max-num-seqs=5 is
# only valid if it still covers 2x65k + 3x8k. The old
# OffloadingConnector CPU-DRAM spillover (tuned for the previous # OffloadingConnector CPU-DRAM spillover (tuned for the previous
# model's much smaller 16384 context) is no longer needed and is # 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, # dropped. Staying on PP=2 and vLLM 0.11.0 (no version bump needed,
@@ -54,10 +59,37 @@ spec:
- --tensor-parallel-size=1 - --tensor-parallel-size=1
- --pipeline-parallel-size=2 - --pipeline-parallel-size=2
- --max-model-len=131072 - --max-model-len=131072
- --gpu-memory-utilization=0.90 - --gpu-memory-utilization=0.95
- --max-num-seqs=4 - --max-num-seqs=5
# Prefill scheduling -- see the measured starvation this fixes.
# V1's scheduler has no prefill/decode phase: it walks self.running and
# hands out tokens from one shared budget (vllm/v1/core/sched/scheduler.py:
# "There's no decoding phase nor prefill phase in the scheduler"). The cap
# per request is applied BEFORE the budget clamp:
# if 0 < long_prefill_token_threshold < num_new_tokens: take = threshold
# take = min(take, token_budget)
# The loop is `while ... and token_budget > 0`. With the threshold at its
# default of 0 the cap is disabled, so one long prompt takes the ENTIRE
# budget, token_budget hits 0, the loop exits and every other request is
# skipped for that step -- measured as 262 tok/s contended prefill and
# 0.0-0.1 tok/s generation for everything else, Running: 2-4 reqs.
# Hence threshold must be strictly BELOW the budget or it never binds:
# at 8192/8192 a single request is served per step (still starved), while
# 8192/2048 serves four. Verified by replaying the loop.
- --max-num-batched-tokens=8192
- --long-prefill-token-threshold=2048
- --enable-chunked-prefill - --enable-chunked-prefill
- --enable-prefix-caching - --enable-prefix-caching
#
# NOT set, deliberately: --max-num-partial-prefills and
# --max-long-partial-prefills. Those are V0-only knobs. vLLM 0.11.0 ships
# V1 only (vllm/core/ absent), and EngineArgs._is_v1_supported_oracle
# rejects any value other than the default for either one, calling
# _raise_or_fallback("Concurrent Partial Prefill") -> "Falling back to V0"
# -> AssertionError, because there is no V0 engine to fall back to.
# The Deployment uses strategy Recreate, so setting them is an outage.
# V1 does concurrent chunked prefill natively; it needs no flag, only the
# budget/threshold ratio above.
# qwen3 is vLLM's dedicated reasoning parser for this family's <think> # qwen3 is vLLM's dedicated reasoning parser for this family's <think>
# blocks. # blocks.
- --reasoning-parser=qwen3 - --reasoning-parser=qwen3
+1 -1
View File
@@ -48,7 +48,7 @@ spec:
mountPath: /backup mountPath: /backup
containers: containers:
- name: mc-mirror - name: mc-mirror
image: quay.io/minio/mc:latest image: docker.io/bitnamilegacy/minio-client:2025.7.21-debian-12-r3@sha256:73bd39f7899a0cef12b8dd5df13aa93a3ed1aaa44236542442e9ac76819ac158
env: env:
- name: ACCESS_KEY - name: ACCESS_KEY
valueFrom: valueFrom:
@@ -249,3 +249,26 @@ spec:
selfHeal: true selfHeal: true
syncOptions: syncOptions:
- CreateNamespace=true - CreateNamespace=true
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: local-path-provisioner
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "1"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/infra/local-path
destination:
server: https://kubernetes.default.svc
namespace: local-path-storage
# No CreateNamespace: the Namespace object (with its privileged PSA label,
# which the helper Pods need for their hostPath) is part of the manifest.
syncPolicy:
automated:
prune: true
selfHeal: true
+36
View File
@@ -0,0 +1,36 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: azhi-riotpiao
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: '8'
spec:
project: homelab
source:
# Private GitHub. The read-only credential is Secret azhi-infra-github in
# namespace argocd, created out-of-band.
repoURL: https://github.com/ToyoGlobal/azhi-infra.git
# The repository's default branch. It is not `main`: this repo's work
# happens on the staging slice branch, and `main` is behind it.
targetRevision: feat/azi-riotpiao-cnpg
# One directory, because ArgoCD reads a single path per source. That
# directory is a root kustomization over foundation, postgres and
# azhi-server.
path: k8s/overlays/azi-riotpiao
destination:
server: https://kubernetes.default.svc
namespace: azi-riotpiao
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
# The two Secrets this namespace needs are credentials, so they are created
# by hand and NOT by this Application:
# acr-pull — the private Alibaba ACR pull credential
# azhi-riotpiao-app-env — DB credentials, JWT secrets, AES key
# A missing imagePullSecret fails the pod outright and a missing app-env
# Secret leaves it in CreateContainerConfigError, which is the intended
# loud failure rather than a pod that runs against unknown values.
+20
View File
@@ -0,0 +1,20 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: oidc-bootstrap
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "5"
spec:
project: homelab
source:
repoURL: https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
targetRevision: main
path: k8s/infra/oidc-bootstrap
destination:
server: https://kubernetes.default.svc
namespace: kube-system
syncPolicy:
automated:
prune: true
selfHeal: true
+6
View File
@@ -16,6 +16,12 @@ spec:
- https://github.com/Riotpiaole/Poimen-memory.git - https://github.com/Riotpiaole/Poimen-memory.git
- https://github.com/Riotpiaole/Poimen-workflows.git - https://github.com/Riotpiaole/Poimen-workflows.git
- https://github.com/Riotpiaole/poimen*.git - https://github.com/Riotpiaole/poimen*.git
# ToyoGlobal (GitHub, private). The azhi-infra Application reads its
# manifests from here, so the URL must be listed or ArgoCD rejects the
# Application with "repository not permitted". A read-only credential for
# this repo lives in Secret azhi-infra-github in namespace argocd; it is
# created out-of-band and is not committed, because it is a credential.
- https://github.com/ToyoGlobal/azhi-infra.git
# In-cluster Forgejo repos — explicit allowlist (no wildcard) # In-cluster Forgejo repos — explicit allowlist (no wildcard)
- https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git - https://forgejo.riotpiao.com/riotpiao-poimen/homelab.git
- https://forgejo.riotpiao.com/riotpiao-poimen/homelab-frontend.git - https://forgejo.riotpiao.com/riotpiao-poimen/homelab-frontend.git
@@ -0,0 +1,27 @@
apiVersion: v1
kind: Secret
metadata:
name: etcd-defrag-creds
namespace: kube-system
labels:
app.kubernetes.io/name: etcd-defrag
type: Opaque
stringData:
ca.crt: ENC[AES256_GCM,data:vso/Ouz5QRoRF2CFj9TsGYhoGkX8oGJIeiFCoS92AAQbyyGPTiZ/fPNFM03p+TpeKDb+Z+Fyo/HXuDeT5Fee5kjPnUHXf2hiUBmJLyHnPMp5i57dRzTC8X4VaNeV/1MmP2WpkWwieWOGBxJVIIrgeQhQexxmIzSlQ5Io9dVOH8F6iyakwdf7qfLFGdoer3rfrXadSOJtKRpLneFJDL2OmR1042LZ8KtNuRRXI/qrdpgr2bnyxFuCRc+tk5QY73mpwwWfKGn6FW/rVdjiEftkC+AvTUItx7RkYMrz0PlTYPUOLustEF2gIgWheYZhE5ebZDurVSG//sudjASliYJItwgSk3G5mZihOSDA7Jcus+U8p1iHQ9CQsey3CSAMmuSGRFmq42kI8135kjtBYrzVswHjX1/51qPMyA2TIeWlRJ7AJlHihG317gT1vPeM1ULl3PkuCxaBLCtjotZBzKLJ/6zXr1ig77Baz71YOVB0D0MgFvzIdao+mdxS8WLOZdiXOCwbI5Y2eo7wJHG4BnpKEdx1rjPkv7DI2NNEoXA7e5FvJWinyAxOy6YGH4Fv6dTlEVhI3Nl/1X+CeafL6Hk8YoqA04Hm+/dJCZi7SsF0r6zfPNNsZLivM8DN18h8KYM3eVY3CwtBldE7atz+oprQ6K/yGUbblHRQwuG3sH3AHrCKLPiUrMIi0jWc0IpdPZRZFL7SnMULfV05QgcvGySOJCuQU+/OYzrRs7umOjwpqvi9PyJYVXBliiYfJutvmfpFAhQj,iv:KnW9LYNzxZ6r3Q6LTeOrgSLLPgiJlibVp6YmTEKBkjE=,tag:KTL1nOWdNWDA4dLVaA4//g==,type:str]
client.crt: ENC[AES256_GCM,data:IEmZe505SwkW3U5vmRtFxR6PpuSFGWEHflUZwyhSODzYG1zLM8olVWDtH+yUVI54kLXxTtcpMkKrTBjpAxHG8zUTLPdTCVzPMq6U20jH4PYFMzoldnSgpRl/eiADUmE4I32CBbPdM5nkPvjVEYMmRafEGyJctjG6Shhh/sUO1yE9A+h1R7o3UCRPttWTozSKN4dPi75v1TSAOLyXqvQYl5Ea9iKW47ea7Q+XTcoKfMPXsTJp44kiZnVW0VT/7xNydR7AVWJJzYltY5380j0Ho+7PfNd+QqmdodkaZPS4b0PaoinMiZ40JnChGVhdS91j3IkUlU1g/+G2iVnPR9KB07xqRubqiASxIvpSaSCeYl3rR7+dXYX18VpWcWnJ6XoREoqxJiqmMQWYSROYDTd4G/zs4Usuh5r0wIodmCjud+g89mmXFtH0PbsMCVvZDUswjVfeT2PUBm036TXlxF/1aAy2GDqycbPgJgDLyw9tdiQUZe2iA+Z/Wx80qgQjmmqCaw77cDergLFj9KnByl3Tinwfm+IWFeQiDW+fwSzz+/vrpl55BkSVkVhw7r2Jd6zyg4VtLVXbxYhzdNz695N+fjkZHuPE5tCwr4WCzAg3vSS9hA7IHo8k93WO8s6+KlyjuwTdjPIYZghebO4ktwdqvhvuqxv8Nzwpc5J7Z2H2IChjXg==,iv:l8BwxxEfHfYrm+mM5UE6Yj42DeeU9y23jjIE1KjIiME=,tag:HwRT6/myIsxc84BYDj3K6w==,type:str]
client.key: ENC[AES256_GCM,data:QD/Glf3lFslBEdEoJ3CNrYzlw2HLCvGSeVuJLtxnFne2aJOuw0sk/e0h/kSAxvG4dZ+LIWJLLbqTjKHOAhkvS/ZOA7GSea4ubNkvTkw8+JBRuYwb2D2iB8kZhlER/CpiGkYJukjkgGP1CzHdvBY1M88pW9w6rH74MD3jY47qExIaNduQj5vkUQZ8ajqkd79l0nk98ZTawfPG4AFcEtAIKAPIuubT2GZ3qqNLjOI7agbtRv8xI44hhb2wj5J9xdl1GrbOWwG5EOo+hLNrcAAOCcWBpNTUCKBy7mrk2ID8jX3HsFWN76DDlQzkhcphMhlM7g==,iv:49HGJAEgllEkB3YSiZLmbWs/vlbFbY52kiKesdGVC/Y=,tag:g8YZ9at3V2l6RFRfvO2lAQ==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4Z0h0R2dhbDdRcFJydE9o
R0hWeCtwVzluZWZlR0VtOXcxdW9FbklOQXlzCmo3eXNyNlFjejNETkJiVVRVWFhr
a2pORnhIQTI0UUk2N3dWMDZJcEhYU1UKLS0tIEduaHlmMHQza1A0TGVZb0JrbTYz
MWJwQjcrdkkzQXhOTmhycERsM0FtNVEKG+gBRcvUsyfRzuW999EQNzyDh2/5fY6p
8ab2xvTOJPiMUBTBuQZsA+iDImytUO/02ROQsbEZkALijp1nqrsV/g==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-07T05:37:26Z"
mac: ENC[AES256_GCM,data:vu6ltV0jYQNGV4eqIcqAp9p2TJ8ULBX5JBELUgm/nA9GX+I6Pv3fyjpcHRSWY0HbLvCwgXd22CG4IYcj4HJFtQGZR7OcQA5WM5plZH3RCPdvPNix/798vkETfpJxorQjlHoq4nlr3dEmUCbzdtE07qV2W1M6GJr4C9xKvB947bQ=,iv:aA9mjZCFAoLEhGqkl6NfyBZ3x3vWGfLYYAEe8jXuA4U=,tag:Drdiji33ckDd8ildpvSEXw==,type:str]
version: 3.13.2
@@ -0,0 +1,24 @@
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: forgejo-registry-token
namespace: cicd
data:
username: ENC[AES256_GCM,data:oRNecs0a7rs=,iv:/5EFdNqpSjoxe7XDmAEDMd6lXd0SMsRiMmBM9SIwDHY=,tag:Wi+ggoP+9/vgz9rHrxB9nA==,type:str]
password: ENC[AES256_GCM,data:HbmwnWk3ST5SDWXwIhu+M5f/FOfFbJHaG0Q/IWrO63HRXmJTmLMpbjtWIXC9IMFoHeDZ1MNAV8A=,iv:ztJZgpaS//yYCiyr6/ubs/UiXkbGMnokvy4Pe45jQMk=,tag:WMpawc/SVAV4mxsCd6XqAA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjQmhxN2E3ZnNTdkR6U2c1
ZStsQktkeWUwMEdmdFIyMkRYRVk1ZGppWlZ3CmUzR2JubWw2VjdWUGt6S2lpNmQ3
cDdSWjJiYzRIWS9UVERpVDFJenFnaDAKLS0tIGhDSmNMSjZwcDVPR3U0dUcwNk9Q
WmdGS1lyS3dkSXIvOFhyRklGRll2UlUK8NGts6a6Fv10c6gxIIPqIIqfgzQ3EEyk
SFrMThwbdaOqLY4AMeDrg+k0Y2t1Xj/XOvdNbRCRictDwZUsptvO7A==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-06T02:01:24Z"
mac: ENC[AES256_GCM,data:imebczVzG9YWd42ER4a+w+Bi3dxbN0HFm/1igxzJt6Z0HG/q6i0s59swpGVruk9dQJWrlO8KKqX739ATp2KGC5szrEuRFvPemF594XFCg6ykW+ZeP5VtTvpJg2nHv7/Pu+71ljKKi4gqOJ4SJDn8owAbSN+su6PiT323wax930A=,iv:iG/G2+qQkvbBU95w/GG2PrbAHDOaamuvjqMDBxSW6zI=,tag:wy9Vn72HBDHqCyzTcV15/Q==,type:str]
version: 3.13.2
@@ -0,0 +1,26 @@
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: memory-agent-oidc
namespace: poimen
data:
CLIENT_ID: ENC[AES256_GCM,data:c/RvtV6anofX8h1BmlKu9w==,iv:3lQ1BR8kVvF1RHVfKstmb0Y7u/uX5OrvmGiCi/xTpjw=,tag:9JC2WkfQFWaiZroAEmPenw==,type:str]
CLIENT_SECRET: ENC[AES256_GCM,data:I258/6O43PccyllYN/wfoKvTgNgZONTivM/KFIr8TGqSl+2XQkwBXvK2VQc5Xgc5cpwlrgnjk8pjQbqr,iv:MZyA+V5z9Po2VoxqvOSlWh1fZp1lrkcbYMx7VcSIaJs=,tag:gXH0BpA8KOZiVd+4RnhRzg==,type:str]
ISSUER: ENC[AES256_GCM,data:GX/uQPDoeJWXXQZlzD6oOIvxo7H4LRAv4MrPAiCgmv6WxW59+KKkymuDT/T9MiDm4bNhxVPgB7nHbDcMHfPvU2zCNM2TWYUsNOgGwxNAqM8=,iv:5OEsdrUZvMLO2C8Ea2i5alsyFGtmEk3othTemk8fOk4=,tag:8CMlRY2JCe4z4fv4WuWjRQ==,type:str]
TOKEN_URL: ENC[AES256_GCM,data:KeU1pQzvQ2/1lQhOqNtcR7t5TGVx1J7NY8ogxCXS6IuHhVe0zWYIbC4BYFc2H9PAjsMJwCCg75N1FrIiCYBvpfK8ln0=,iv:NbPiJC/B7W1Zm+JwnEGDikE2cb3L4l6TBeQTKEb2boM=,tag:FRmllFLZzQW03lsCu9mAUA==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBmK0dPSmhKT01FSitDdUNW
Um1XQU9FVnBUeDdoWXpCcXRzVXh0VXkvNGxNClZaQ3R5U2NkYTJjaFdHRWhUVmFS
K0tHZVRFRDZGREVRZzZqcWJ6dnRpUUEKLS0tIE15UUNtMStKbWwzb3dEVGEwN1VQ
cktlVTM2SUhpOGJZMXJOOG84RnhCQzAKOdM0pThEaN37SJtRrKIivncMu8Z91Btq
es6NOlJdBUhzIhnEJWfDzZ9aA6I/fYzugKbC+AfUwdGgThAgHx9crg==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-06T02:01:24Z"
mac: ENC[AES256_GCM,data:/BTQ984D+hsk0LmnjD3hxopB65NAR54LhoAPNcATWA7MCTJXfdnYRu3gNxUzyiyCBd+rDaPp77TBnak4jVjMnOiYJsYLj1R7hrrhTZopz8pT+YNMupperwsirKZwK8it4Mu+fnpCyox2mtGED0hk+Nh/b6IteTRt5lS7noumo2M=,iv:RWIrVkNYj+8mqMSPiLueToexabzAtVbIR525m6bUrlg=,tag:5+wcNoNYsjGKpFlML+N70Q==,type:str]
version: 3.13.2
@@ -0,0 +1,27 @@
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: oauth2-credentials
namespace: iam
data:
api-gw-client-secret: ENC[AES256_GCM,data:YiUCk61cCsTJE8gTLZWhqVPsCSu3b4OnG+Oh456A+mnxip0FjLTTqV+kYBdfZivIkn9ua4RcVW+rYILu,iv:K08m1EaB7/SIVNWZ3FfeRP4tbCAo6KeiNC0NpE9VO1o=,tag:ZXV9YRWUOPJ/vdoqMtvQeA==,type:str]
minio-client-secret: ENC[AES256_GCM,data:vcwLo1cJqbZr2MwkAKO09xPjb09ss7+s6HIvEfaLxt8ka5PUAlXQ37pTkynuktlKq/t0DvSk+x1+5G7QqVD1JJlwyDGM6yuOhBQ3MwucLNl+T4RDKtgGuA==,iv:sCdd+UNX2TfTAM6Zslrh8KrvBSzqpe67F3YMGrl4YDU=,tag:9Tpzm9NEXNuXyCd7l7GeHw==,type:str]
poimen-client-secret: ENC[AES256_GCM,data:dYtCiMGZaOFokihfGD2SXJDfXrhfwEQcTOPNu7JNHDUnx0hqwAdYAPAJTEN21p0OpHYDINMs5lwUpMwY,iv:ll8USuzs0881ryHImVjHGO0iFxUzqg5XtI6Ekd+EFXE=,tag:I/4ZfB9Ss4zEP57sRtRTXQ==,type:str]
paperless-client-secret: ENC[AES256_GCM,data:XMj9rHkI20lI37vChq9wS5lUq7l95SisRX0LChuj09O5w76Zt9NdG6OEUYQFf1z8vCfMJxBInIEEIq0e,iv:UpYy2ZEZ4AgBACEyrlxEcr13V+8Ksu4p8JHYApmyYHo=,tag:o9Z+/b6T9POgowIsks56kw==,type:str]
grafana-client-secret: ENC[AES256_GCM,data:V/j0+P3HoDLqYjRalWK4v1hvJTuVmzxVra47HsF1/BZvWZjIyEIH3Pt8BekT3O1dUFynxjcaxdZMBVYg,iv:9pMp+vIsp/sA7GP9wqNDf5DdZVLDow46OURW0mMRXIc=,tag:i7VJFA5o0hToZEJ5JPWIvw==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB2amZPendIOU9PeStOcmJG
c1dzOFVvd3RoT3dXQlJUNkdCSytEZUVGVEJJCklMUkNjUytBMXh0OVUrNUlpMWw3
aHRFT1hBZTZHNHVtaGJCTEFyT2ZWNWsKLS0tIENxR3FKMEplV0V5Y0dsZzBkODVG
U0ZlT2R0VFdEQmpsakVvZ1FJRkRQYTAKESQcSlMAuCG0/tLWWZaeoPayf1EKxSaf
e4o8TmjmSgPEhepQ/LmNXl6obyuqUxztkh4Mm6pocqDp+gfzt7X/1g==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-06T02:01:24Z"
mac: ENC[AES256_GCM,data:AI/znHiUa/Rwg0YRz65qUZ+b6V73oi3ZfAG/V5uZZvrGFrA0EISzD5LaP5Kl4GUU1svo4G5EI4aftPDgpdPptz21CCa8iYkXGe9pTDmtaE5ZICQAUbRcVhGMTfuWFhrgEhMfCJdOpfHaIQVEsQJQJgrnkXQVuUe9aT75LQz7pnY=,iv:KbIaYT0VeBT2WeSU5V5ZB4e+04JICYaY+egGN1Pv5bM=,tag:pPbhDxeLUx6V1XC/LPwxTg==,type:str]
version: 3.13.2
@@ -0,0 +1,24 @@
apiVersion: v1
kind: Secret
type: Opaque
metadata:
name: pi-code-agent-api-keys
namespace: poimen-sandbox
stringData:
ANTHROPIC_API_KEY: ENC[AES256_GCM,data:TBcno/dzj3qykHjeX96UKZekuA/WMQT2E7a1ziPyYo5J6lWLPydgli84KeJZhH62/8CnnFtFOhVIe4FWCGPGjvqKH6M8WbiQHaP9y7FN0qQUpFHKNVAQ2R+s0wInhw0eUmANvd7bRx8aIZ2X,iv:oex19yVtWUevoJ0A3mkMLCfWu85o1gPSNp43x1iV0LE=,tag:BW3zQnwvagPceeNird9CZg==,type:str]
DEEPSEEK_API_KEY: ENC[AES256_GCM,data:vq/5gOsMVjlVMd0oXiBtHFHFEr+IF2YR2iP2OBFWx3ZFNoQ=,iv:QhLU/BRIJb/7QKDCRyw+HWPphZifVEhIOLywYDgOb20=,tag:BZdtXw+WsuK+P4E0caTa0Q==,type:str]
sops:
age:
- enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqNm1LNE5nWktnd3c3STg3
UlVWbWJxWElxUHMwa0JVMk81Z0c4d0JaQm1vCnpLQWJWV3hxOU00NzJIaG51Mmlr
ZWpNTFJWcmdtV0w4YndQeHpvSUx2MTAKLS0tIDVnNkNuVWhPUFZ5V2hFOEt1eitG
blVqRy8yNnYzSU1FNzZGNkxrRElvWVEKL3J77INje75+CvKXaJHC3CvONgHv7d0a
a10MITvfreFr6M4AN3Z/mno49SDMp15Q4oQRagHHxSTS1K54mWHo8g==
-----END AGE ENCRYPTED FILE-----
recipient: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
encrypted_regex: ^(data|stringData)$
lastmodified: "2026-10-06T01:19:49Z"
mac: ENC[AES256_GCM,data:GpYl8IRefms36GvO3RW41c0US7SOgoOhd4rumxsCdEf5ME9kJ0eOxPx8klMgnlYrkBnwHmad8jj7C+HcA5Eim7mdUaYOZZWPFohvC6N4GLHX85aH78P09CelqG5tdm/Gm+1QfxYu0vANYGCAVjLK7YbBbZjbZmbvVfQvAbs3mOY=,iv:y3lglGu8FhTHQpO1poSIKgGYJFu5J9yjIaOjulda6fY=,tag:5Sgh4TbtNFMw7xOJjYRn0g==,type:str]
version: 3.13.2
+31 -26
View File
@@ -7,29 +7,34 @@ metadata:
exec: exec:
path: ksops path: ksops
files: files:
- agent-pod-models.enc.yaml - agent-pod-models.enc.yaml
- agent-pod-ssh-key.enc.yaml - agent-pod-ssh-key.enc.yaml
- authentik-secrets.enc.yaml - authentik-secrets.enc.yaml
- cloudflare-secrets.enc.yaml - cloudflare-secrets.enc.yaml
- forgejo-registry-pat.enc.yaml - forgejo-registry-pat.enc.yaml
- forgejo-registry-pat-api.enc.yaml - forgejo-registry-pat-api.enc.yaml
- forgejo-registry-pull.enc.yaml - forgejo-registry-pull.enc.yaml
- forgejo-runner-token.enc.yaml - forgejo-runner-token.enc.yaml
- forgejo-secrets.enc.yaml - forgejo-secrets.enc.yaml
- grafana-oidc-secrets.enc.yaml - grafana-oidc-secrets.enc.yaml
- grafana-secrets.enc.yaml - grafana-secrets.enc.yaml
- homarr-auth-oidc.enc.yaml - homarr-auth-oidc.enc.yaml
- homarr-db-encryption.enc.yaml - homarr-db-encryption.enc.yaml
- homarr-secrets.enc.yaml - homarr-secrets.enc.yaml
- homelab-ca-secrets.enc.yaml - homelab-ca-secrets.enc.yaml
- loki-secrets.enc.yaml - loki-secrets.enc.yaml
- minio-secrets.enc.yaml - minio-secrets.enc.yaml
- paperless-secrets.enc.yaml - paperless-secrets.enc.yaml
- vault-secrets.enc.yaml - vault-secrets.enc.yaml
- vault-unseal-keys.enc.yaml - vault-unseal-keys.enc.yaml
- portfolio-secrets.enc.yaml - portfolio-secrets.enc.yaml
- gotify-admin-secrets.enc.yaml - gotify-admin-secrets.enc.yaml
- gotify-tokens-secrets.enc.yaml - gotify-tokens-secrets.enc.yaml
- gotify-smtp-secrets.enc.yaml - gotify-smtp-secrets.enc.yaml
- forgejo-smtp-secrets.enc.yaml - forgejo-smtp-secrets.enc.yaml
- paperless-ai-secrets.enc.yaml - paperless-ai-secrets.enc.yaml
- memory-agent-oidc.enc.yaml
- oauth2-credentials.enc.yaml
- forgejo-registry-token.enc.yaml
- pi-sandbox-secrets.enc.yaml
- etcd-defrag-creds.enc.yaml
@@ -0,0 +1,177 @@
# etcd backend maintenance: compact, defrag, disarm NOSPACE.
#
# Why this exists
# ---------------
# etcd's backend file only grows. The apiserver compacts every 5m, but
# compaction marks superseded revisions free *inside* the file; only `defrag`
# returns that space to the filesystem. Nothing ran defrag, so the file grew
# until it crossed the 2 GiB quota:
#
# 2026-10-07 backend 2,148,843,520 B (2.1 GB) on talos-cp-1 and talos-cp-3
# -> etcd raised NOSPACE on those two members
# -> etcd rejected ALL writes cluster-wide
# -> kubelets could not post node status: 3 nodes NotReady
# -> apiserver writes returned 500, the tunnel API path 502
#
# The live data was tiny: 15,943 keys, 69 MB after compaction. The rest was
# dead pages. Recovery was `compact <current-revision>` then `defrag` per
# member then `alarm disarm`. Compaction had also stalled while the alarm was
# up, so `dbSizeInUse` read 100% of the file - defrag alone would NOT have
# worked. Order matters: compact first, then defrag.
#
# The earlier revision of this file never ran, for two independent reasons:
# 1. it existed only on branch fix/etcd-defrag-cron; the ArgoCD application
# `cluster-maintenance` tracks `main` (targetRevision: main), so the
# cluster never had the CronJob or the Secret.
# 2. the image was quay.io/coreos/etcd, which has NO /bin/sh (verified:
# `exec: "/bin/sh": no such file or directory`). Any container that runs
# a shell script needs an image that ships one.
# This revision fixes both: image bitnamilegacy/etcd (ships bash + etcdctl
# 3.6.4, matching the 3.6.11 servers) pinned by digest.
#
# Cadence: every 15 days. Measured growth is ~29 MB/day, so a 15-day cycle
# peaks near 500 MB - comfortably under the 2 GiB quota. The schedule is daily
# and the script exits unless the UTC epoch day is a multiple of 15. That gives
# an exact 15-day interval; `*/15` in the day-of-month field does not (it fires
# on the 1st, 16th and 31st, which puts two runs one day apart).
#
# Safety
# ------
# 1. Compaction uses the MINIMUM revision across members. Compacting to a
# revision a member has not reached wedges that member - measured on
# 2026-10-07, when talos-cp-2 froze ~1.2M entries behind the leader and
# needed a reboot to rejoin.
# 2. A member whose raft applied index lags the leader by more than 100000 is
# skipped and reported. Defragging a frozen member reclaims nothing.
# 3. Members are defragged one at a time. A defrag pauses the member's apply
# loop for the duration of the file copy.
#
# Credentials: Secret etcd-defrag-creds in kube-system, SOPS-managed in
# k8s/argocd/secrets/etcd-defrag-creds.enc.yaml (ksops generator 00-secrets).
# Its client certificate is signed by the cluster's etcd CA and expires
# 2036-10; rotate it before then by re-running the mint and re-encrypting.
#
# Companion alerts (monitoring): etcd_mvcc_db_total_size_in_bytes /
# etcd_server_quota_backend_bytes > 0.8, and an etcd NOSPACE alarm alert.
apiVersion: batch/v1
kind: CronJob
metadata:
name: etcd-defrag
namespace: kube-system
labels:
app.kubernetes.io/name: etcd-defrag
app.kubernetes.io/part-of: cluster-maintenance
spec:
schedule: "0 3 * * *" # daily; the script acts only every 15th day
concurrencyPolicy: Forbid
startingDeadlineSeconds: 600
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
spec:
restartPolicy: OnFailure
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
containers:
- name: defrag
image: bitnamilegacy/etcd:3.6.4@sha256:c86c06f1ce6a08b6df21eb7a9655b1818778b64c85b5cbb8413ebf7534789730
env:
- name: ETCDCTL_API
value: "3"
- name: ETCDCTL_CACERT
value: /etc/etcd/ca.crt
- name: ETCDCTL_CERT
value: /etc/etcd/client.crt
- name: ETCDCTL_KEY
value: /etc/etcd/client.key
- name: EPS
value: "https://192.168.1.166:2379 https://192.168.1.214:2379 https://192.168.1.162:2379"
command: ["/bin/bash", "-c"]
args:
- |
set -u
DAY=$(( $(date -u +%s) / 86400 ))
if [ $(( DAY % 15 )) -ne 0 ]; then
echo "skip: UTC epoch day $DAY is not a 15-day boundary"
exit 0
fi
echo "== before =="
for ep in $EPS; do
etcdctl --endpoints="$ep" endpoint status -w table || true
done
MINREV=""
MAXAPPLIED=0
for ep in $EPS; do
json=$(etcdctl --endpoints="$ep" endpoint status -w json 2>/dev/null) || continue
rev=$(printf '%s' "$json" | sed -n 's/.*"revision":\([0-9]*\).*/\1/p' | head -1)
app=$(printf '%s' "$json" | sed -n 's/.*"raftAppliedIndex":\([0-9]*\).*/\1/p' | head -1)
echo "member $ep revision=${rev:-?} raftAppliedIndex=${app:-?}"
if [ -n "$rev" ] && { [ -z "$MINREV" ] || [ "$rev" -lt "$MINREV" ]; }; then
MINREV="$rev"
fi
if [ -n "$app" ] && [ "$app" -gt "$MAXAPPLIED" ]; then
MAXAPPLIED="$app"
fi
done
if [ -z "$MINREV" ]; then
echo "FATAL: no member returned a revision; nothing attempted"
exit 1
fi
echo "== compact to revision $MINREV =="
etcdctl --endpoints="$(echo "$EPS" | awk '{print $1}')" compact "$MINREV" || echo "COMPACT-FAILED (continuing: defrag still reclaims free pages)"
# Physical deletion of superseded keys runs in the background
# after the compaction commit; give it a moment to drain.
sleep 20
echo "== defrag each member (sequential, one at a time) =="
rc=0
for ep in $EPS; do
json=$(etcdctl --endpoints="$ep" endpoint status -w json 2>/dev/null) || { echo "SKIP $ep (unreachable)"; rc=1; continue; }
app=$(printf '%s' "$json" | sed -n 's/.*"raftAppliedIndex":\([0-9]*\).*/\1/p' | head -1)
if [ -n "$app" ] && [ "$app" -lt $(( MAXAPPLIED - 100000 )) ]; then
echo "SKIP $ep (applied index $app lags leader $MAXAPPLIED; defrag would reclaim nothing)"
rc=1
continue
fi
echo "-- $ep"
if ! etcdctl --endpoints="$ep" defrag; then
echo "DEFRAG-FAILED $ep"
rc=1
fi
done
echo "== disarm NOSPACE =="
for ep in $EPS; do
etcdctl --endpoints="$ep" alarm list 2>/dev/null || true
etcdctl --endpoints="$ep" alarm disarm 2>/dev/null || true
done
echo "== after =="
for ep in $EPS; do
etcdctl --endpoints="$ep" endpoint status -w table || true
done
etcdctl --endpoints="$(echo "$EPS" | awk '{print $1}')" alarm list || true
exit $rc
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { cpu: 200m, memory: 128Mi }
volumeMounts:
- name: creds
mountPath: /etc/etcd
readOnly: true
volumes:
- name: creds
secret:
secretName: etcd-defrag-creds
defaultMode: 0444
+7
View File
@@ -0,0 +1,7 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
# No commonLabels on purpose: this is a vendored third-party manifest and
# kustomize would also rewrite the provisioner Deployment's selector, which
# is immutable on re-apply.
resources:
- local-path-storage.yaml
@@ -0,0 +1,200 @@
# Vendored from https://github.com/rancher/local-path-provisioner
# deploy/local-path-storage.yaml @ v0.0.37 (release 2026-08-05)
#
# Local modifications (the only three; re-apply them on any re-vendor):
# 1. the namespace carries pod-security.kubernetes.io/enforce=privileged, because the
# provisioner's helper Pods mount a hostPath and this cluster defaults to the
# baseline policy, which forbids that;
# 2. config.json nodePathMap is /var/lib/local-path-provisioner — Talos mounts the root
# filesystem read-only, so upstream's /opt path cannot be created;
# 3. the helper Pod's busybox image is pinned to a tag instead of a floating name.
apiVersion: v1
kind: Namespace
metadata:
name: local-path-storage
labels:
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/audit: privileged
pod-security.kubernetes.io/warn: privileged
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: local-path-provisioner-service-account
namespace: local-path-storage
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: local-path-provisioner-role
namespace: local-path-storage
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch", "create", "patch", "update", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: local-path-provisioner-role
rules:
- apiGroups: [""]
resources: ["nodes", "persistentvolumeclaims", "configmaps", "pods", "pods/log"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["persistentvolumes"]
verbs: ["get", "list", "watch", "create", "patch", "update", "delete"]
- apiGroups: [""]
resources: ["events"]
verbs: ["create", "patch"]
- apiGroups: ["storage.k8s.io"]
resources: ["storageclasses"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: local-path-provisioner-bind
namespace: local-path-storage
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: local-path-provisioner-role
subjects:
- kind: ServiceAccount
name: local-path-provisioner-service-account
namespace: local-path-storage
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: local-path-provisioner-bind
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: local-path-provisioner-role
subjects:
- kind: ServiceAccount
name: local-path-provisioner-service-account
namespace: local-path-storage
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: local-path-provisioner
namespace: local-path-storage
spec:
replicas: 1
selector:
matchLabels:
app: local-path-provisioner
template:
metadata:
labels:
app: local-path-provisioner
spec:
serviceAccountName: local-path-provisioner-service-account
containers:
- name: local-path-provisioner
image: docker.io/rancher/local-path-provisioner:v0.0.37
imagePullPolicy: IfNotPresent
command:
- local-path-provisioner
- --debug
- start
- --config
- /etc/config/config.json
ports:
- name: health
containerPort: 8080
protocol: TCP
startupProbe:
httpGet:
path: /health
port: health
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 12
livenessProbe:
httpGet:
path: /health
port: health
initialDelaySeconds: 10
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: health
initialDelaySeconds: 5
periodSeconds: 5
volumeMounts:
- name: config-volume
mountPath: /etc/config/
env:
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: CONFIG_MOUNT_PATH
value: /etc/config/
- name: HEALTH_PORT
value: "8080"
volumes:
- name: config-volume
configMap:
name: local-path-config
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: local-path
provisioner: rancher.io/local-path
volumeBindingMode: WaitForFirstConsumer
reclaimPolicy: Delete
---
kind: ConfigMap
apiVersion: v1
metadata:
name: local-path-config
namespace: local-path-storage
data:
config.json: |-
{
"nodePathMap":[
{
"node":"DEFAULT_PATH_FOR_NON_LISTED_NODES",
"paths":["/var/lib/local-path-provisioner"]
}
]
}
setup: |-
#!/bin/sh
set -eu
mkdir -m 0777 -p "$VOL_DIR"
teardown: |-
#!/bin/sh
set -eu
rm -rf "$VOL_DIR"
helperPod.yaml: |-
apiVersion: v1
kind: Pod
metadata:
name: helper-pod
spec:
priorityClassName: system-node-critical
tolerations:
- key: node.kubernetes.io/disk-pressure
operator: Exists
effect: NoSchedule
containers:
- name: helper-pod
image: docker.io/library/busybox:1.36
imagePullPolicy: IfNotPresent
+13 -10
View File
@@ -41,13 +41,24 @@ spec:
# - /var/lib/longhorn-disk1 (sdb1, 600GB) # - /var/lib/longhorn-disk1 (sdb1, 600GB)
# - /var/lib/longhorn-disk2 (sdc1, 500GB) # - /var/lib/longhorn-disk2 (sdc1, 500GB)
# - /var/lib/longhorn-disk3 (sdd1, 160GB) # - /var/lib/longhorn-disk3 (sdd1, 160GB)
# - /var/lib/longhorn-disk4 (sde1, 2TB) #
# /var/lib/longhorn-disk4 (sde1, the 2TB PERC virtual disk) was RETIRED
# on 2026-10-12 and must not be re-added. The device returned SCSI I/O
# errors at ~7,000/minute, the XFS filesystem on it hung, and every
# process that touched it blocked in unkillable D state. Those unkillable
# processes held Longhorn's per-node rebuild slots forever, so no new
# replica could start on talos-cp-2 and every new volume there failed to
# attach. It is removed from the Terraform disk list too, so a re-render
# does not put it back.
echo "Checking talos-cp-2..." echo "Checking talos-cp-2..."
CURRENT_DISKS=$(kubectl -n longhorn-system get nodes.longhorn.io talos-cp-2 -o json | jq -r '.spec.disks | keys | length') CURRENT_DISKS=$(kubectl -n longhorn-system get nodes.longhorn.io talos-cp-2 -o json | jq -r '.spec.disks | keys | length')
echo " Current disk count: $CURRENT_DISKS" echo " Current disk count: $CURRENT_DISKS"
if [ "$CURRENT_DISKS" -lt 5 ]; then # 4 = default-disk-080400000000 + disk1..disk3. It was 5 while disk4
# existed; leaving the old threshold would re-apply the patch on every
# run once disk4 is gone.
if [ "$CURRENT_DISKS" -lt 4 ]; then
echo " Adding extra disks to talos-cp-2..." echo " Adding extra disks to talos-cp-2..."
kubectl -n longhorn-system patch nodes.longhorn.io talos-cp-2 --type merge -p '{ kubectl -n longhorn-system patch nodes.longhorn.io talos-cp-2 --type merge -p '{
"spec": { "spec": {
@@ -75,14 +86,6 @@ spec:
"path": "/var/lib/longhorn-disk3", "path": "/var/lib/longhorn-disk3",
"storageReserved": 5368709120, "storageReserved": 5368709120,
"tags": [] "tags": []
},
"disk4": {
"allowScheduling": true,
"diskType": "filesystem",
"evictionRequested": false,
"path": "/var/lib/longhorn-disk4",
"storageReserved": 21474836480,
"tags": []
} }
} }
} }
@@ -52,14 +52,14 @@ spec:
restartPolicy: Never restartPolicy: Never
initContainers: initContainers:
- name: kubectl-copy - name: kubectl-copy
image: bitnami/kubectl:latest image: bitnami/kubectl:latest@sha256:f7f9e4f64d9e114650c115a4ac6fd383394b3d494061a8f17b1a0f6c8d55bc25
command: ["sh", "-c", "cp $(which kubectl) /shared/kubectl"] command: ["sh", "-c", "cp $(which kubectl) /shared/kubectl"]
volumeMounts: volumeMounts:
- name: shared - name: shared
mountPath: /shared mountPath: /shared
containers: containers:
- name: provision - name: provision
image: quay.io/minio/mc:latest image: docker.io/bitnamilegacy/minio-client:2025.7.21-debian-12-r3@sha256:73bd39f7899a0cef12b8dd5df13aa93a3ed1aaa44236542442e9ac76819ac158
volumeMounts: volumeMounts:
- name: shared - name: shared
mountPath: /shared mountPath: /shared
+9 -1
View File
@@ -11,7 +11,15 @@ metadata:
prometheus.io/port: "9000" prometheus.io/port: "9000"
prometheus.io/scrape: "true" prometheus.io/scrape: "true"
spec: spec:
image: minio/minio:RELEASE.2025-07-23T15-54-02Z # Was minio/minio:RELEASE.2025-07-23T15-54-02Z — that Docker Hub repository no longer
# exists (404 for every tag), so the tenant sat in ImagePullBackOff and Vault's S3
# backend could not resolve minio-cluster-hl. Official images now need registry auth
# (quay.io/minio/minio returns 401), so this pins a public mirror of the same upstream
# release; swap it for the official image once MinIO registry credentials exist.
# The digest freezes those bytes, so a retag or a deletion on the mirror cannot break
# the pull again. The same digest already sits in the containerd cache on talos-cp-1,
# so the first start needs no registry access.
image: docker.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z@sha256:69b55a1c1c5dc285ce04db96689f5b2102317fc77a50680a1874ca6efd1c87f9
# Disable operator auto-TLS: MinIO serves plain HTTP internally on 9000. # Disable operator auto-TLS: MinIO serves plain HTTP internally on 9000.
# External TLS is terminated at nginx ingress (wildcard riotpiao-com-tls cert). # External TLS is terminated at nginx ingress (wildcard riotpiao-com-tls cert).
@@ -158,3 +158,66 @@ spec:
severity: warning severity: warning
annotations: annotations:
summary: "Gateway pod {{ $labels.pod }} memory at {{ $value | humanizePercentage }} of limit" summary: "Gateway pod {{ $labels.pod }} memory at {{ $value | humanizePercentage }} of limit"
- name: api-gateway.admission
# The admission gate fails OPEN when its view of the engine gauges goes
# stale, so a broken observer degrades silently to the pre-#64 behaviour
# of admitting everything. These two series are the only detection —
# the gate has no automated tests.
rules:
# The gate fails OPEN when it cannot read the engine, so "no data" must
# alert: otherwise a missing emitter, a renamed metric, or a broken
# scrape is indistinguishable from a healthy observer. Both expressions
# therefore OR in an absent() guard on the metric itself.
- alert: APIGatewayObserverDegraded
expr: observer_degraded == 1 or absent(observer_degraded)
for: 2m
labels:
severity: warning
annotations:
summary: "Gateway admission observer degraded or not reporting — gate is failing open"
description: "observer_degraded is either 1 or absent. Absent means the emitter is missing, renamed, or unscraped, which is the fail-open case."
# Engine gauges went stale, or are not being reported at all
- alert: GatewayEngineStateStale
expr: increase(engine_state_stale_total[15m]) > 0 or absent(engine_state_stale_total)
for: 2m
labels:
severity: warning
annotations:
summary: "Gateway engine state stale or not reporting — admission may be based on stale gauges"
description: "Non-zero means the observer lost the engine; absent means the metric is not being emitted at all."
# The acceptance metric for reasoning.yaml's prefill change. Preemption
# of a 65k prompt costs a full re-prefill (~250s at the measured
# 262 tok/s), so any sustained rate is a regression, not noise.
- alert: LLMPreemptions
# Guarded absent() is deliberately NOT used bare here: the engine
# restarts (strategy Recreate) and the series legitimately vanishes
# for the length of a boot, so a bare absent() would false-fire on
# every rollout. It only fires when the metric is missing WHILE the
# predictor is up, which is the real "renamed or unscraped" case.
expr: |
increase(vllm:num_preemptions_total[15m]) > 0
or (absent(vllm:num_preemptions_total) and on() up{job="reasoning-predictor"} == 1)
for: 5m
labels:
severity: warning
annotations:
summary: "vLLM preempting requests ({{ $value }} in 15m) — KV cache over-committed, re-prefill cost is severe"
description: "Any sustained preemption rate is a regression for the reasoning prefill change: evicting a 65k-token prompt costs a full re-prefill (~250s at the measured 262 tok/s). Orchestrator verified `up{job=\"reasoning-predictor\"}` is the live job label — if that label is wrong the guard is inert but harmless."
# This change trades single-request latency for fairness: capping a long
# prompt at 2048 tokens/step means it takes more steps to finish, so a
# lone large prompt can now be slower even while preemptions stay 0.
# Without this alert the change has no way to show a regression.
- alert: LLMPrefillStarvation
expr: |
(sum(vllm:num_requests_waiting) > 0 and max(vllm:kv_cache_usage_perc) < 0.5)
or histogram_quantile(0.95, sum(rate(vllm:time_to_first_token_seconds_bucket[10m])) by (le)) > 60
for: 10m
labels:
severity: warning
annotations:
summary: "reasoning queueing or TTFT p95 above 60s while KV is under 50% — scheduling, not capacity"
description: "Queue depth with spare KV means requests are being starved rather than blocked on memory; a high TTFT p95 with headroom points at the same. Both series verified live (vllm:num_requests_waiting and vllm:time_to_first_token_seconds exist)."
@@ -27,6 +27,24 @@ spec:
annotations: annotations:
summary: "{{ $value }} pod(s) stuck in Pending state for >10m" summary: "{{ $value }} pod(s) stuck in Pending state for >10m"
# ImagePullBackOff on a vendor-deleted image produced no alert for 13 days
- alert: PodImagePullFailure
expr: sum(kube_pod_container_status_waiting_reason{reason=~"ImagePullBackOff|ErrImagePull"}) by (namespace, pod) > 0
for: 10m
labels:
severity: critical
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} cannot pull its image"
# Pod not ready
- alert: PodNotReady
expr: sum(kube_pod_status_ready{condition="false"}) by (namespace, pod) > 0
for: 15m
labels:
severity: warning
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} is not ready"
# CrashLoopBackOff # CrashLoopBackOff
- alert: PodCrashLooping - alert: PodCrashLooping
expr: sum(kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"}) by (namespace, pod) > 0 expr: sum(kube_pod_container_status_waiting_reason{reason="CrashLoopBackOff"}) by (namespace, pod) > 0
@@ -26,20 +26,23 @@ def stat_panel(id, title, expr, x, y, w=4, h=4, unit="short", mappings=None, thr
return p return p
def ts_panel(id, title, exprs, x, y, w=8, h=8, unit="short"): def ts_panel(id, title, exprs, x, y, w=8, h=8, unit="short", description=None):
targets = [] targets = []
for e in exprs: for e in exprs:
if isinstance(e, tuple): if isinstance(e, tuple):
targets.append({"expr": e[0], "legendFormat": e[1]}) targets.append({"expr": e[0], "legendFormat": e[1]})
else: else:
targets.append({"expr": e, "legendFormat": "{{pod}}"}) targets.append({"expr": e, "legendFormat": "{{pod}}"})
return { p = {
"id": id, "title": title, "type": "timeseries", "id": id, "title": title, "type": "timeseries",
"gridPos": {"h": h, "w": w, "x": x, "y": y}, "gridPos": {"h": h, "w": w, "x": x, "y": y},
"datasource": DS_PROM, "datasource": DS_PROM,
"fieldConfig": {"defaults": {"unit": unit}}, "fieldConfig": {"defaults": {"unit": unit}},
"targets": targets, "targets": targets,
} }
if description:
p["description"] = description
return p
def table_panel(id, title, expr, x, y, w=12, h=8): def table_panel(id, title, expr, x, y, w=12, h=8):
@@ -274,23 +277,29 @@ def build_api_gateway():
# vLLM + TEI (llm-serving) — engine-truth, but only for the three engines # vLLM + TEI (llm-serving) — engine-truth, but only for the three engines
# that expose /metrics. Ollama (ornith:35b, # that expose /metrics. Ollama (ornith:35b,
# qwen2.5:3b-instruct) exposes nothing at all. # qwen2.5:3b-instruct) exposes nothing at all.
# api-gateway (/metrics) — intended to cover EVERY model including the # api-gateway (/metrics) — covers EVERY model including the two Ollama
# Ollama ones, but see the dead-metrics note # ones, derived from the SSE byte stream: TTFT is
# below: its latency series are not recorded. # request-start to first byte, ITL is the gap
# between SSE writes carrying "data: ", so it
# counts SSE chunks rather than true tokens.
# vLLM's own histograms remain authoritative for
# `reasoning`.
# #
# NOTE — llm_ttft_seconds, llm_itl_seconds and llm_tokens_total are DEAD as of # These three series ARE live as of 2026-09-29 — 3 series each, one per replica,
# 2026-09-29. The only recorder is LLMMetricsCapture # verified against the running gateway and Prometheus:
# (internal/proxy/llm_metrics.go), and NewLLMMetricsCapture() has ZERO call # llm_ttft_seconds, llm_itl_seconds, llm_tokens_total
# sites in the whole repo, so the exporter emits HELP-only stubs and any panel
# querying them stays empty forever. Verified by grep, not assumed.
# #
# The gateway latency panels are kept deliberately so the gap is visible — they # History, recorded so nobody re-derives it the hard way: on 2026-09-29 these
# light up unchanged once the capture wrapper is wired into the streaming proxy # recorded nothing at all. internal/proxy/llm_metrics.go declared a capture
# path. Until then vLLM's own histograms are the latency source for `reasoning`, # wrapper that was never constructed, so the exporter emitted HELP-only stubs.
# and the two Ollama models have no latency visibility from anywhere. # homelab-frontend#63 fixed exactly that — the wrapper is now
# ResponseWriterWrapper, constructed by NewResponseWriterWrapper
# (internal/proxy/llm_metrics.go:38) and installed on the streaming proxy path.
# The "dead code" note that used to live in this header is superseded.
# #
# Panels backed by queue_full_requests_total, warmup_requests_delayed_total and # Panels backed by queue_full_requests_total, warmup_requests_delayed_total and
# vllm_prefix_cache_hit_tokens_total DO work today (3 series, one per replica). # vllm_prefix_cache_hit_tokens_total also work (3 series, one per replica).
#
# #
# All metric names below were verified against a live scrape, not assumed: # All metric names below were verified against a live scrape, not assumed:
# vLLM 0.11.0 exposes prefix_cache_hits_total / prefix_cache_queries_total, # vLLM 0.11.0 exposes prefix_cache_hits_total / prefix_cache_queries_total,
@@ -436,6 +445,48 @@ def build_llm_observability():
16, 15, w=8, h=8, unit="s"), 16, 15, w=8, h=8, unit="s"),
])) ]))
# ── Admission control ───────────────────────────────────────────────────
# These four panels read the gateway's admission gate, which is the
# companion change in homelab-frontend#64. That emitter is NOT deployed
# yet (verified against the live /metrics endpoint: 0 series matching
# ^(admission_|observer_|engine_state_)), so they render "No data" until
# #64 ships. That is expected, and each panel carries a description
# saying so — an unexplained blank panel would read as a broken
# dashboard, which is exactly the failure mode this row exists to catch.
panels.append(row(160, "Admission Control", 16, [
ts_panel(161, "Admission parked (5m)",
[("increase(admission_parked_total[5m])", "parked")],
0, 17, w=8, h=8, unit="short",
description="Requests the gateway held back rather than admitting. "
"Emitted by the admission gate (homelab-frontend#64); "
"empty until that change is deployed."),
ts_panel(162, "Admission rejected by reason",
[("sum by (reason) (increase(admission_rejected_total[5m]))", "{{reason}}")],
8, 17, w=8, h=8, unit="short",
description="Admission rejections split by the gate's own reason label. "
"Emitted by homelab-frontend#64; empty until deployed."),
ts_panel(163, "Engine state stale",
[("increase(engine_state_stale_total[5m])", "stale")],
16, 17, w=8, h=8, unit="short",
description="Times the gateway's view of the engine gauges went stale. "
"The gate fails OPEN when this happens, so any non-zero rate "
"means admission is silently degraded to admit-everything. "
"Emitted by homelab-frontend#64; empty until deployed."),
ts_panel(164, "Gateway view vs engine truth",
[("max(vllm_requests_running)", "gateway: running"),
("sum(vllm:num_requests_running)", "engine: running"),
("max(vllm_kv_usage_perc)", "gateway: kv (fraction) [same as engine if healthy]"),
("max(vllm:kv_cache_usage_perc)", "engine: kv (fraction)")],
0, 18, w=24, h=8, unit="short",
description="The gateway's own reading of the engine against the engine's "
"self-reported value. A disagreement between a pair is how a "
"stale observer or a mistuned admission target shows itself, "
"and nothing else in this dashboard reveals it. The two "
"gateway series (vllm_requests_running, vllm_kv_usage_perc) "
"ship with homelab-frontend#64 and are absent until it is "
"deployed — until then this panel shows the engine's pair only."),
]))
return { return {
"title": "LLM Observability", "title": "LLM Observability",
"uid": "llm-observability", "uid": "llm-observability",
@@ -64,7 +64,27 @@ data:
/ rerank latency p95","type":"timeseries","gridPos":{"h":8,"w":8,"x":16,"y":15},"datasource":{"type":"prometheus","uid":"prometheus"},"fieldConfig":{"defaults":{"unit":"s"}},"targets":[{"expr":"histogram_quantile(0.95, / rerank latency p95","type":"timeseries","gridPos":{"h":8,"w":8,"x":16,"y":15},"datasource":{"type":"prometheus","uid":"prometheus"},"fieldConfig":{"defaults":{"unit":"s"}},"targets":[{"expr":"histogram_quantile(0.95,
sum(rate(te_embed_duration_bucket[5m])) by (le, app))","legendFormat":"{{app}} sum(rate(te_embed_duration_bucket[5m])) by (le, app))","legendFormat":"{{app}}
embed"},{"expr":"histogram_quantile(0.95, sum(rate(te_embed_queue_duration_bucket[5m])) embed"},{"expr":"histogram_quantile(0.95, sum(rate(te_embed_queue_duration_bucket[5m]))
by (le, app))","legendFormat":"{{app}} queue"}]}]}]}' by (le, app))","legendFormat":"{{app}} queue"}]}]},{"id":160,"title":"Admission
Control","type":"row","collapsed":true,"gridPos":{"h":1,"w":24,"x":0,"y":16},"panels":[{"id":161,"title":"Admission
parked (5m)","type":"timeseries","gridPos":{"h":8,"w":8,"x":0,"y":17},"datasource":{"type":"prometheus","uid":"prometheus"},"fieldConfig":{"defaults":{"unit":"short"}},"targets":[{"expr":"increase(admission_parked_total[5m])","legendFormat":"parked"}],"description":"Requests
the gateway held back rather than admitting. Emitted by the admission gate (homelab-frontend#64);
empty until that change is deployed."},{"id":162,"title":"Admission rejected by
reason","type":"timeseries","gridPos":{"h":8,"w":8,"x":8,"y":17},"datasource":{"type":"prometheus","uid":"prometheus"},"fieldConfig":{"defaults":{"unit":"short"}},"targets":[{"expr":"sum
by (reason) (increase(admission_rejected_total[5m]))","legendFormat":"{{reason}}"}],"description":"Admission
rejections split by the gate''s own reason label. Emitted by homelab-frontend#64;
empty until deployed."},{"id":163,"title":"Engine state stale","type":"timeseries","gridPos":{"h":8,"w":8,"x":16,"y":17},"datasource":{"type":"prometheus","uid":"prometheus"},"fieldConfig":{"defaults":{"unit":"short"}},"targets":[{"expr":"increase(engine_state_stale_total[5m])","legendFormat":"stale"}],"description":"Times
the gateway''s view of the engine gauges went stale. The gate fails OPEN when
this happens, so any non-zero rate means admission is silently degraded to admit-everything.
Emitted by homelab-frontend#64; empty until deployed."},{"id":164,"title":"Gateway
view vs engine truth","type":"timeseries","gridPos":{"h":8,"w":24,"x":0,"y":18},"datasource":{"type":"prometheus","uid":"prometheus"},"fieldConfig":{"defaults":{"unit":"short"}},"targets":[{"expr":"max(vllm_requests_running)","legendFormat":"gateway:
running"},{"expr":"sum(vllm:num_requests_running)","legendFormat":"engine: running"},{"expr":"max(vllm_kv_usage_perc)","legendFormat":"gateway:
kv (fraction) [same as engine if healthy]"},{"expr":"max(vllm:kv_cache_usage_perc)","legendFormat":"engine:
kv (fraction)"}],"description":"The gateway''s own reading of the engine against
the engine''s self-reported value. A disagreement between a pair is how a stale
observer or a mistuned admission target shows itself, and nothing else in this
dashboard reveals it. The two gateway series (vllm_requests_running, vllm_kv_usage_perc)
ship with homelab-frontend#64 and are absent until it is deployed \u2014 until
then this panel shows the engine''s pair only."}]}]}'
kind: ConfigMap kind: ConfigMap
metadata: metadata:
annotations: annotations:
@@ -1,25 +1,36 @@
# WARNING — three of the series documented below are DEAD CODE as of # History, recorded so nobody re-derives it the hard way: on 2026-09-29 the
# 2026-09-29 and will never produce data: # three latency series below recorded nothing. internal/proxy/llm_metrics.go
# declared a capture wrapper (LLMMetricsCapture) that was never constructed
# anywhere, so the exporter emitted HELP-only stubs and the dashboard panels
# stayed empty.
# #
# llm_ttft_seconds, llm_itl_seconds, llm_tokens_total # homelab-frontend#63 fixed exactly that. The wrapper is now
# ResponseWriterWrapper, constructed by NewResponseWriterWrapper
# (internal/proxy/llm_metrics.go:38) and installed on the streaming proxy
# request path. Verified live on 2026-09-29:
# llm_ttft_seconds count=7 sum=598.212s
# llm_itl_seconds count=16116
# llm_tokens_total 3 series
# #
# They are declared and exported in internal/observability/prometheus.go, but # These derived-from-SSE series cover EVERY model including the two Ollama ones
# the only thing that would ever record samples is LLMMetricsCapture # (ornith:35b, qwen2.5:3b-instruct), which expose no metrics endpoint at all.
# (internal/proxy/llm_metrics.go) — and NewLLMMetricsCapture() has ZERO call # They are proxy-side: TTFT is request-start to first byte and ITL is the gap
# sites. Verified: `grep -rn NewLLMMetricsCapture` returns only the definition, # between SSE writes, so both are granular to SSE write boundaries rather than
# and RecordTTFT/RecordITL/RecordTokens are called from nowhere else. The # to the engine's own token cadence. llm_tokens_total counts `data:` events,
# exporter skips keys with no samples, so these render as HELP-only stubs. # and the documented assumption in internal/proxy/llm_metrics.go is that these
# upstreams emit one such event per token — so it is a token count only as far
# as that assumption holds. vLLM's own histograms remain authoritative.
# #
# The gateway latency panels on the llm-observability dashboard depend on that # Also recorded and working (3 series, one per replica):
# wiring. Until it lands, use vLLM's own histograms as the latency source for
# `reasoning`; the two Ollama models (ornith:35b, qwen2.5:3b-instruct) have no
# latency visibility at all, because Ollama exposes no metrics endpoint either.
#
# These three ARE recorded and do work (3 series, one per replica):
# queue_full_requests_total counter backpressure rejects # queue_full_requests_total counter backpressure rejects
# warmup_requests_delayed_total counter vLLM slot contention # warmup_requests_delayed_total counter vLLM slot contention
# vllm_prefix_cache_hit_tokens_total counter prefix-cache hits # vllm_prefix_cache_hit_tokens_total counter prefix-cache hits
# request_duration_seconds histogram full request #
# NOT working, do not build on it: request_duration_seconds is declared and
# exported but has 0 samples, and its only writer Metrics.RecordRequest has
# zero call sites outside _test.go — verified by ast-grep, not assumed. It is
# the same dead-code shape this branch already corrected for the latency
# series, so it is listed here rather than left to be rediscovered.
# #
# Also note the Deployment already carried prometheus.io/scrape annotations, # Also note the Deployment already carried prometheus.io/scrape annotations,
# but kube-prometheus-stack ignores pod annotations (no additionalScrapeConfigs, # but kube-prometheus-stack ignores pod annotations (no additionalScrapeConfigs,
+59
View File
@@ -0,0 +1,59 @@
# One-shot bootstrap: restart the control-plane apiservers ONCE so they pick up the
# OIDC authenticator (they last started before the Authentik provider existed, so OIDC
# tokens were never accepted -> every login got 401). Delete this whole directory after.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: oidc-bootstrap
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: oidc-bootstrap
namespace: kube-system
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: oidc-bootstrap
namespace: kube-system
subjects:
- kind: ServiceAccount
name: oidc-bootstrap
namespace: kube-system
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: oidc-bootstrap
---
apiVersion: batch/v1
kind: Job
metadata:
name: oidc-bootstrap-restart-apiserver-v2
namespace: kube-system
spec:
backoffLimit: 4
ttlSecondsAfterFinished: 7200
template:
spec:
serviceAccountName: oidc-bootstrap
restartPolicy: Never
containers:
- name: restart
image: docker.io/curlimages/curl:8.22.0
command: ["/bin/sh", "-c"]
args:
- |
set -u
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CA=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
for pod in kube-apiserver-talos-cp-1 kube-apiserver-talos-cp-2 kube-apiserver-talos-cp-3; do
curl -sS --max-time 15 --cacert "$CA" -H "Authorization: Bearer $TOKEN" -X DELETE "https://kubernetes.default.svc/api/v1/namespaces/kube-system/pods/$pod?gracePeriodSeconds=0" -o /dev/null -w "$pod -> HTTP %{http_code}\n" || echo "$pod -> request failed"
done
echo "deletes requested; kubelet recreates each static pod in ~20-40s"
@@ -0,0 +1,141 @@
# One-shot: restart the control-plane API servers so their OIDC authenticator initialises.
#
# Two earlier mechanisms failed and are documented so nobody repeats them:
# 1. deleting the mirror pod does NOT restart a static pod (deletes returned 200, no restart);
# 2. matching /proc/*/cmdline for 'kube-apiserver' also matched this script's own cmdline,
# so every pod SIGTERMed itself (exit 143) before reaching the real process.
# This version matches /proc/<pid>/comm (the process name) and skips \$\$, so self-kill is
# impossible. hostPID + privileged are permitted in the 'cicd' namespace. Delays stagger the
# three control planes by 2 minutes each, so only one API server is down at a time.
---
apiVersion: batch/v1
kind: Job
metadata:
name: oidc-restart-apiserver-cp1-v2
namespace: cicd
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 3600
template:
spec:
hostPID: true
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: talos-cp-1
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
- key: node-role.kubernetes.io/master
operator: Exists
effect: NoSchedule
containers:
- name: kill
image: docker.io/library/busybox:1.36
securityContext:
privileged: true
command: ["/bin/sh", "-c"]
args:
- |
echo "sleeping 10s before restarting the API server on talos-cp-1"
sleep 10
self=$$
killed=0
for d in /proc/[0-9]*; do
pid=${d#/proc/}
[ "$pid" = "$self" ] && continue
comm=$(cat "$d/comm" 2>/dev/null)
[ "$comm" = "kube-apiserver" ] || continue
echo "SIGTERM -> pid $pid"
kill -TERM "$pid" 2>/dev/null && killed=$((killed+1))
done
echo "signalled $killed API server process(es); kubelet recreates the static pod"
---
apiVersion: batch/v1
kind: Job
metadata:
name: oidc-restart-apiserver-cp2-v2
namespace: cicd
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 3600
template:
spec:
hostPID: true
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: talos-cp-2
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
- key: node-role.kubernetes.io/master
operator: Exists
effect: NoSchedule
containers:
- name: kill
image: docker.io/library/busybox:1.36
securityContext:
privileged: true
command: ["/bin/sh", "-c"]
args:
- |
echo "sleeping 120s before restarting the API server on talos-cp-2"
sleep 120
self=$$
killed=0
for d in /proc/[0-9]*; do
pid=${d#/proc/}
[ "$pid" = "$self" ] && continue
comm=$(cat "$d/comm" 2>/dev/null)
[ "$comm" = "kube-apiserver" ] || continue
echo "SIGTERM -> pid $pid"
kill -TERM "$pid" 2>/dev/null && killed=$((killed+1))
done
echo "signalled $killed API server process(es); kubelet recreates the static pod"
---
apiVersion: batch/v1
kind: Job
metadata:
name: oidc-restart-apiserver-cp3-v2
namespace: cicd
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 3600
template:
spec:
hostPID: true
restartPolicy: Never
nodeSelector:
kubernetes.io/hostname: talos-cp-3
tolerations:
- key: node-role.kubernetes.io/control-plane
operator: Exists
effect: NoSchedule
- key: node-role.kubernetes.io/master
operator: Exists
effect: NoSchedule
containers:
- name: kill
image: docker.io/library/busybox:1.36
securityContext:
privileged: true
command: ["/bin/sh", "-c"]
args:
- |
echo "sleeping 240s before restarting the API server on talos-cp-3"
sleep 240
self=$$
killed=0
for d in /proc/[0-9]*; do
pid=${d#/proc/}
[ "$pid" = "$self" ] && continue
comm=$(cat "$d/comm" 2>/dev/null)
[ "$comm" = "kube-apiserver" ] || continue
echo "SIGTERM -> pid $pid"
kill -TERM "$pid" 2>/dev/null && killed=$((killed+1))
done
echo "signalled $killed API server process(es); kubelet recreates the static pod"
+1
View File
@@ -9,3 +9,4 @@ resources:
- poimen-sandbox-namespace.yaml - poimen-sandbox-namespace.yaml
- poimen-sandbox-quota.yaml - poimen-sandbox-quota.yaml
- pi-session-netpol.yaml - pi-session-netpol.yaml
- pi-agent-workspace-pv.yaml
@@ -0,0 +1,57 @@
apiVersion: v1
kind: PersistentVolume
metadata:
finalizers:
- kubernetes.io/pv-protection
labels:
app: pi-coding-agent-sandbox
part-of: poimen-toolkit
name: pi-agent-workspace-pv
annotations:
argocd.argoproj.io/sync-wave: '-1'
spec:
accessModes:
- ReadWriteOnce
capacity:
storage: 10Gi
claimRef:
apiVersion: v1
kind: PersistentVolumeClaim
name: pi-agent-workspace
namespace: poimen-sandbox
hostPath:
path: /var/lib/pi-agent-sandbox
type: DirectoryOrCreate
nodeAffinity:
required:
nodeSelectorTerms:
- matchExpressions:
- key: kubernetes.io/hostname
operator: In
values:
- talos-cp-2
persistentVolumeReclaimPolicy: Retain
volumeMode: Filesystem
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
annotations:
pv.kubernetes.io/bind-completed: 'yes'
argocd.argoproj.io/sync-wave: '-1'
finalizers:
- kubernetes.io/pvc-protection
labels:
app: pi-coding-agent-sandbox
part-of: poimen-toolkit
name: pi-agent-workspace
namespace: poimen-sandbox
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
storageClassName: ''
volumeMode: Filesystem
volumeName: pi-agent-workspace-pv
+1
View File
@@ -39,6 +39,7 @@
rewrite name comfy.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name comfy.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name comfyui.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name comfyui.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name gotify.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name gotify.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name pi.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name igotify.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name igotify.riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
rewrite name riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local rewrite name riotpiao.com ingress-nginx-controller.ingress-nginx.svc.cluster.local
+8 -1
View File
@@ -20,6 +20,7 @@ controlplane_configs = {
"talos-cp-1" = { "talos-cp-1" = {
hostname = "talos-cp-1" hostname = "talos-cp-1"
lan_ip = "192.168.1.166" lan_ip = "192.168.1.166"
public_ip = "https://k8s-api.riotpiao.com"
lan_subnet = "192.168.1.0/24" lan_subnet = "192.168.1.0/24"
lan_gateway = "192.168.1.254" lan_gateway = "192.168.1.254"
install_disk = "/dev/nvme0n1" install_disk = "/dev/nvme0n1"
@@ -32,6 +33,7 @@ controlplane_configs = {
"talos-cp-2" = { "talos-cp-2" = {
hostname = "talos-cp-2" hostname = "talos-cp-2"
lan_ip = "192.168.1.214" lan_ip = "192.168.1.214"
public_ip = "https://k8s-api.riotpiao.com"
lan_subnet = "192.168.1.0/24" lan_subnet = "192.168.1.0/24"
lan_gateway = "192.168.1.254" lan_gateway = "192.168.1.254"
install_disk = "/dev/disk/by-id/wwn-0x644a842029bd3f002720989b07d7143d" install_disk = "/dev/disk/by-id/wwn-0x644a842029bd3f002720989b07d7143d"
@@ -39,7 +41,11 @@ controlplane_configs = {
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b60f4375d97062", mountpoint = "/var/lib/longhorn-disk1" }, { device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b60f4375d97062", mountpoint = "/var/lib/longhorn-disk1" },
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b5ffeb8bb8b47f", mountpoint = "/var/lib/longhorn-disk2" }, { device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b5ffeb8bb8b47f", mountpoint = "/var/lib/longhorn-disk2" },
{ device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b6000c8dabb266", mountpoint = "/var/lib/longhorn-disk3" }, { device = "/dev/disk/by-id/wwn-0x644a842029bd3f0031b6000c8dabb266", mountpoint = "/var/lib/longhorn-disk3" },
{ device = "/dev/disk/by-id/wwn-0x6b083fe0c5782700321370dc23fd765b", mountpoint = "/var/lib/longhorn-disk4" }, # /var/lib/longhorn-disk4 (wwn-0x6b083fe0c5782700321370dc23fd765b) was
# retired 2026-10-12: the 2TB PERC virtual disk failed with continuous
# SCSI I/O errors, hung its XFS filesystem, and wedged Longhorn replica
# rebuilds on talos-cp-2 with unkillable D-state processes. Do not
# re-add it.
] ]
zone = "az-b" zone = "az-b"
allow_scheduling = true allow_scheduling = true
@@ -49,6 +55,7 @@ controlplane_configs = {
"talos-cp-3" = { "talos-cp-3" = {
hostname = "talos-cp-3" hostname = "talos-cp-3"
lan_ip = "192.168.1.162" lan_ip = "192.168.1.162"
public_ip = "https://k8s-api.riotpiao.com"
lan_subnet = "192.168.1.0/24" lan_subnet = "192.168.1.0/24"
lan_gateway = "192.168.1.254" lan_gateway = "192.168.1.254"
install_disk = "/dev/nvme0n1" install_disk = "/dev/nvme0n1"