Compare commits

..
Author SHA1 Message Date
jakman85andClaude Opus 5 8eb5baffa4 feat(sec): constrain the conformance CI trigger at admission (R-SEC-02)
gate / gate (pull_request) Successful in 1m38s
gate / conformance (pull_request) Has been skipped
Implements the credential model the operator chose for wiring L2/L3 into CI: a narrow
trigger SA bounded by a fail-closed Kyverno admission policy, rather than the
zero-credential GitOps-native alternative (plan section 4, primary model).

THE ESCALATION THIS CLOSES. Kubernetes places NO `use` check on a Job's
serviceAccountName. Any principal that can `create jobs` in ns conformance can create a Job
running as conformance-runner -- nodes/patch, pods/exec, namespaces/patch -- with an
arbitrary command. A CI token with plain `create jobs` IS that privileged identity no matter
how narrow its own Role looks. RBAC cannot fix this; only constraining the SHAPE of what the
trigger may create can.

Adds:
  * SA/Role/RoleBinding conformance-trigger -- create+get+list+watch jobs, get pods and
    pods/log, nothing else. Namespaced. No delete (a trigger that can delete can hide a
    failed run), no exec, no secrets. Results come back as a tarball on stdout read via
    pods/log, so no execution channel into the runner pod is ever granted.
  * ClusterPolicy conformance-trigger-job-shape -- Enforce, failurePolicy: Fail. It does NOT
    use ${KYVERNO_VALIDATION_ACTION}: a security boundary an overlay can downgrade to Audit
    is not a boundary. It constrains ONLY conformance-trigger, so a mistake here cannot wedge
    the rest of the cluster.
  * Flux Kustomization `conformance` -> ./conformance/incluster, wait:false and non-gating
    like apps-vms, so a test identity can never stall the platform or app tiers.
  * A kyverno test with seven cases, six of them real escalation paths.

VERIFIED by `kyverno apply` (pass: 10, fail: 6, error: 0), not assumed:
  evil-command    DENIED at /spec/template/spec/containers/0/command/   <- THE attack
  evil-image      DENIED at .../image/
  tag-not-digest  DENIED at .../image/          (mutable tag is republishable)
  host-namespaces DENIED at /spec/template/spec/hostPID/
  extra-volume    DENIED by the volumes foreach
  injected-args   DENIED by the args foreach
  good-run        ADMITTED

ALSO FIXES A FAIL-OPEN IN THE GATE ITSELF. `kyverno test` does NOT fail when a policy cannot
LOAD: every row becomes "Skip / Invalid Policy" and it still prints "N tests passed" and
exits 0. Measured while writing this policy -- a schema error (subjects in a separate
match.all entry from resources) produced exactly that, and ci/l0.sh would have certified a
security policy that does not load as green. This is not specific to this policy: a typo in
restrict-host-namespaces or secret-provenance would silently disable it with the gate still
green. ci/l0.sh now scans for "Invalid Policy" and fails. Proven: broken policy -> l0 rc=1,
restored -> rc=0.

IMAGE PINNING, HONEST LIMIT: the plan specifies pinning the exact runner image digest. That
image does not exist yet, so this enforces the enforceable half -- the image must be under
the conformance repository path AND digest-pinned, never a mutable tag. Residual surface:
anyone who can PUSH to that path could publish a different digest. Tighten to the literal
digest when the image is built; it is a one-line change to the pattern, reviewed in a PR,
which is where a change to a security boundary belongs.

NOT YET WIRED, deliberately: no runner image, no trigger kubeconfig, no CLUSTER_AVAILABLE.
The identities here are inert until those exist, and PR #12 makes CLUSTER_AVAILABLE=true
block rather than fake a green in the meantime.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 03:22:41 -05:00
jakman85 e8e6b9c681 Merge pull request 'fix(gitea): admins could override the gate' (#10) from fix/gitea-admins-could-override-the-gate into main
gate / gate (push) Successful in 1m34s
gate / conformance (push) Has been skipped
Reviewed-on: #10
Reviewed-by: Keith Jackson <[email protected]>
2026-09-29 07:43:52 +00:00
jakman85andClaude Opus 5 fcc66c7858 fix(gitea): admins could override the gate -- the enforcement was not enforcing
gate / gate (pull_request) Successful in 1m34s
gate / conformance (pull_request) Has been skipped
enable_status_check and required_approvals were set earlier today and recorded as done.
They did not bite. block_admin_merge_override was false, and Gitea lets repo admins
override merge requirements without it. jakman85 holds owner, so both requirements were
bypassable by exactly the account that performs every merge.

The evidence was already on the record: PRs #5-#9 all merged while required_approvals: 1
was in force, with ZERO approvals recorded on any of them.

Two process failures, both the same shape as the defect this whole session started with:

1. Verified with the wrong view. /branches/main reports protected, enable_status_check and
   required_approvals but NOT block_admin_merge_override -- it showed everything green while
   the rule was overridable. /branch_protections/main is authoritative.
2. Probed for a field name assumed to exist. This Gitea (1.24.3) has no `apply_to_admins`;
   asking jq for it returns null, which reads like "set to false" but means "key absent", so
   a PATCH that silently did nothing produced a convincing before/after. jq returning null
   is not a value -- it may be an absence. Enumerate with to_entries.

require-gate.sh now sets block_admin_merge_override: true; block-admin-override.sh fixes it
in isolation. Both carry the field-name warning.

This PR is the test. If it merges WITHOUT an approval, the fix did not work.

Also records the governance question: with only jakman85 (human) and gitea-admin (agent),
a human-authored PR now needs the automation account to approve it. That is ceremony, not
review, and the choice between keeping it, dropping approvals in favour of the status
check, or adding a second human is the operator's to make.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 02:36:38 -05:00
jakman85 b9e8a4f9e8 Merge pull request 'docs(ci): close out the hardening; correct a claim that shipped wrong' (#9) from docs/close-out-ci-hardening into main
gate / gate (push) Successful in 1m34s
gate / conformance (push) Has been skipped
Reviewed-on: #9
2026-09-29 07:32:52 +00:00
jakman85andClaude Opus 5 b2d9883958 docs(ci): close out the hardening -- land the scripts, correct a claim that shipped wrong
gate / gate (pull_request) Successful in 2m2s
gate / conformance (pull_request) Has been skipped
The branch-protection + PR-gate + sidecar sequence is complete and verified. This records
the finished state and fixes what the sequence itself proved wrong.

CORRECTS A FALSE CLAIM I SHIPPED. PR #7 said the sidecar conversion would remove the
one-per-start dockerd line "http: TLS handshake error from 127.0.0.1: EOF". It does NOT.
Exactly one still appears, immediately after "API listen on [::]:2376" -- it is now the
startupProbe's FIRST attempt, landing microseconds after dockerd begins listening and cut
off by timeoutSeconds: 1; the next poll succeeds. Cosmetic, and deliberately not chased:
raising the timeout to hide one benign startup line would slow every subsequent poll. The
manifest comment now states this instead of the wrong prediction.

Lands infra/gitea/require-gate.sh and verify-then-enforce.sh, which drove the rollout from
a session temp dir. verify-then-enforce.sh merges nothing -- merging is a human's reviewed
act -- but forces the reconcile and ABORTS unless the pod is healthy, a runner is online,
and a real workflow goes green on it, before enabling enforcement and probing the push.
Re-run it after any future act-runner change.

Trims the now-obsolete feat/ rationale from claim-ship.sh: per-branch push triggers were
deleted, so no prefix produces a duplicate run any more.

CLAUDE.md's enforcement table flips from "weaker than this rule" to machine-enforced, with
all four rows marked verified and the push rejection noted as PROVOKED rather than assumed.
It also now states what a green PR does NOT prove: CLUSTER_AVAILABLE is unset, so L2/L3
never runs in CI and only the L0/L1 offline subset is covered.

infra/gitea/README.md marks the sequence done and names the root cause that remains
unfixed: Gitea repo settings live in its own database, invisible to Flux and unreadable by
the conformance gate, so NOTHING detects drift from what this directory declares. These
scripts assert desired state; they are not a detector. The check that reads the live API
and fails on divergence is the highest-value work left, and it is the difference between
this being fixed and this being unable to recur.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 02:28:55 -05:00
jakman85 00efd4b828 Merge pull request 'fix(ci): convert dind to a native sidecar; delete both wait loops' (#7) from fix/dind-native-sidecar into main
gate / gate (push) Successful in 1m35s
gate / conformance (push) Has been skipped
Reviewed-on: #7
2026-09-29 07:22:50 +00:00
jakman85 67a54a33a4 Merge pull request 'fix(ci): delete the per-branch push triggers instead of adding feat/**' (#6) from fix/gate-push-trigger-matches-feat-prefix into main
gate / conformance (push) Has been cancelled
gate / gate (push) Has been cancelled
Reviewed-on: #6
2026-09-29 07:22:20 +00:00
jakman85 db1a623073 Merge pull request 'docs(runbooks): the PR flow, not git push origin main' (#5) from docs/runbooks-pr-flow into main
gate / conformance (push) Has been cancelled
gate / gate (push) Has been cancelled
Reviewed-on: #5
2026-09-29 07:21:28 +00:00
jakman85 ce271225de Merge pull request 'docs: correct CI/platform documentation that was stale or wrong' (#8) from docs/ci-reality-vs-phase-0 into main
gate / conformance (push) Has been cancelled
gate / gate (push) Has been cancelled
Reviewed-on: #8
2026-09-29 07:20:48 +00:00
jakman85andClaude Opus 5 e72050c690 docs: correct CI/platform documentation that was stale, wrong, or load-bearing
gate / gate (push) Successful in 1m37s
gate / gate (pull_request) Successful in 1m36s
gate / conformance (push) Has been skipped
gate / conformance (pull_request) Has been skipped
The 2026-09-29 session found the merge gate did not exist -- no branch protection, no
runner -- while the tree asserted both. This corrects the documents that carried, and in
several cases CAUSED, that belief.

CLAUDE.md gains section 2b: never infer platform state from this repository. It carries
the specific API and kubectl checks for protection, runners, CI status, workloads and
Flux revisions, and the rule that a green tick is not proof anything ran (a skipped Gitea
job reports success). This is the rule whose absence produced every other finding.

.gitea/workflows/README.md and merge-gate.yaml both said "there is no remote git host,
this file is dormant". Gitea had been live for weeks. Both were written as plans and never
revisited once the plan completed. The Transition section is now marked COMPLETE and says
what is genuinely still outstanding (CLUSTER_AVAILABLE unset, so L2/L3 never runs in CI).

DECISIONS-LOG 2026-08-31 is annotated IN PLACE. Anyone reading chronologically previously
hit "required status check; runner incluster-dind online" with nothing marking it false
until a month later. The correction now sits on the bullet itself.

ci/README.md, clusters/README.md and both conformance READMEs carry a status banner: they
speak as though no cluster exists, which makes a live L2/L3 coverage gap read as an
expected phase. That framing is why the gap went unexamined.

apps/wave-1/gitea-actions-runner-secret.sops.yaml: fixed a wrong path, replaced advice to
`sops -d` into a terminal (which CLAUDE.md forbids) with the rotation script, and recorded
that a fresh token alone does not re-register a runner holding a stale /data/.runner.

apps/wave-0/plex-claim-secret.sops.yaml: the header claimed the file ships the placeholder.
It has not since the claim -- it holds the spent token, which is inert and deliberately
left in place.

catalog/plex.yaml and the plex secret both asserted a "~4-minute TTL" as fact. It was
uncited, upstream documents no expiry, and it was the basis for a since-disproven claim
that the pipeline was too slow to fit inside it. Measured: ~2m45s.

Both .sops.yaml files were verified to still decrypt after the comment edits
(`sops -d > /dev/null`), and render==golden confirms the catalog comment does not
propagate into the rendered manifest.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 02:14:26 -05:00
jakman85andClaude Opus 5 761e25cc3f docs(runbooks): two more uncited "4-minute" claims the first pass missed
gate / gate (push) Successful in 1m34s
gate / gate (pull_request) Successful in 1m35s
gate / conformance (push) Has been skipped
gate / conformance (pull_request) Has been skipped
A systematic grep for the TTL assertion found it still in runbooks/README.md and in
runbooks/kustomization.yaml, outside the page being rewritten. Same correction: the
countdown on plex.tv/claim is the authority, not a number in our tree.

README.md also now records that the server is CLAIMED and points at claim-ship.sh with
the measured ~2m45s loop, rather than describing a push-based sequence that no longer
exists.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 01:57:47 -05:00
jakman85andClaude Opus 5 e7508931ca fix(ci): convert dind to a native sidecar; delete both hand-rolled wait loops
gate / gate (push) Successful in 1m34s
gate / gate (pull_request) Successful in 1m35s
gate / conformance (push) Has been skipped
gate / conformance (pull_request) Has been skipped
Executes the FOLLOW-UP that has been carried in this manifest since the readiness fix,
now that k3s is v1.36 and sidecar containers are GA.

dind moves into initContainers as the first entry with restartPolicy: Always -- the one
field that makes an initContainer a sidecar: it starts before the regular containers,
runs alongside them, and is terminated after them. kubelet therefore enforces the
ordering that the runner used to hand-roll, so the runner drops its command: override
and runs the image's own entrypoint.

The startupProbe execs a full TLS-verified `docker ... info`, NOT a tcpSocket probe. A
tcpSocket probe would re-open the exact gap the old Stage 1 existed to close: dockerd
writes the TLS certs and only THEN starts listening, so the port can accept a connection
while the certs are absent, and act_runner hard-exits on a missing
/certs/client/ca.pem. `docker info` proves both that the certs are usable and that the
daemon answers. periodSeconds 1 x failureThreshold 60 preserves the previous 60s cap.

It also removes the one-per-start dockerd log line
  http: TLS handshake error from 127.0.0.1: EOF
which was the old `nc -z` probe opening a TCP connection and closing it without a TLS
handshake -- an error we were generating ourselves and then reading in the logs.

SECONDARY WIN: deleting the shell deletes an envsubst hazard. apps-wave-1 runs postBuild
substitution over this ENTIRE manifest, comments included, and those loops depended on
bare dollar-names passing through untouched -- a braced or double-dollar spelling would
have silently unbounded the waits or failed the whole Kustomization. The only dollar left
in the file is the intended ${BASE_DOMAIN}.

LANDS ALONE, DELIBERATELY. This Deployment is the merge gate for the entire repository:
if the conversion is wrong, nothing can merge. Merge this BEFORE enabling the required
status check, and watch a real workflow run green on the converted pod before doing so --
otherwise a bad conversion locks the repo with no way to revert through the gate.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 01:54:47 -05:00
jakman85andClaude Opus 5 bf98a99af6 fix(ci): delete the per-branch push triggers instead of adding feat/** to them
gate / gate (pull_request) Successful in 1m34s
gate / conformance (pull_request) Has been skipped
The tracked next-step said "align gate.yml's push trigger with this repo's feat/**
prefix". Doing that literally would have made things worse, so this does the opposite.

act_runner runs at capacity 1. A branch push and its PR fire the SAME gate over the SAME
tree, and the redundant run SERIALISES AHEAD of the PR run -- doubling the wall clock on
the one result that gates the merge. Adding another prefix widens that, it does not fix
it. infra/plex/claim-ship.sh currently has to pick a feat/ branch name specifically to
DODGE this, during a loop that is racing a short-lived token.

The prefix list also silently failed to match this repo's own convention: branches here
are feat/**, which was absent, so the "fast feedback" never fired for the most common
prefix. Nobody noticed for a month because the only symptom of a wrong entry is a MISSING
run. Any hand-maintained prefix list has that failure mode. Deleting the list removes the
whole class rather than correcting one entry.

It was redundant anyway: CLAUDE.md already requires ./ci/gate.sh in the pinned toolbox
before pushing (74s locally), and the PR gate re-runs it authoritatively.

push: [main] is KEPT -- post-merge main must still be verified, and it is the only run for
which no PR context exists.

FOLLOW-UP once this and #5 are both merged: the feat/ rationale in
infra/plex/claim-ship.sh becomes obsolete and its comment should be trimmed. The script
keeps working either way.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 01:50:59 -05:00
jakman85andClaude Opus 5 8e017fad91 docs(runbooks): the PR flow, not git push origin main -- that instruction is now blocked
gate / gate (pull_request) Successful in 1m34s
gate / gate (push) Successful in 1m34s
gate / conformance (pull_request) Has been skipped
gate / conformance (push) Has been skipped
main became a protected branch on 2026-09-29, so the direct push that 37 of 39 runbooks
instructed is REJECTED. The wording was merely stale before; it is now actively wrong,
and it is the line an operator reads mid-incident.

Every occurrence now describes the real path: push a branch, open a PR, merge once the
gate is green -- that merge is the deploy. 35 files shared one identical block;
flux-reconcile.md, README.md and plex-claim.md each said it their own way and were
corrected individually.

plex-claim.md is rewritten around the delivery loop and records that THE SERVER IS NOW
CLAIMED (PlexOnlineToken present in /config), so PLEX_CLAIM is a permanent no-op and the
page only applies after a /config wipe.

Adds infra/plex/claim-ship.sh, which drives branch -> commit -> push -> PR -> gate ->
merge -> reconcile -> pod roll -> verdict with a stopwatch. Three steps are easy to lose
under time pressure and two are SILENT when skipped: --with-source (without it the
Kustomization applies the previously-fetched revision), the mandatory rollout restart
(only the ciphertext changes, so the pod template is byte-identical and nothing restarts
itself), and the feat/ branch prefix (act_runner capacity is 1, and a prefix inside
gate.yml's push trigger fires a redundant run that serialises ahead of the PR run).

Drops the "~4-minute TTL" the page asserted in four places. It was uncited, and upstream
pms-docker documents no expiry at all. The countdown on plex.tv/claim is the authority.
Measured loop: ~2m45s.

DECISIONS-LOG carries the PM handoff: both halves of the 2026-08-31 CI claim were false,
the stale-.runner root cause, the four-deep Flux dependency cascade, and the correction
that a skipped Gitea job reports success rather than hanging.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 01:42:35 -05:00
gitea-admin 27d0ef3324 Merge pull request 'chore(plex): fresh plex.tv claim token' (#4) from feat/plex-claim-token-013124 into main
gate / gate (push) Successful in 1m33s
gate / conformance (push) Has been skipped
2026-09-29 06:33:04 +00:00
jakman85 bbd4f682a6 chore(plex): fresh plex.tv claim token
gate / gate (pull_request) Successful in 1m35s
gate / conformance (pull_request) Has been skipped
Injected inside the plex.tv claim window per runbooks/plex-claim.md.
2026-09-29 01:31:24 -05:00
jakman85 0391139240 Merge pull request 'feat(plex): wire PLEX_CLAIM from a SOPS Secret (placeholder value)' (#1) from feat/plex-claim-sops-secret into main
gate / gate (push) Successful in 1m34s
gate / conformance (push) Has been skipped
Reviewed-on: #1
2026-09-29 06:18:14 +00:00
jakman85 e9f441b5ce Merge pull request 'docs(gitea): a skipped job reports SUCCESS, not pending' (#3) from fix/conformance-context-is-fail-open into main
gate / gate (push) Successful in 1m34s
gate / conformance (push) Has been skipped
Reviewed-on: #3
2026-09-29 06:12:30 +00:00
jakman85andClaude Opus 5 234cc6a71a docs(gitea): a skipped job reports SUCCESS, not pending -- correct the deadlock claim
gate / gate (push) Successful in 1m33s
gate / gate (pull_request) Successful in 1m32s
gate / conformance (push) Has been skipped
gate / conformance (pull_request) Has been skipped
The runner came up on 2026-09-29 and the first CI jobs this repo has ever run went
green, which falsified a claim merged in #2.

MEASURED:
  gate / gate (pull_request)          success   1m58s (PR 1), 1m34s (PR 2)
  gate / gate (push)                  success   1m32s (PR 2 only)
  gate / conformance (pull_request)   success   "Has been skipped"

#2 said listing the conformance context as a required check would deadlock every PR,
because the job is `if: false` and never runs. That is wrong. Gitea reports a skipped
job as SUCCESS. Requiring it would not deadlock anything -- it would pass VACUOUSLY,
forever, while nothing ran.

The conclusion is unchanged (never require it) but the reason inverts, and the corrected
reason matters more: under HARD RULE 4 a permanently-green check that verifies nothing is
worse than a deadlock. A deadlock is loud. This is the exact fail-open shape the
2026-09-18 audit and this one both keep finding.

Also records that the (push) contexts only appear for branches matching gate.yml's push
trigger -- feature/**, fix/**, docs/**, chore/** -- and NOT feat/**, which is why PR 1's
feat/ branch produced only pull_request contexts. Require the pull_request context alone,
or PRs from non-matching prefixes never satisfy the rule.

CLAUDE.md's enforcement table is updated: the runner half is genuinely fixed and observed
green, the branch-protection half is not. Adds the warning that a green tick is not by
itself evidence that anything ran.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 01:07:13 -05:00
jakman85 69a0d1a1c7 Merge pull request 'docs: delivery contract for agents + record that the merge gate does not exist' (#2) from docs/ci-delivery-flow-for-agents into main
gate / gate (push) Successful in 1m34s
gate / conformance (push) Has been skipped
Reviewed-on: #2
2026-09-29 05:50:31 +00:00
jakman85 d3a0d587a4 Added gitea action runner secret
gate / gate (push) Successful in 1m32s
gate / gate (pull_request) Successful in 1m34s
gate / conformance (push) Has been skipped
gate / conformance (pull_request) Has been skipped
2026-09-29 00:50:02 -05:00
jakman85andClaude Opus 5 fe862ab527 infra(gitea): recover the Actions runner before anything requires the gate
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
gate / gate (pull_request) Successful in 1m32s
gate / conformance (pull_request) Has been skipped
No runner is registered at repo, org or site level, so gate.yml has never executed.
PR #1 sat pending for 30.6 minutes across 122 polls and never reported. Requiring the
gate as a status check in that state would lock the repo permanently.

Adds runner-token-rotate.sh: mints a fresh instance-level registration token from the
Gitea API and writes it into the SOPS secret via sops set, without ever printing the
value.

Records the likely root cause, which a new token alone does NOT fix. act_runner
registers once and persists its identity in /data/.runner on the act-runner-data PVC.
If Gitea no longer holds a matching runner row, the pod starts, sees .runner, SKIPS
registration entirely, and polls with an identity Gitea does not recognise. Symptom:
zero runners, pod running, nothing obviously broken. Clearing the PVC is what forces a
genuine re-registration.

The README now sequences runner-first, enforcement-second, and requires CONFIRMING the
runner reports online rather than assuming it. That assumption is the whole defect:
DECISIONS-LOG recorded the runner as online on 2026-08-31 and nobody checked since.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 00:43:47 -05:00
jakman85andClaude Opus 5 a2665c0511 infra(gitea): script the branch protection that was never applied
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
gate / gate (pull_request) Successful in 1m33s
gate / conformance (pull_request) Has been skipped
Gitea repo settings live in its own database, not in a manifest Flux reconciles, so
they are drift-prone and invisible to the conformance gate -- which is how main came
to accept direct pushes while the log recorded it as protected.

Adds infra/gitea/branch-protection.sh (blocks direct pushes to main) and a README
carrying the measured before-state and the ORDER the remaining steps must happen in.

The script deliberately leaves enable_status_check:false and required_approvals:0.
Both would deadlock today: there is no runner to satisfy a required context, and the
only human account is not yet a repo collaborator, so no one could supply an approval.
The README sequences both.

It prints a push probe to run afterwards. The API response is not proof -- Gitea may
let site admins bypass protection, and that has to be observed, not assumed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 00:20:12 -05:00
jakman85andClaude Opus 5 5a9e455149 docs: delivery contract for agents + record that the merge gate does not exist
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
gate / gate (pull_request) Successful in 1m33s
gate / conformance (pull_request) Has been skipped
Going to land the plex claim wiring through the documented PR flow surfaced that
the flow has never run. PR #1 is the first pull request this repo has ever had.

Verified against the live Gitea, not inferred from the docs:
  * branch_protections is [] -- main is protected:false, enable_status_check:false,
    status_check_contexts:[], required_approvals:0, user_can_push:true
  * ZERO Actions runners registered at repo, org and site level

DECISIONS-LOG 2026-08-31 asserts a "required status check" and a runner
"incluster-dind online". Both are false of the live system. The gate is neither
enforced nor able to run, and that went unnoticed because the log claimed
otherwise -- the same fail-open class as the two holes found on 2026-09-18.

Adds CLAUDE.md, the first agent-facing delivery doc here: the
branch -> PR -> gate -> approval -> merge rule, stated alongside the honest fact
that enforcement is currently weaker than the rule, so agents follow the rule
rather than what they can get away with. Carries the toolbox gate invocation, the
two git-worktree workarounds, the rc 0/1/3 contract, and secret handling.

Records the ordering trap: enabling a required status check before a runner exists
deadlocks every PR permanently, including the one that would fix it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 00:11:58 -05:00
jakman85andClaude Opus 5 4fd75ccdaa feat(plex): wire PLEX_CLAIM from a SOPS Secret so the server can be claimed
gate / gate (pull_request) Successful in 1m58s
gate / conformance (pull_request) Has been skipped
Plex cannot create libraries over the already-mounted 8.33T NFS bulk share while
the server is UNCLAIMED. catalog/plex.yaml carried a long argument for leaving
PLEX_CLAIM unwired; the operator has decided to override it. This implements the
wiring in the way that respects the half of that argument which is still true.

WHAT WAS TRUE AND STILL IS: a https://plex.tv/claim token has a ~4-minute TTL,
far shorter than commit -> push -> Flux reconcile -> pod-roll on its own timers.
So the committed value is a documented PLACEHOLDER and the live token is injected
by the operator inside that window, with the reconcile FORCED rather than waited
for. runbooks/plex-claim.md is that timed procedure.

WHAT WAS NOT TRUE: the old comment claimed "a placeholder/absent claim Secret
would crashloop the pod". Half of that is wrong, and the difference is the whole
design. Established from plexinc/pms-docker's own first-run script (branch
`master`, root/etc/cont-init.d/40-plex-first-run, s6-overlay v2), cross-checked
byte-identical against the same file inside the running :latest image:

  * the claim exchange (line 106) is gated on PLEX_CLAIM being non-empty AND the
    PlexOnlineToken pref in /config being empty. Once the server is claimed the
    block is skipped entirely, so the env is a NO-OP on every later start
    whatever it holds ("If server is already logged in, this parameter is
    ignored" -- upstream README).
  * with an invalid/expired token on a still-unclaimed /config, plex.tv answers
    403 with an empty body; curl's status is never checked, the token sed yields
    empty, the success branch at line 120 does not fire, there is no else branch
    and no non-zero exit, and the script (no `set -e`) exits 0. The pod comes up
    UNCLAIMED AND SERVING. It does not crashloop.
  * the image never sets S6_BEHAVIOUR_IF_STAGE2_FAILS (default 0 = continue even
    if a cont-init script failed), so even a non-zero exit would not halt
    startup.

An ABSENT Secret is the real hard failure (secretKeyRef has no `optional`, so it
is CreateContainerConfigError), which is why the Secret is committed, encrypted
and listed in the wave kustomization rather than hand-created at claim time.

NO SCHEMA CHANGE. `optional` on secretKeyRef would only be needed if the pod had
to tolerate a missing Secret or could not start without a VALID token. Neither
holds: the Secret always exists in the Flux graph, and an invalid token is
non-fatal. schemas/servicedefinition.schema.json is untouched, and so are
catalog/render.py's env emission and the renderer contract.

Changes:
  * apps/wave-0/plex-claim-secret.sops.yaml -- new Opaque Secret plex-claim/plex,
    key PLEX_CLAIM, SOPS-encrypted to the committed age recipient. Its header
    carries NO dollar-brace expressions on purpose: kustomize preserves body
    comments into the stream envsubst reads (per
    conformance/tests/l0-static/flux-postbuild-substitution), so quoting the
    upstream shell conditions verbatim would have been a live substitution
    hazard on apps-wave-0, not prose.
  * catalog/plex.yaml -- the PLEX_CLAIM env via valueFrom.secretKeyRef, the
    "deliberately NOT set" block rewritten around the TTL + one-shot design, and
    the stale crashloop claim in the /config storage comment corrected.
  * apps/wave-0/plex.yaml -- re-rendered with the canonical command
    (`--all catalog -o apps --wave-layout --no-image-automation`). No L1 golden
    under conformance/tests/l1-rendering covers plex, so this is the only
    rendered surface that moves.
  * catalog/render.py -- plan_apps_waves places `<svc>-claim-secret.sops.yaml`
    in the app's OWN wave, like the bootstrap secret, plus a selftest assertion.
    Without this, the next `--emit-waves` would have silently dropped the Secret
    from apps/wave-0/kustomization.yaml and put the pod into
    CreateContainerConfigError. apps/wave-0/kustomization.yaml is the hand-pruned
    subset (vm-demo/keycloak are rendered but deliberately unlisted), so its one
    new line was applied without re-emitting the rest.
  * runbooks/plex-claim.md + its kustomization.yaml generator entry + README
    index -- the operator procedure that fits in 4 minutes, including the single
    forced-reconcile command and the mandatory pod roll (a changed Secret does
    NOT restart anything: PLEX_CLAIM is read only at container start and the pod
    template is byte-identical across this change).

Touched requirements: R-SEC-01 (secrets encrypted at rest), R-GITOPS-02
(ServiceDefinition onboarding contract).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-28 22:26:44 -05:00
jakman85 d7f24d0a9e merge: fix/unit-test-coverage-means-asserted-not-mentioned
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
2026-09-19 01:00:59 -05:00
jakman85andClaude Opus 5 712f4c9336 fix(conformance): "unit-tested" counted an alert MENTIONED in a comment as tested
alert-rules-unit-tested decided whether an alert had a unit test with a bare word
match over the whole test file. Any mention counted — including prose in a `#`
comment.

Hit for real while adding platform/monitoring/tests/vpn-egress.test.yaml. Its header
explains that VPNTunnelDown stays quiet during an egress-steering failure, and that
sentence alone made this check declare VPNTunnelDown "asserted on" and demand its
removal from UNTESTED_ALERTS. Removing the entry would have recorded coverage that did
not exist — the precise "looks fixed" outcome this check exists to prevent, produced
by the check itself. (In that instance I made the coverage real instead, which also
exposed that the fixtures were missing tun0's receive counter entirely.)

Now an alert counts only when a test file names it in an assertion position, with
comment lines stripped first so a commented-out assertion does not count either.

BOTH promtool idioms are accepted, because both are real assertions:

    alert_rule_test:    - alertname: CNPGPrimariesCoLocated
    promql_expr_test:   - expr: 'ALERTS{alertname="CNPGPrimariesCoLocated"}'

Matching only the first is not a stricter check, it is a WRONG one: it reported
CNPGPrimariesCoLocated as untested when platform/cnpg/alerts.promtool-test.yaml pins
it across four scenarios entirely through the second form. Believing that would have
produced a redundant duplicate test for an alert already covered better than most —
so the first version of this very fix had to be corrected before it landed.

MEASURED BEFORE LANDING, because the registers are shrink-only and a prose-only alert
would have required an entry this check forbids adding: of 61 alerts, 32 are asserted
in one of the two idioms, 29 are not mentioned at all (already declared), and ZERO were
prose-only. So this tightening failed nothing on the day it landed.

My first measurement said 31/30/0 and was wrong in a way worth recording: it scanned
only tests/*.test.yaml and missed the <manifest>.promtool-test.yaml companion
convention, which is exactly what hid the CNPGPrimariesCoLocated false negative.

Verified in both directions: 134 assertions pass with the real coverage intact, and a
temporary prose-only mention of NodeExporterDown (a registered untested alert) leaves
it correctly listed as untested instead of triggering the stale-entry failure.

Gated: offline l0,l1 --include-scope whole-repo — 24 cases, 0 failures, 0 skips,
0 unwired.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 01:00:59 -05:00
jakman85andClaude Opus 5 e3e56af461 docs(decisions-log): overnight handoff — backup alert crying wolf, 49 unmapped app manifests, egress branch finished
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
Records the three fixes and, in each case, the thing that was reporting green or red
over something other than what it claimed to measure. Includes corrections to my own
numbers (the '192 unmapped files' figure was my audit script's regex matching across a
newline, not the repo) and the two check defects the egress branch exposed.

PM HANDOFF lists open items in priority order. R-STATE-02 remains the Phase 7 exit
item and is blocked on the permission classifier, not on readiness.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 00:52:29 -05:00
jakman85andClaude Opus 5 a3f4ddb971 merge: feat/egress-gateway-staleness-alert — finished so it can actually pass the gate
Brings the two alerts for the silent egress-gateway failure: VPNEgressSteeringStale
(critical) and VPNEgressUplinkLabelMissing (warning), plus the kube-state-metrics
allowlist entry that makes them non-inert.

WHY THE BRANCH SAT IN THE BACKLOG: it could not pass the merge gate. Three
whole-repo checks failed on it, and each one was a real gap rather than a nuisance:

  * alert-runbooks-resolvable — both alerts link to /runbooks/vpn-egress-stale,
    which did not exist. An alert firing now would hand the operator a 404.
  * alert-rules-unit-tested — alerts.yaml is already a covered file, so new rules
    in it must be tested or a "tested" file quietly grows untested rules.
  * alert-metrics-have-scrape-targets — see the guard bug below.

WHAT THE ALERTS CATCH. egress-class=vpn pods leave through one gateway node's tun0;
Cilium resolves it to an ifindex and caches it. An OpenVPN reconnect destroys and
re-creates tun0 with a NEW ifindex, and the node label
homelab.keithjackson.dev/egress-uplink=<addr>-<ifindex> is what forces Cilium to
re-resolve. If the label does not follow the tunnel, the cache points at a destroyed
interface and every vpn-class pod is silently dropped — while tun0 stays up and
VPNTunnelDown says nothing. That ran ~80 minutes undetected on the dev gateway.

Verified this is live on prod, not inert: k3s-prod-3 carries egress-gateway=true and
egress-uplink=10.150.192.86-139, and vpn-egress-steering has been in place 14 days.
The label was exported on 0 series before this change, so the allowlist entry is
what switches the alerts on.

runbooks/vpn-egress-stale.md — written and wired into the configMapGenerator, with
the four required operator sections. It leads with proving egress from a pod the
policy selects, because the symptom is "outbound traffic silently disappears", and
it says plainly not to "fix" this by deleting the policy: that does not restore
egress, it sends vpn-class traffic out of the real ISP connection, which is the leak
the class exists to prevent.

platform/monitoring/tests/vpn-egress.test.yaml — four promtool fixtures. 1 and 2 are
the SAME tunnel reconnect with and without the label following it, which isolates the
mechanism the rule actually keys on: a label VALUE change is not a changing sample,
it retires one series and starts another. 3 is the inert case (gateway node, no uplink
label) where the critical rule CANNOT fire, which is why the warning exists.

Two fixes the branch needed that were defects in the checks themselves:

1. alert-metrics-have-scrape-targets asserted the KSM allowlist matched
   `nodes=[...egress-gateway]` with a literal closing bracket — i.e. that the list
   contained EXACTLY that one label. Adding a second label correctly (egress-uplink,
   which these alerts need) therefore FAILED the check while the property it guards
   was perfectly satisfied. Loosened to `[,\]]`: in the list, not the whole list. A
   guard that breaks when you correctly extend the thing it guards teaches people to
   edit the guard, which is how it stops guarding.

2. UNTESTED_ALERTS listed VPNTunnelDown, and the check flagged it as stale because
   "asserted on" is a bare word match over the test file — my header COMMENT
   mentioning VPNTunnelDown was enough to count as coverage. Rather than delete the
   register entry and claim coverage that did not exist, the fixtures now assert
   VPNTunnelDown QUIET in all four cases. That required making them faithful: they
   had no node_network_receive_bytes_total{device="tun0"} at all, so VPNTunnelDown
   would have been firing throughout unnoticed, because promtool only checks the
   alertnames you name. A healthy tun0 passes traffic, and that silence IS the claim
   these rules exist for.

NOTE FOR LATER, not fixed here: that word-match means any alert named in prose counts
as tested. Tightening it to `alertname:` positions could reveal other prose-only
"coverage", and the register is shrink-only, so the cascade wants doing deliberately
rather than at 01:00 unattended.

Conflicts resolved: kept HEAD's Velero custom-resource-state block and took only the
branch's one-line allowlist change (14 lines down to 1); inserted the branch's
2026-09-12 DECISIONS-LOG entry in date order rather than appending it out of sequence.

Gated: offline l0,l1 --include-scope whole-repo — 24 cases, 0 failures, 0 skips,
0 unwired. The new unit tests pass under the pinned promtool 3.14.0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 00:52:06 -05:00
jakman85 5f3ea63b7f merge: fix/touched-requirements-maps-apps-and-c2 2026-09-19 00:39:21 -05:00
jakman85andClaude Opus 5 23c335257b fix(ci): apps/* and the C2 UI attributed no requirement, so no behaviour test gated them
ci/touched-requirements.sh's coarse path map had no arm for apps/*. A change adding
or editing ANY workload on the cluster — 49 tracked manifests across apps/wave-0,
-1, -2 and apps/vms — attributed zero requirements, so the merge gate selected only
the whole-repo-scoped checks and none of the requirement-scoped L2 behaviour tests.
The tests that enforce default-deny, egress class and auth mode did not run on
exactly the changes that can violate them. Kyverno admission and the L0 egress-path
check are real backstops, but admission is post-merge and the gate exists to catch
it first.

platform/homepage and platform/headlamp were unmapped too — the C2 UI. That gap was
already written down in changed_diff()'s rule-3 comment ("the ONLY route to
R-C2-01/02 was rule 3", i.e. editing the C2 test itself) and had sat there since.

Twelve arms added: apps/vms/* (ordered BEFORE apps/*, because `case` takes the first
match), apps/*, platform/homepage|headlamp, platform/kubevirt, platform/gateway,
platform/coredns, platform/crafty, platform/external-snapshotter|backup, runbooks/*,
platform-config/*, platform/kustomization.yaml and .sops.yaml.

platform/kustomization.yaml earns its own arm: it is the assembly list, so one
deleted line disables an entire platform component while every file it points at
stays perfectly valid on disk. That is the most consequential single-line edit in
the repo and it attributed nothing.

Measured effect for an apps/ change, via --print-selection:

    before:  0 requirement-scoped tests
    after:   13  (3 L1, 8 L2, 2 L3)

Coverage stays 51/51, so the gate's "attributed IDs must be covered" assertion does
not false-block.

Ten new assertions in ci/tests/touched-requirements.test.sh (26 total, all passing),
including the ORDER property — apps/vms/* must win over apps/* — because arm order
is a real failure mode of `case`, not merely a question of both arms existing. Each
fixture cites no R-ID of its own, so any ID in the output came from the PATH and not
the content scan.

A correction on numbers I reported earlier in this session: I claimed "192 unmapped
files", then "182". Both were inflated by my own audit script rather than the repo.
Its regex used `[^)]*`, which matches across a newline, so the first arm parsed as
`case "$1" in\n    catalog/*` and never matched anything; I also mis-parsed
alternation arms like `.gitea/*|ci/*`, which made ci/ look unmapped when it is
mapped. True remainder after this change is 18 files, all prose or captured
artifacts (docs/plans/, root READMEs, .standup-dns/*.txt).

Gated: offline l0,l1,meta --include-scope whole-repo, exit 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 00:39:21 -05:00
jakman85 9339d5a79e merge: fix/velero-skip-emptydir-volumes
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
2026-09-19 00:22:21 -05:00
jakman85andClaude Opus 5 a1cc0a5556 fix(velero): skip emptyDir volumes by type; revert the narrower namespace exclusion
Supersedes 05a8094, which fixed the right bug in the wrong layer.

That commit excluded ns velero's volume data because Velero's own transient kopia
maintenance Jobs (emptyDir `plugins` + `scratch`) were failing their
PodVolumeBackups and turning the nightly Backup PartiallyFailed. It deferred the
general fix — skip emptyDir by type — because Velero's `volumeTypes` policy
condition was unproven here and an unparseable policies.yaml breaks EVERY backup.
It said "revisit if the shape shows up elsewhere."

It showed up elsewhere within the hour. A verification Backup came back
PartiallyFailed with 4 errors, all in ns runbooks: the nginx Deployment's emptyDir
`tmp` + `cache`, failing with

    error to expose PVB: error getting client pod runbooks-...: pods "..." not found

because pushing a runbook edit rolled that Deployment while the backup was
snapshotting it. Cause worth stating plainly: I triggered that rollout myself, so
those four failures were self-inflicted rather than a standing defect — but the
mechanism is not. A PVB exposes the volume through its pod, so ANY rollout landing
in the backup window fails one, and one failed PVB makes the whole Backup
PartiallyFailed. On a GitOps cluster that is routine.

So the property is what matters, not the namespace: an emptyDir is created empty
with its pod and destroyed with it, so backing one up cannot protect anything
while attempting it can break the backup that protects everything else.

The `volumeTypes` risk was retired by testing, not by hoping. The new policy went
into a THROWAWAY ConfigMap first, leaving the live one untouched, and was proven
on prod (velero 1.18.1) with a control:

    control, ns runbooks, NO policy  -> 4 PodVolumeBackups (2x tmp, 2x cache), Completed
    same namespace, WITH the policy  -> 0 PodVolumeBackups, Backup Completed, 0 errors

The control is the point — "zero PVBs" alone is equally consistent with nothing
having been attempted, which is the kind of empty green I have shipped before.

The ns velero exclusion is now reverted as redundant: `plugins` and `scratch` are
emptyDir (verified on a live maintain pod), so the general rule covers them, and
reverting keeps Velero's own resources in schedule (1) where they belong instead
of moving them between schedules for no reason.

Gated: L0 whole-repo 21 cases, 0 failures, 0 skips, 0 unwired.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 00:22:21 -05:00
jakman85 6eaaaae334 merge: fix/node-memory-swap-cnpg-placement
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
2026-09-19 00:01:19 -05:00
jakman85andClaude Opus 5 cde02d19d2 fix(runbooks): node-memory-swap claimed the CNPG primaries sit on node 1
They do not, and the correction changes the operator's plan rather than just a
detail.

Verified live: all three CNPG clusters run 3 instances spread one per node by
required anti-affinity, and k3s-prod-1 holds a REPLICA of each (keycloak-db-2,
nextcloud-db-2, semaphore-db-2). No primary is on node 1, so draining it forces
no failover at all — the best state this maintenance could ask for. The original
sentence was carried over from earlier session state and was simply stale; the
runbook now prints the command to re-derive it, because primaries move on their
own after any restart or failover.

Adds the warning that matters more. CNPGPrimariesCoLocated is currently firing
for k3s-prod-2, which holds both the keycloak-db and semaphore-db primaries. The
instinct is to tidy that before a maintenance window. Doing so would be actively
harmful here: rebalancing means promoting a replica, the only node with spare
primary capacity is node 1, and so "fixing" the alert would put a primary on the
very node about to be powered off — converting a no-failover drain into a
two-database failover. Rebalance after node 1 is back and caught up.

Gated: L0 whole-repo 21 cases, 0 failures, 0 skips, 0 unwired.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-19 00:01:19 -05:00
jakman85 05a8094785 merge: fix/velero-partialfail-on-own-maintenance-jobs
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
2026-09-18 23:55:57 -05:00
jakman85andClaude Opus 5 d99d47494c fix(velero): nightly backup PartiallyFailed on Velero's own maintenance Jobs
VeleroNoRecentBackup has been firing CRITICAL on prod over a backup that was
actually protecting everything.

velero-daily-20260919030041: PartiallyFailed, 3079/3079 items backed up, exactly
2 errors — both PodVolumeBackups for the `plugins` and `scratch` volumes of
homepage-default-kopia-maintain-job-1789786837269-qhsvh. Four such PVB failures
had accumulated.

Velero runs transient kopia repository-maintenance Jobs in its own namespace, one
per BackupRepository, each with emptyDir `plugins` and `scratch` volumes. Schedule
(1) runs defaultVolumesToFsBackup:true — the opt-out model — so it attempts
fs-backup on those volumes too. The Job finishes and its pod disappears
mid-backup, and the PodVolumeBackup fails.

Nothing real was ever missed. But PartiallyFailed does not advance
velero_backup_last_successful_timestamp, so the alert that exists to catch a
genuine backup outage fires every night over Velero cleaning up after itself. An
alert that cries wolf nightly is how a real failure gets ignored — and this one
guards R-STATE-03.

Fix uses the pattern already established for monitoring+unifi: exclude the
namespace's volume DATA from schedule (1), keep its RESOURCES in schedule (2).
Safe by inspection — ns velero holds no PVCs, so there is no volume data to lose,
while the BSL, credentials and Backup CRs that DR actually needs are still
captured.

Deliberately narrower than the general rule. The general fix is a volume policy
skipping emptyDir by type, which would also cover a transient Job in any other
namespace, but it needs Velero's `volumeTypes` policy condition — unproven in this
repo on 1.18.1, and a policies.yaml Velero cannot parse breaks EVERY backup. That
is a much worse failure than the one being fixed, and all four observed failures
were kopia-maintain pods in ns velero. Rationale recorded inline so the next
person can widen it on purpose rather than by accident.

Gated: L0 whole-repo 21 cases, 0 failures, 0 skips, 0 unwired.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 23:55:57 -05:00
jakman85 6f231a4311 merge: docs/decisions-log-gate-fail-open-holes
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
2026-09-18 22:43:36 -05:00
jakman85andClaude Opus 5 623fa14256 docs(decisions-log): PM handoff — two fail-open holes in the gate itself
Records the session's three pieces of work and, more importantly, the theme
joining them: the gate was reporting green over things it had not checked.

- runbooks/node-memory-swap.md, with the three claims I wrote and then removed
  because they were false (EtcdIsVoter does not exist here; node-drain-policy is
  not pinned so the runbook reads it live; the 5-vs-1 rebuild-limit confusion).
- The nested-worktree scan: six discovery sites auditing 18 copies of the repo.
- L1 rendering never running on a requirement-scoped gate, with the external-dns
  incident reproduced via --print-selection (0 tests before, 3 after).
- The two defects that surfaced in the meta-check guarding all of it.

PM HANDOFF lists the open items in priority order. The first is a judgement call
worth flagging rather than burying: the planned memory swap on k3s-prod-1 IS a
supervised single-node loss on prod, which is exactly the event R-STATE-02
describes and which is still proven only on the decommissioned dev cluster.
Capturing evidence during the swap costs nothing; manufacturing the event later
costs a second maintenance window.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 22:43:36 -05:00
jakman85 16cbd4a79e merge: fix/l1-rendering-declares-whole-repo-scope 2026-09-18 22:43:17 -05:00
jakman85andClaude Opus 5 755c08ab40 fix(conformance): the meta-check was layer-agnostic and reported failures silently
Declaring whole-repo scope on the L1 tests made whole-repo-invariants-wired
(R-TEST-03) fail. It was right to change behaviour and wrong about why, and it
could not tell anyone either way. Two independent defects.

1. LAYER-AGNOSTIC WHERE THE DESIGN IS LAYER-BOUNDED

The "every scope-declared test is selected despite a requirement filter matching
none" assertion compared EVERY scope-declaring dir against an `--layer l0`
selection. That silently assumed only L0 tests would ever declare the scope —
while ci/l1.sh was written from the opposite premise, that a whole-repo invariant
could appear at any layer. So the first L1 declaration made the check blame the
union for correctly excluding another layer.

Now partitioned by layer, and the L1 half of the property is asserted too:
every scope-declared L1 test must be selected by `--layer l1 --include-scope`,
and that selection must not leak non-L1 tests. That is what makes the L1
declaration load-bearing rather than decorative — previously nothing proved the
union reached L1 at all, because nothing at L1 declared the scope.

2. EVERY ASSERTION FAILED SILENTLY

The idiom throughout was a bare test on one line and `chk $?` on the next, under
`set -euo pipefail`. A false test aborts the script BEFORE chk can print, so the
diagnostic — which is the entire value of this check — was never reached. The
real failure above emitted 26 `ok` lines, rc=1, and no message at all; it read
as a tooling glitch rather than a finding.

All 12 assertion sites now capture the status (`chk $arc`) so the check reports
and continues. Proven by forcing MIN_DECLARED=999 against the real check: the
failure prints its full explanation and the run proceeds to the remaining
assertions, where before it died mute.

A meta-check that cannot say why it failed is worse than no meta-check.

Verified: 35 assertions, rc=0; full offline l0,l1,meta 25 tests / 0 failures.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 22:43:17 -05:00
jakman85andClaude Opus 5 b389566641 fix(conformance): L1 rendering never ran on a requirement-scoped gate
All three L1 tests now declare `conformance.home.k3s/scope: "whole-repo"`.

ci/l1.sh has passed --include-scope whole-repo since it was written, with a
comment saying no L1 test declared the scope yet and it was wired ahead of time
so the next whole-repo invariant "at any layer" would be picked up by annotation
alone. Nothing ever declared it. The consequence is that the entire L1 rendering
layer only ran when a touched path happened to attribute R-PLAT-02, R-NET-01 or
R-NET-05/06/07 — a fail-open hole for every other change in the repo.

The coupling requirement-scoped selection cannot see is that all three tests
exercise ONE code path: catalog/render.py against
schemas/servicedefinition.schema.json. Anything that changes rendered output
invalidates these goldens no matter which requirement the touched path maps to.

The 2026-09-04 external-dns merge is the proof, measured rather than asserted.
ci/touched-requirements.sh attributes platform/externaldns/helmrelease.yaml to
R-ENV-02, R-OBS-01/02/04, R-STATE-03 and R-TEST-01/02/03/04 — none of the
rendering requirements — yet that change added

    external-dns.alpha.kubernetes.io/controller: none

to the rendered output of all three goldens. Via --print-selection:

  requirement-scoped only (the 2026-09-04 behaviour):  0 L1 tests selected
  with --include-scope whole-repo (how ci/l1.sh runs): 3 L1 tests selected

So the merge gated GREEN while leaving every rendering golden stale, and the
staleness surfaced only on a later full run.

Also corrects the now-false comment in ci/l1.sh that said no L1 test declares the
scope, and records why the annotation is load-bearing there.

Verified: offline l0,l1,meta --include-scope whole-repo in the pinned toolbox —
25 tests, 0 failures, 0 errors, exit 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 22:43:03 -05:00
jakman85 7887ecbbdc merge: docs/node-memory-swap-runbook 2026-09-18 22:15:08 -05:00
jakman85 d717d8e979 merge: fix/conformance-skips-nested-worktrees 2026-09-18 22:15:08 -05:00
jakman85andClaude Opus 5 bc106caac1 docs(runbooks): node-memory-swap — replacing k3s-prod-1's mismatched DIMM pair
The operator has a matched 32 GB pair to install. k3s-prod-1 is the node that
needs it: DIMM1 is a Micron 8ATF2G64HZ-3G2E1 (16 GB, DDR4-3200, 1Rx8) and DIMM3
a 16ATF2G64HZ-2G6E1 (16 GB, DDR4-2667, 2Rx8), so capacity is correct at 32 GB
but the pair runs at the slower 2667. Nothing is faulty. k3s-prod-3 is a matched
Crucial pair.

The procedure exists because this node is the awkward one to take down: it held
24 Longhorn replicas, it is one of three etcd members (so the drain leaves
quorum at 2 of 3 with no further margin), and its rebuilds are the load that
starved etcd once already. So the runbook front-loads the checks that decide
whether to start at all, and treats a BLOCKED drain as the safety property
working rather than an obstacle to force past.

Two things it deliberately does not assert:

- node-drain-policy is not pinned in platform/longhorn/helmrelease.yaml, so the
  runbook has the operator READ the live Setting and set it before draining if
  it is not block-if-contains-last-replica, instead of assuming the default.
- Configured Memory Speed, not Speed, is named as the field that proves the
  swap worked — a matched pair being clocked down still reports the rated Speed.

It also flags the capacity decision as a decision: 2x16 GB keeps the three nodes
symmetric as PROD-PROVISIONING.md §2 assumes, while 2x32 GB makes node 1 the
asymmetric one on memory as well as disk.

Recorded caveats: the node-health artifacts de-duplicate DIMM part numbers, so
they can prove "two different modules" but cannot distinguish one module from
two identical ones — verification therefore collects dmidecode live. And node 2's
inventory is stale and self-contradictory (documented as the only single-DIMM
node at 16 GB, yet reporting ~31.1 GiB), so it needs re-inventory before anyone
claims three-way symmetry. This is NOT the §0b "do not buy RAM" case, which was
an EFI memattr kernel bug on node 2.

Wired into the configMapGenerator so it is served at /runbooks/node-memory-swap
rather than existing only in git.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 22:15:08 -05:00
jakman85andClaude Opus 5 79c703bc79 fix(conformance): whole-repo checks audited nested agent worktrees as source
Six discovery sites walked ROOT without pruning dot-directories, so they
descended into .claude/worktrees/agent-*/ — up to 18 full copies of the repo,
created for isolated agent runs and gitignored (.gitignore:43).

The visible half was false FAILs: four ServiceMonitors reported as selecting no
Service, and nine PrometheusRule manifests reported as "under test but not in
the repo", all of them worktree paths. The whole-repo L0 gate was red for
reasons that had nothing to do with authored surface.

The dangerous half is quieter. A check that asks "does anything in the repo
declare X" can be satisfied by a worktree copy instead of real source, so the
same bug that invents failures can also manufacture a pass. That is the reason
to fix all six sites rather than only the ones that happened to be failing.

egress-paths-selected-by-rules already had this fix and documents it (it once
saw 5 CiliumEgressGatewayPolicy objects instead of the 1 authored); this applies
the same dot-directory predicate everywhere else, which skips dot-DIRECTORIES
while still matching dot-FILES such as .sops.yaml.

platform/monitoring/tests/run.sh is the other side of the same failure: its
`--list` reported each manifest once per worktree, and the L0 check compares
that listing against the tree. Both find calls now prune .claude alongside .git.
`--list` returns 21 real pairs where it previously returned them ~19x over.

Verified in the pinned toolbox: L0 --include-scope whole-repo is 21 cases,
0 failures, 0 skips, 0 unwired (was 4 failures, then 1). L1 3/3.

The 18 stale worktrees are all on already-merged branches and remain on disk;
cleaning them up is a separate change and is no longer load-bearing for the gate.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 22:14:51 -05:00
jakman85 3523617671 Rolled unifi console password
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
2026-09-19 02:49:16 +00:00
jakman85andClaude Opus 5 6a4dc0ada7 docs: INCIDENT — I printed the console password to the transcript
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
A kubectl jsonpath with {range .data}{@}{end} dumped the unifi Secret's data
map, putting the base64 console-password in the session transcript. Exactly the
constraint I put in every agent brief, broken by my own careless verification
command.

Exposure bounded: 0 commits contain it (git log -S), the capturing scratch file
was shredded, and a repo-wide grep finds nothing on disk. Critically it is not
yet a live credential — the Secret was created two days after the running VMI,
whose volume list is rootdisk and cloudinit only, so the UOSDATA disk was never
attached, install-uos never ran, and the ubuntu account is still locked.

Action recorded with the ordering that matters: rotate console-password BEFORE
recreating the VMI, so the leaked value never becomes valid.

Lesson: checking a Secret's existence never needs its values. Listing .data's
keys or .metadata.name answers the question; dumping .data answers one nobody
asked.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 21:39:42 -05:00
jakman85andClaude Opus 5 14d5ccbadd fix(unifi): pin the MAC — an unpinned MAC is why the guest keeps losing its IP
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
Operator set console-password in the SOPS secret (installer-url deliberately
left as REPLACE-ME, which install-uos handles by printing 'not set yet' and
exiting 0; the uosserver guard exits before it is even read).

Root cause of the lost address, from evidence rather than inference. KubeVirt
generates a fresh MAC per VMI when none is pinned. cloud-init writes
/etc/netplan/50-cloud-init.yaml matching the MAC it saw, and will not rewrite
it while the NoCloud instance-id is unchanged — and that instance-id is
domain.firmware.uuid, stable across restarts by design. So every VMI
recreation handed the guest a MAC its retained netplan did not match, leaving
enp1s0 administratively DOWN, never even attempting DHCP.

What the 2026-09-12 boot log shows, and why it fits: cloud-init's own device
table reads 'enp1s0 | Up: False | Address: .'; there are no apt failures
because the per-instance modules were skipped; yet UOS is installed (6.3 GB of
40 GB) and qemu-guest-agent answers — both of which required working network on
an EARLIER VMI. The MAC is identical between the current VMI status and that
boot log, so it did not change on this boot; the mismatch is against a netplan
written under a previous incarnation.

Pinned to the MAC the current VMI already holds, so nothing changes for this
guest. Combined with the instance-id bump already merged, the next restart
re-runs cloud-init, rewrites netplan against a MAC that is now permanent, and
the interface should come up — and the console password is in place as the
fallback if it does not.

Still unverified from outside: the netplan file's actual contents. That needs
the console the operator can now reach.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 21:33:51 -05:00
jakman85andClaude Opus 5 05881d5a6a fix(conformance): regenerate the three L1 goldens the external-dns change drifted
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
b184a42 made the renderer stamp external-dns.alpha.kubernetes.io/controller:
none on internal services' HTTPRoutes, but three L1 golden fixtures were never
regenerated, so container-workload-render (R-PLAT-02) and egress-class-render
(R-NET-05/06/07) failed. Exactly one line differs in each — the annotation and
nothing else.

WORTH RECORDING: the merge that caused this GATED GREEN. The requirement-scoped
selection did not include R-PLAT-02 or R-NET-05/06/07, so neither check ran. A
later docs-only commit shifted the touched set, selected them, and exposed a
failure the earlier merge had introduced — meaning the gate reported PASS on a
tree it had not actually checked in the relevant place.

That is the same fail-open the whole-repo scope mechanism was built for, except
that mechanism covers L0 only; L1 is still purely requirement-scoped. A renderer
change can therefore drift any L1 golden whose requirements the change does not
happen to attribute. Both affected checks are pure offline renders with no
cluster dependency, so they are cheap to run unconditionally — declaring
scope: whole-repo on the l1-rendering tests looks like the right follow-up.

Also note the checks diff fail-fast: only egress-vpn was reported, while
egress-internet had drifted identically and was never reached.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 20:59:19 -05:00
jakman85andClaude Opus 5 cf0d6c6beb docs: verified — internal services are gone from public DNS, split-horizon works
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
Confirmed at the authority, not a cache: all six internal names absent
publicly, all five public names untouched, and Pi-hole answering for the six
internally. The R-DNS-03 disclosure is closed for that tier.

Records a correction to my own sequencing: I insisted this could not land
before the DHCP repoint, but the router returns nothing for those six names, so
the records served nobody and the dependency I asserted did not exist. One
query would have shown it.

Also records the operator's clarifications — Pi-hole is DNS only with the
router still doing DHCP, reverse proxy by hostname rather than a second IP, and
Minecraft staying open — plus the unencrypted-secret fix.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 20:56:29 -05:00
jakman85andClaude Opus 5 20a28d0993 fix(unifi): the installer secret was committed unencrypted
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
Every other *.sops.yaml in this repo carries sops metadata and ENC[] values;
this one shipped with neither and both fields as literal REPLACE-ME. Two
consequences: 'sops <file>' fails with 'sops metadata not found', so the
documented operator step could not work; and Flux would pass the file through
undecrypted and create the Secret with the literal placeholder strings.

Encrypted in place with the repo's age recipient, placeholders intact.
Round-trip verified: sops -d returns installer-url and console-password as
REPLACE-ME, so the operator's workflow is now the normal one — sops the file,
set both values, commit.

Both fields need setting, not just the password: installer-url is also
REPLACE-ME, which I had omitted from the operator instructions.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-18 20:51:16 -05:00
jakman85 b184a42729 merge: internal services stop advertising themselves in public DNS
Six exposure:internal services published A records into the public Cloudflare
zone answering 192.168.2.20, disclosing the internal subnet, the Gateway VIP
and a full service inventory, and forming a DNS-rebind primitive that is why
the SOHO router strips these answers in the first place.

The renderer now stamps external-dns.alpha.kubernetes.io/controller: none on
every HTTPRoute it renders for an internal service, derived from the exposure
field alone — so a service that omits exposure defaults to internal and is
likewise unpublishable. Established against the deployed v0.21.0's own source,
where the controller check runs before hostname resolution in both configured
sources, then proven live on a throwaway hostname including that adding the
annotation to an existing record triggers the DELETE.

Deletes exactly 12 records: the A and TXT pair for jackett, pihole,
qbittorrent, radarr, semaphore and sonarr. A 39-record recovery baseline is
committed alongside.

Safe to land before the DHCP change, contrary to my earlier caution: the
router returns NOTHING for all six names today, so these records serve nobody
on the LAN, while Pi-hole at 192.168.2.23 already answers for anyone pointed
at it.

The five exposure:public services are untouched. They remain unreachable
publicly, which is a separate problem: the legacy edge at 136.51.115.252
returns HTTP 403 for our hostnames and serves a placeholder certificate, so
nothing is reverse-proxying them today.
2026-09-18 20:50:43 -05:00
jakman85andClaude Opus 5 3173254cc4 docs: UniFi OS was never the blocker — it is installed and the guest lost its IP
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
Third confident claim of mine corrected in two days, and this one I took from
a stale README rather than the machine. uosserver.service starts on the
current boot and the root filesystem holds 6.3 of 40 GB, while enp1s0 is up
with no address and does not answer ARP — so the controller runs and nothing
can reach it.

Records the near-miss: proceeding on my premise would have reinstalled over a
working controller, which the new install-uos guard now prevents. Also records
that there is no way into the guest at all, which is why the deliverable became
console access, and the cloud-init instance-id discovery that explains why an
earlier unattended install could never have taken effect.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 02:24:01 -05:00
jakman85 7cb209b9a0 merge: UniFi OS is already installed — the guest lost its IP; deliver console access
The premise I briefed was wrong and the agent checked before building on it.
UniFi OS Server IS installed: the current boot starts uosserver.service and
uosserver-updater.service, and the root filesystem holds 6.3 GB of 40 GB where
bare Ubuntu plus our packages is about 2 GB. Verified independently.

The actual fault is that the guest has no address — .status.interfaces shows
enp1s0 link up with no ipAddress, and ip neigh inside virt-launcher reports
10.0.2.2 FAILED, so the guest does not answer ARP on its masquerade address.
Probes from virt-launcher straight to the pod IP and to 10.0.2.2 RST on
11443/8443/8080/22, which exonerates the VIP, the traffic policy and the
network policy. The cause cannot be determined from outside the guest.

That made one guard non-negotiable: install-uos now bails the moment systemd
knows a uosserver unit. Without it this merge would have reinstalled over a
working controller.

Second finding: there is no way into the guest at all — no SSH key, no
password, and SSH cannot work by construction because masquerade forwards only
declared ports and 22 is not one. So the deliverable is console access: a SOPS
secret carrying installer-url AND console-password, mounted as a NoCloud disk
and re-read every boot.

The discovery that makes it work: NoCloud instance-id is
spec.template.spec.domain.firmware.uuid, stamped on the VM object. Cloud-init
compares it to the DMI product UUID, so editing userData alone changes nothing
on a second boot — the cached datasource is restored and write_files/runcmd are
skipped. That is why a previously shipped unattended install could never take
effect. The UUID is now pinned in git and bumped once.

Deliberately did NOT put the whole cloud-config behind secretRef: that would
hide the shell in ciphertext, out of reach of the L0 dollar-collapse guard,
while Flux's envsubst still runs over the decrypted resource — the nextcloud-db
shape from 2026-09-13. Script in plaintext, operator values in SOPS.

Unattended install is reported honestly as third-party practice, not verified
here, and the README's stale dev-era addressing is corrected throughout since
it doubles as the rebuild runbook.
2026-09-14 02:22:20 -05:00
jakman85andClaude Opus 5 12d4e0ae40 feat(unifi): SOPS-delivered UOS installer URL + per-boot install unit; correct the README
The operator's UOS bring-up becomes one field. apps/vms/unifi/unifi-uos-installer.sops.yaml
(SOPS/age, with a .example sibling per the crafty/headlamp convention) carries `installer-url`
and `console-password`. KubeVirt mounts it as a read-only iso9660 disk labelled UOSDATA and an
in-guest `uos-install.service` reads it on EVERY boot, so a later URL change needs only a commit
and a VM restart.

Mechanism verified against the DEPLOYED KubeVirt v1.9.0, not from memory:
  * inline userData cap is exactly 2048 bytes — server-side dry-run apply accepted 2048 and
    rejected 2049; the payload here is 1454.
  * `cloudInitNoCloud.secretRef` and `secret.volumeLabel` both exist in the live CRD; the secret
    disk + third disk were accepted by a server-side dry-run of the real manifest.
  * the NoCloud seed's instance-id IS spec.template.spec.domain.firmware.uuid (read out of
    noCloud.iso inside virt-launcher), stamped on the VM OBJECT, so it survives restarts. Editing
    userData alone therefore changes NOTHING — cloud-init restores its cached datasource and skips
    write_files/runcmd. The UUID is now pinned in git and bumped once so the new payload lands.

secretRef was deliberately NOT used for the whole cloud-config: it would put the shell in
ciphertext, out of reach of the L0 dollar-collapse guard, while Flux's envsubst still runs over
decrypted resources — the 2026-09-13 nextcloud-db shape. Script in plaintext, operator values in
SOPS. apps-vms had no `decryption` stanza (first encrypted content under apps/vms/), so one was
added to both overlays.

CORRECTION, and it is the headline: UNIFI OS SERVER IS ALREADY INSTALLED on the live guest. The
serial console log starts uosserver.service and uosserver-updater.service, and / is 6.3 GB of
40 GB used. The controller is unreachable because THE GUEST HAS NO IP: .status.interfaces shows
enp1s0 up with no address, and `ip neigh` in virt-launcher reports 10.0.2.2 FAILED. 11443/8443/
8080 and 22 all RST, from the pod IP and from 10.0.2.2 directly, so the VIP and the CNP are
exonerated. install-uos therefore bails out the moment systemd knows a uosserver.service —
without that guard this change would have reinstalled over a working controller.

There was also no way into the guest at all: no key, no password, and 22 is not a declared
masquerade port so SSH cannot work by construction. Hence console-password.

README corrections (it doubles as the rebuild runbook and was misdirecting):
  * VIP 192.168.1.54 -> 192.168.2.21; pool 192.168.1.53-.60/.53 -> 192.168.2.20-.30 with the
    Gateway on .20; device subnet 192.168.1.0/24 -> 192.168.2.0/24; node k3s-dev-1 -> k3s-prod-1.
    All re-read live (pool blocks, cluster-vars, the Service EXTERNAL-IPs, LAN_CIDR).
  * "UOS is NOT installed" -> it is; the real fault is the lost IP, with the evidence table.
  * "apps-vms must NOT use postBuild.substituteFrom" -> it has since 2026-08-31 and the escape
    contract is in force.
  * "install-uos is no longer baked into the guest" -> it was baked in, and it was broken twice
    over (an unverified firmware API, and no `install` subcommand).
  * "SSH in, cloud-init installs the operator's key" -> it installs none, and 22 is not forwarded.
  * externalTrafficPolicy Local -> Cluster (the 8-day outage fix already in 50-service-l4.yaml).
  * 60 GB -> 40Gi (the DataVolume and the Bound PVC); "10 GB minimum" -> ~25 GB.
  * userDataSecretRef -> the YAML key is `secretRef`.
  * 90-httproute-optional.yaml referenced as if it existed; it was dropped.

Unattended install, honestly: Ubiquiti documents no silent flag, no answer file, no env vars.
`yes y | <installer> install` is the convergent community practice and is what ships here, with a
bare-invocation fallback. It has NOT been run end-to-end on our guest. The installer URL also
does not look signed or time-limited — it is account-derived and per-release; it is in SOPS
anyway, but nothing should depend on expiry.

The running VM was not touched: no restart, no migration, no re-import. Every cluster call was a
read or an explicit --dry-run=server; the live VM still carries firmware uuid ...c07 and the
34h-old virt-launcher pod is untouched. The change takes effect on the operator's next VM restart.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 02:19:13 -05:00
jakman85andClaude Opus 5 989781b352 docs: Minecraft server live, EULA accepted on the operator's behalf, world backed up
Records the EULA acceptance prominently with revocation instructions, the
Server List Ping proof, and Crafty's autostart trap where the create API
accepts 'autostart' but stores 'auto_start'.

Also corrects my own claim that the crafty world was unbacked: I grepped
PodVolumeBackup names, which never contain the namespace, and read the empty
result as a finding. Crafty had 2 PVBs in the last run. The real gap was only
that the last run predated the world, so I took a one-off backup —
crafty-world-202609140709, Completed, 328 MiB captured to R2.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 02:09:58 -05:00
jakman85 97257515e8 merge: a Minecraft server exists and 192.168.2.22:25565 is playable
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
Crafty was healthy but had never had a server created — /crafty/servers was
empty, which is why the port refused. Paper 26.2 (Mojang's latest.release,
jar SHA256-verified by Crafty) with a 2 GiB heap sized from measurement: with
the server running the container cgroup reported 2.86Gi against a 4Gi limit,
so a 3 GiB heap would have risked OOMKill.

EULA ACCEPTED ON THE OPERATOR'S BEHALF — https://aka.ms/MinecraftEULA — via
Crafty's agree_to_eula, writing eula=true. A server refuses to start without
it and the operator asked for the service brought up while asleep. No account,
key or payment involved. To revoke: stop the server and set eula=false, or
delete the server.

Proven playable by a real Server List Ping handshake from the LAN, not merely
an open port: MOTD, Paper 26.2, protocol 776, 0/20 players, 12.3 ms RTT.

Caught a trap worth recording: Crafty's create API ACCEPTS and validates
'autostart' but stores 'auto_start', so it silently read back false. Only
reading the record back caught it; trusting the create call would have
reported autostart working while the server stayed dead after any restart.
Fixed by PATCH and then proven — deleting the pod moved Crafty prod-3 ->
prod-1 and it auto-started with the same world, which also independently
re-confirms the externalTrafficPolicy: Cluster fix, since under Local the VIP
would have blackholed exactly there.

Raises crafty's memory request 1Gi -> 3Gi (limit untouched): a permanently
committed 2 GiB heap made the old request understate real usage threefold,
misleading the scheduler and making this Burstable pod an early eviction
candidate.
2026-09-14 02:06:54 -05:00
jakman85andClaude Opus 5 b4bc8e1cb9 feat(crafty): first Minecraft server — Paper 26.2 on 25565; EULA ACCEPTED ON OPERATOR'S BEHALF
ACTION TAKEN ON THE OPERATOR'S BEHALF WHILE THEY SLEPT: I accepted Mojang's
Minecraft End User Licence Agreement, https://aka.ms/MinecraftEULA, by passing
agree_to_eula to Crafty's v2 API, which wrote eula=true into
/crafty/servers/<uuid>/eula.txt. A Minecraft server will not start without it,
and bringing this service up was what was asked for. Please review. To revoke:
stop the server and set eula=false in that file, or delete the server.

Crafty was healthy but had never had a server created, so 192.168.2.22:25565
refused connections. Created "homelab" via the API (not UI automation):

  Paper 26.2, TCP 25565, heap -Xms2000M -Xmx2000M, autostart on
  uuid 13c7b85d-00bb-4a58-8c86-2433ca47891c

Paper over Vanilla for tick performance and plugins, with no cost to players
(vanilla clients connect unchanged). 26.2 is Mojang's latest.release, out
2026-06-16; the 26.3-rc-2 snapshot and 26.2-rc-2 were deliberately avoided.
The 2 GiB heap was measured rather than guessed: with the server running the
cgroup reported memory.current 2.86Gi against the 4Gi limit, so a 3 GiB heap
would have risked an OOMKill.

Proven playable, all real output: Crafty reports running with 'Done (12.737s)!';
Server List Ping succeeds from a pod on k3s-prod-1 against the pod IP, the
ClusterIP and the VIP while crafty ran on k3s-prod-3; and from the LAN the
workstation gets MOTD 'A Minecraft Server', version 'Paper 26.2', protocol 776,
players 0/20 over a full handshake plus ping/pong. Deleting the pod moved Crafty
to another node; it auto-started with no manual action and the LAN ping still
succeeded, re-confirming the externalTrafficPolicy: Cluster fix from adda6dd.

Repo change is one line of substance: the crafty container's memory REQUEST goes
1Gi to 3Gi, because a permanently committed 2 GiB heap makes a 1Gi request
understate real usage roughly threefold and makes this Burstable pod an early
eviction candidate. Limit stays 4Gi. L4 Services, NetworkPolicy and PVC size are
untouched; egress already allowed world, so the jar download needed no new rule.

Trap recorded in both the README and the log: POST /api/v2/servers accepts and
validates autostart/crashdetection but never persists them — the stored columns
are auto_start/crash_detection and need a separate PATCH. Trusting the create
call would have left the server dead after the next restart.

The server is APP STATE, not git: Crafty's SQLite row plus /crafty/servers/<uuid>
including the generated world, both on the Longhorn PVC. A rebuild from git alone
gives an empty Crafty and a refused 25565. The world is irreplaceable and now
makes the open no-backups gap materially worse.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 02:03:20 -05:00
jakman85andClaude Opus 5 83f13f4b0a docs: correct my 'live outage' claim — unifi and crafty were never up
gate / gate (push) Has been cancelled
gate / conformance (push) Has been cancelled
I reported the UniFi controller and Minecraft server as unreachable for up to 8
days. Probing the backend pods directly, bypassing VIP and policy, returns
REFUSED on all four ports while crafty's own UI and pihole's DNS answer as
controls. /crafty/servers is empty and the unifi README still lists installing
UOS in the guest as the open blocker. Nothing regressed.

The externalTrafficPolicy defect is real and is fixed, but it was not the cause.
I observed unreachable ports, found a mechanism that produces unreachable ports,
and concluded it was the cause without running the one control probe that
separates them — the same error shape as nearly blaming the drive for the I/O
pressure spike when the local disks were idle.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 01:40:26 -05:00
jakman85 adda6dd779 merge: L2-announced VIPs must serve from any node, not only the backend's
Cilium's L2 announcer elects a lease holder without regard to which nodes hold
a backend, so externalTrafficPolicy: Local leaves the frontend absent from the
announcing node's service table entirely and the VIP answers ICMP unreachable.
Proven causally with cilium-dbg service list: under Local the frontend is
missing from prod-2; flipped to Cluster it appears as an active backend entry;
flipped back it vanishes. Durability proven too — cordon prod-1, crafty
reschedules to prod-3, and prod-1's UDP lease re-points to the new backend with
no intervention, which under Local is exactly the failure mode.

IMPORTANT CORRECTION to my own framing: this is NOT why unifi and crafty do not
answer, and fixing it does not make them answer. There is no application behind
any of those ports. /crafty/servers is empty — no Minecraft server has ever been
created — and apps/vms/unifi/README.md step 5 still reads 'NEXT: install UOS in
the guest', with the unattended install flagged as the open blocker. Probing the
pods directly, bypassing VIP and policy, returns REFUSED on all four ports while
crafty's own UI and pihole's DNS answer as controls. Nothing regressed; the
8-day-old leases are just 8-day-old Services. I called it a live outage and it
was not.

What this commit does is remove a landmine that would have blackholed both VIPs
the moment those applications exist.

Client-IP cost assessed per service rather than assumed: zero for UniFi, whose
KubeVirt masquerade binding already NATs everything into the guest, so Local was
protecting an address the controller could never observe; real but acceptable for
Crafty, where player identity is a Mojang UUID so bans and whitelists are
unaffected, with /ban-ip documented as do-not-use. loadBalancerSourceRanges
deliberately NOT copied from the Pi-hole pattern: those Services never had a LAN
restriction, and adding one during a fix could cut off off-LAN players.

Cilium DSR mode is recommended separately, not bundled — it carries an Ansible
parity contract and rolls the CNI across all three nodes.
2026-09-14 01:38:50 -05:00
jakman85andClaude Opus 5 4664b41db3 fix(l4): externalTrafficPolicy Cluster on unifi + crafty L4 VIPs
Cilium's L2 announcer elects one node per Service to answer ARP for its
LoadBalancer VIP, and that election is NOT backend-aware. Combined with
externalTrafficPolicy: Local -- which lets a node deliver only to a backend on
itself -- an elected announcer holding no backend answers ARP for the address
and then has nowhere to send the packet, so the client gets ICMP port
unreachable. It never self-heals: the lease holder keeps renewing after the
backend moves away.

Measured on prod 2026-09-14: unifi's VM ran on k3s-prod-1 with both leases on
k3s-prod-2, and crafty's pod ran on k3s-prod-1 with the -tcp lease on
k3s-prod-2 while its identical -udp twin happened to draw k3s-prod-1 and
worked. Confirmed causally on the announcing node with `cilium-dbg service
list`: under Local the frontend 192.168.2.22:25565 is absent from that node's
service table entirely; under Cluster it appears as `1 => 10.0.0.11:25565
(active)`; flipped back, it vanishes again. Durability confirmed by cordoning
k3s-prod-1 and moving the crafty pod to k3s-prod-3, after which the announcing
node re-pointed to the new backend with no intervention.

Both files also carried a comment claiming Local "pins the VIP to the node
running the pod", which is the precise thing that is false here; both are
rewritten. apps/wave-0/pihole-dns.yaml is the reference implementation.

The client-IP cost is stated per service rather than waved away. For unifi it
is ZERO: the VM's KubeVirt `masquerade` binding already SNATs everything into
the guest, so Local was never preserving a client address the controller could
see. For crafty it is real and accepted -- player identity is a Mojang/Xbox
UUID so /ban and the whitelist are unaffected, but /ban-ip would hit every
player at once and must not be used; no IP ban or per-IP rate limit is
configured today.

No loadBalancerSourceRanges here, deliberately departing from pihole-dns.yaml:
these Services never had a source restriction (their CNPs admit `world`), so
Cluster removes no protection, and adding a LAN-only filter during an outage
fix could cut off off-LAN players or a remote site's device informing.

No CiliumNetworkPolicy change is needed, and that was verified rather than
assumed: Cilium's `cluster` entity subsumes `remote-node`, confirmed by
dumping the crafty endpoint's datapath policy map with the CNP unmodified
(`reserved:remote-node` was already an Allow on the game ports). Unlike
pihole, whose policy omits `cluster` and must name remote-node explicitly.

Adds an L0 whole-repo guard for the class. It fails on the pre-fix tree with
exactly these four Services named and passes after. whole-repo scope is
necessary: ci/touched-requirements.sh has no arm for apps/* or
platform/crafty/*, so the change most likely to reintroduce this would
attribute no requirement and be gated by nothing.

HONEST LIMIT -- this does NOT end the reported outage. All four ports also
have no listening backend: UniFi OS was never installed in the VM (its README
flags this as the open blocker) and /crafty/servers is empty, so no Minecraft
server exists. Probed directly from the node, bypassing the VIP entirely,
10.0.0.74:8443/:8080/:11443 and 10.0.0.11:25565 all refuse, while the two
controls answer. This commit fixes a real latent defect that would have
blackholed those VIPs once the applications exist; it cannot make an absent
application answer.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 01:32:47 -05:00
jakman85andClaude Opus 5 4080065aa7 feat(dns): exposure: internal can no longer publish a public record
docs/09 R-DNS-03 and schemas/renderer-contract.md both said an `internal`
service renders no public DNS record. Nothing enforced either sentence.
ExternalDNS's `gateway-httproute` source admitted EVERY HTTPRoute on the shared
Gateway, so six `exposure: internal` services — jackett, pihole, qbittorrent,
radarr, semaphore, sonarr — published `<name>.home.keithjackson.dev` into the
public authoritative Cloudflare zone answering with the Gateway VIP
192.168.2.20. That published the internal subnet, the Gateway VIP and a full
service inventory to anyone who could guess a label, and made each of those
names a DNS-rebind primitive — which is why the SOHO router strips RFC1918
answers and LAN clients need per-client dnsmasq overrides to reach their own
services.

THE MECHANISM, established against the DEPLOYED version rather than from
memory. The live prod Deployment runs
registry.k8s.io/external-dns/external-dns:v0.21.0. In v0.21.0,
source/annotations/processors.go IsControllerMismatch() skips a source object
whose `external-dns.alpha.kubernetes.io/controller` annotation is present and
!= its own `dns-controller`, and source/gateway.go applies that check inside
Endpoints() BEFORE any hostname is resolved; source/service.go applies the
identical check, covering the second configured source. catalog/render.py now
emits that annotation on every HTTPRoute it renders for an `internal` service,
derived from `exposure` alone — no per-service list — so a service that omits
`exposure` inherits the internal default and is unpublishable too. Verified
LIVE on prod on a throwaway hostname with no existing record at risk: the
annotated route published nothing, and adding the annotation to a route that
already had a record produced action=DELETE for both its A record and its `a-`
TXT registry entry one ~60s reconcile later, moving nothing else in the zone.

WHY PER-RESOURCE AND NOT A CLUSTER-WIDE OPT-IN FILTER. `labelFilter` is the
stronger default posture and v0.21.0 supports it for both configured sources,
but nine publishing objects here are hand-authored platform/apps manifests with
no `exposure` field, seven of which hold live records (auth, crafty, crafty-mc,
grafana, headlamp, the apex, unifi). An opt-in filter deletes all seven at
once, and whether each should be publicly resolvable is an operator decision
per name. Per-resource exclusion has exactly the blast radius of the defect;
the seven are enumerated in a shrink-only register so the unadjudicated set
cannot grow silently, and the filter stays available as hardening afterwards.

`policy: sync`, `txtOwnerId` and `domainFilters` are UNTOUCHED. Per-resource
exclusion only ever removes an object from this instance's view, so nothing
here makes prod capable of deleting a record it does not own.

WHAT THIS DELETES, and it is not reversible by a revert alone: 12 records — the
A record and the `a-`-prefixed TXT registry entry for each of jackett, pihole,
qbittorrent, radarr, semaphore and sonarr. The zone has no
`*.home.keithjackson.dev` wildcard and `*.keithjackson.dev` does not match two
labels, so each becomes a public NXDOMAIN with no fallback. The complete
pre-state is captured at the authority and committed as a recovery baseline
(.standup-dns/dns-20260914-pre-externaldns-scope.txt), with the restore
procedure for both halves of a record.

MERGE ORDER IS NOT OPTIONAL: feat/pihole-lan-dns-authority must land and be
verified first. Removing these records while the LAN still depends on them,
with the router stripping RFC1918 and no internal authority yet, breaks name
resolution outright.

The five `exposure: public` services are LEFT UNCHANGED and still answer with
the Gateway VIP, so they remain publicly unreachable. That is two open operator
decisions, both written up in platform/externaldns/helmrelease.yaml: what
address they should answer with (136.51.115.252 is the legacy non-k3s stack
serving a placeholder cert, not established as correct for this cluster), and
how to separate them from the non-public names that share the Gateway —
because in v0.21.0 the target annotation is read only from the GATEWAY and a
ListenerSet, never from an HTTPRoute, so a per-service override is not
expressible while they all attach to `shared`. No knob is added for it yet: one
that cannot take effect invites someone to set it and expect one.

Guard: conformance/tests/l0-static/internal-services-publish-no-public-dns, a
whole-repo-scoped L0 static check (no ci/l0.sh stage — the
whole-repo-invariants-wired meta-test forbids one). It asserts both directions
(present on internal, absent on public), the `service`-source half, that the
renderer DERIVES the annotation from `exposure` and cannot emit external-dns's
own claiming value, proves the shipped renderer end-to-end on
internal/public/omitted-field fixtures, and ratchets the non-catalog register.
Proven to fail on the pre-fix tree (9 of 53 assertions, naming all six
services) and pass after (56/56).

Schema 1.6.0 -> 1.7.0: no field changes, a mapping change to `exposure`.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-14 00:43:12 -05:00
jakman85andClaude Opus 5 16c6234696 feat(monitoring): alert when egress steering goes stale, not just when tun0 dies
`VPNTunnelDown` goes green the instant a tun0 exists, which is not the same as
vpn-class pods having egress: Cilium caches (egress IP, egress ifindex) at first
resolution, so after a reconnect the BPF egress map points at a destroyed
interface and the packets are dropped while tun0 looks perfectly healthy. Dev sat
in exactly that state for ~80 minutes with nothing alerting.

Two rules close it:

* VPNEgressSteeringStale (critical, for: 15m) — fires when the live tun0 was
  re-created more times in the last hour than the gateway node's `egress-uplink`
  label changed, i.e. Cilium was never told about a reconnect.
* VPNEgressUplinkLabelMissing (warning, for: 30m) — the watchdog for the
  watchdog: a gateway node whose uplink label never reaches kube-state-metrics is
  a cluster where the rule above is inert.

Both gated on a gateway node existing, reusing `VPNTunnelDown`'s
`kube_node_labels` guard — a cluster with no gateway cannot be made to fire them.

The direct label-vs-map comparison is NOT available and nothing was invented to
pretend otherwise: no `cilium_*`/`hubble_*` series exist on either cluster (the
cilium-agent exposes no metrics port, there is no ServiceMonitor), and Cilium OSS
publishes no series carrying egress-map contents at all — the map is readable only
via `cilium-dbg bpf egress list`. PromQL also cannot compare a metric value (the
live ifindex) against a number inside a label value, so the level-triggered
equality test is not expressible; the same invariant is counted over a window
instead. Both limits, and the textfile-collector/black-box-probe routes that would
lift them, are written down in the rule comments and the DECISIONS-LOG.

`egress-uplink` added to kube-state-metrics.metricLabelsAllowlist in the same
kustomization, because without it the first rule has nothing to compare.

Verified read-only against LIVE prod (port-forward + /api/v1/query), rendered expr
extracted from `kubectl kustomize platform-config`: empty when healthy; empty when
evaluated at a timestamp before prod had a gateway node; and FIRING when pointed at
a label that genuinely failed to follow prod's real 128 -> 129 tun0 flap earlier
today. Nothing on prod was disturbed to produce that.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-12 20:46:56 -05:00
124 changed files with 6155 additions and 380 deletions

No files matched your search

+28 -14
View File
@@ -40,9 +40,15 @@ and ADR-0011: **CI holds no broad kubeconfig.** L2/L3 run as an in-cluster **Cha
*collects* its artifacts, using a narrow trigger credential referenced **by secret name only**
(`secrets.CONFORMANCE_TRIGGER_KUBECONFIG`) — never a plaintext secret, never a cluster-admin kubeconfig.
## Honest SKIP — never fake green (Phase 0)
## Honest SKIP — never fake green
**No cluster exists in Phase 0.** The gate is deliberately built so unavailable checks **SKIP loudly,
**NOTE (2026-09-29):** this section is written in the past's voice — "Phase 0", "no cluster
exists". **A cluster has existed for weeks** and Flux has been reconciling against it. The
skip logic below is still correct and still honest, but read it as describing a **live
coverage gap** (L2/L3 is not wired into CI) rather than an expected phase. Its being
phrased as a phase is part of why the gap went unexamined.
Originally: The gate is deliberately built so unavailable checks **SKIP loudly,
they do not pass**:
- The `conformance` job is `if: ${{ vars.CLUSTER_AVAILABLE == 'true' }}` — with no cluster it is
@@ -77,21 +83,29 @@ Both dirs are git-ignored (see [`.gitignore`](../../.gitignore)).
## Triggers & branch model (CONTRIBUTING.md)
- `pull_request` → `main`: **PRs are the enforced merge gate into `main`.**
- `push` → `feature/**`, `fix/**`, `docs/**`, `chore/**`: fast feedback on the branch. **No work on `main`.**
- `push` → `main` only. The per-branch prefix list was removed: `act_runner` runs at capacity 1,
so a branch push fires a redundant run that serialises *ahead* of the PR run for the same tree.
**No work on `main`** — and since 2026-09-29 that is enforced, not conventional: `main` is a
protected branch and a direct push is rejected by the pre-receive hook (verified, not assumed).
## Transition: generic-CI-now → Gitea-Actions-after-Gitea-is-up
## Transition: COMPLETE (2026-09-29)
Per [`CONTRIBUTING.md`](../../../home-network-k3s/CONTRIBUTING.md) and
[`docs/05` §"local bootstrap → self-hosted Gitea"](../../../home-network-k3s/docs/05-gitops-and-delivery.md):
This section used to describe a migration as pending. It has happened. Gitea is live at
`gitea.home.keithjackson.dev`, the runner `incluster-dind` is registered and online, and
`gate.yml` runs on every PR.
1. **Now (interim):** there is **no remote git host** on this network. This workflow file is dormant —
but the **identical gate runs locally today** via `make -C ci gate` (R-TEST-02). Development is
local feature-branch → `main`, the gate enforced by convention + the local runner.
2. **Gitea comes online early** (one of the first services onboarded) at `gitea.home.keithjackson.dev`.
3. **CI moves to Gitea Actions:** this workflow activates unchanged. The only operational steps are
enabling a Gitea Actions runner and setting `vars.CLUSTER_AVAILABLE=true` +
`secrets.CONFORMANCE_TRIGGER_KUBECONFIG` once a dev/test cluster exists. PRs then become the
**platform-enforced** merge gate — the same no-work-in-main rule, now enforced by machine.
**What took a month to notice:** the migration was recorded as done on 2026-08-31 without
anyone checking the platform. In reality `main` had **no branch protection** and **zero
runners were registered** until 2026-09-29. Every "the gate enforces this" statement in
between rested on an assertion nobody verified. PR #1 — the first pull request this
repository ever had — sat `pending` for 30.6 minutes because nothing existed to run it.
So: **do not read this file, or the DECISIONS-LOG, as evidence about the live platform.**
Query the API. `infra/gitea/` holds the scripts and the measured state.
Still genuinely outstanding: `vars.CLUSTER_AVAILABLE` and
`secrets.CONFORMANCE_TRIGGER_KUBECONFIG` are unset, so L2/L3 conformance does not run in
CI. That is a real coverage gap, not a phase.
## Configuration (set in Gitea; referenced by name only)
+19 -4
View File
@@ -44,16 +44,31 @@
---
name: gate
# TRIGGERS — deliberately only `pull_request` into main, plus `push` to main itself.
#
# There is NO per-branch push trigger. There used to be a prefix list (feature/** fix/**
# docs/** chore/**) for "fast feedback on the branch", and it was a net negative here:
#
# * `act_runner` runs at capacity 1. A branch push and its PR fire the SAME gate over the
# SAME tree, and the redundant run SERIALISES AHEAD of the PR run — doubling the wall
# clock on the one result that actually gates the merge. That cost real time during the
# 2026-09-29 plex claim, where the loop is racing a short-lived token.
# * The list silently did not match this repo's own convention. Branches here are `feat/**`,
# which was absent, so the "fast feedback" never fired for the most common prefix anyway —
# a mismatch nobody noticed for a month because its only symptom was a MISSING run.
# Any hand-maintained prefix list has that failure mode; deleting it removes the class.
# * It is redundant. CLAUDE.md already requires `./ci/gate.sh` in the pinned toolbox before
# you push (74s locally), and the PR gate re-runs it authoritatively. A third run of the
# same checks buys nothing.
#
# `push: [main]` is KEPT: post-merge main must still be verified, and it is the only run for
# which no PR context exists.
on:
pull_request:
branches: [main]
push:
branches:
- main
- 'feature/**'
- 'fix/**'
- 'docs/**'
- 'chore/**'
concurrency:
group: gate-${{ github.ref }}
+8 -5
View File
@@ -11,11 +11,14 @@
# v
# merge-gate decision [R-TEST-03: touched subset green]
#
# Transition (CONTRIBUTING.md / docs/05 §"local bootstrap -> self-hosted Gitea"):
# Today there is no Gitea host, so this file is dormant. The SAME gate is runnable
# now via `make -C ci gate` (R-TEST-02). Once Gitea is self-hosted and Gitea
# Actions is enabled, this workflow becomes the ENFORCED PR merge gate into main —
# no code change required, only turning on the runner and setting CLUSTER_AVAILABLE.
# STATUS (corrected 2026-09-29): Gitea IS hosted and Gitea Actions IS enabled; the runner
# `incluster-dind` is online and gate.yml is the live PR gate. The text that used to sit here
# ("Today there is no Gitea host, so this file is dormant") was stale by weeks -- it was written
# as a plan and never revisited once the plan completed.
#
# THIS file is still dormant, but for the reason in the DORMANT block below -- its spec checkout
# and L2/L3 seams are unwired -- NOT because there is no host. Do not read a stale plan as a
# statement about the live platform; query the API instead (see infra/gitea/).
#
# CI holds NO broad kubeconfig. L2/L3 run as an in-cluster Chainsaw Job whose own
# scoped ServiceAccount holds cluster access (docs/14, R-SEC-02); CI uses only a
@@ -0,0 +1,97 @@
# RECOVERY BASELINE — the COMPLETE Cloudflare record set for zone `keithjackson.dev`
# as it stood IMMEDIATELY BEFORE feat/externaldns-scope-public-only is applied.
#
# WHY THIS FILE EXISTS. The branch that adds it changes which resources ExternalDNS considers,
# and ExternalDNS runs `policy: sync` — a record whose source stops matching is DELETED, not
# merely left alone. That is a one-way, outward-facing operation against a live authoritative
# zone, so the pre-state is captured and committed BEFORE the change lands, and any record the
# reconcile removes can be restored by hand from this file. It is the same discipline as
# dns-before.txt / dns-verify.txt / dns-post-l2.txt beside it, applied to a destructive-capable
# change rather than to a migration.
#
# CAPTURED 2026-09-14 from the Cloudflare API (GET /zones/<id>/dns_records, all pages) using the
# SOPS-encrypted token in platform/externaldns/cloudflare-api-token.sops.yaml. Read at the
# AUTHORITY, not through a resolver: 1.1.1.1 served stale answers for minutes after the
# 2026-09-13 apex flip and must not be trusted for this. `dig` is on the host but NOT in the
# devcontainer, which has previously produced false "no records" readings — the API is the only
# reading that is both complete and authoritative.
#
# FORMAT (5 fields, `|`-separated) — wider than the sibling files on purpose: a restore needs the
# TTL and the proxy flag, not just the value.
#
# <type>|<name>|<content>|<ttl>|<proxied>
#
# ttl 1 = Cloudflare "Automatic". proxied false = DNS-only (grey cloud), which every record here
# is; a restore that flips one to proxied would put Cloudflare's edge in front of a LAN address.
#
# 39 records. 36 of them are ExternalDNS-managed (18 A + their 18 `a-`-prefixed TXT registry
# entries), every one registered `external-dns/owner=prod` — i.e. THIS cluster owns them, which
# is why this cluster's reconcile is able to delete them. The remaining 3 belong to the legacy
# non-k3s stack and carry NO TXT registry entry, so no owner id claims them and ExternalDNS
# cannot touch them regardless of what this branch does:
#
# *.keithjackson.dev -> 136.51.115.252 legacy stack (the zone wildcard)
# brickbreak.keithjackson.dev -> 20.236.242.14 unrelated, off-prem
# dev.brickbreak… -> 20.236.242.14 unrelated, off-prem
#
# NOTE there is NO `*.home.keithjackson.dev` wildcard in the zone any more (the comment in
# clusters/prod/kustomization.yaml still says the zone has both wildcards; it does not, as of
# this capture). A single-label wildcard does not match two labels, so `*.keithjackson.dev` does
# NOT cover `<svc>.home.keithjackson.dev`: when a specific `<svc>.home` record is removed, that
# name goes to NXDOMAIN publicly rather than falling back to the legacy IP. That is the intended
# end state for `internal` services and it is also precisely why Pi-hole must already be
# answering the LAN authoritatively before this branch merges.
#
# WHICH OF THESE THIS BRANCH DELETES — the 12 rows for the six `exposure: internal` catalog
# services (jackett, pihole, qbittorrent, radarr, semaphore, sonarr): each one's A record and its
# `a-`-prefixed TXT registry entry. Nothing else. Verified live on prod against the deployed
# v0.21.0 with a throwaway hostname before any of this was authored: adding the annotation to an
# HTTPRoute that already had a published record produced
# `action=DELETE record=... type=A` and `action=DELETE record=a-... type=TXT` one reconcile
# interval (~60s) later, and nothing else in the zone moved.
#
# TO RESTORE ONE ROW BY HAND (both halves, or ExternalDNS will not recognise the record as its
# own): recreate the A record with its value/ttl/proxied from this file, then recreate the
# matching `a-<name>` TXT with its content VERBATIM — the `heritage=…,external-dns/owner=prod,
# external-dns/resource=…` string is what re-establishes ownership. An A record restored without
# its TXT is unowned and will not be reconciled; a TXT restored with a wrong `resource=` value
# points the registry at a source object that does not exist.
A|*.keithjackson.dev|136.51.115.252|1|false
A|auth.home.keithjackson.dev|192.168.2.20|1|false
A|brickbreak.keithjackson.dev|20.236.242.14|1|false
A|crafty-mc.home.keithjackson.dev|192.168.2.22|1|false
A|crafty.home.keithjackson.dev|192.168.2.20|1|false
A|dev.brickbreak.keithjackson.dev|20.236.242.14|1|false
A|gitea.home.keithjackson.dev|192.168.2.20|1|false
A|grafana.home.keithjackson.dev|192.168.2.20|1|false
A|headlamp.home.keithjackson.dev|192.168.2.20|1|false
A|home.keithjackson.dev|192.168.2.20|1|false
A|jackett.home.keithjackson.dev|192.168.2.20|1|false
A|keycloak.home.keithjackson.dev|192.168.2.20|1|false
A|nextcloud.home.keithjackson.dev|192.168.2.20|1|false
A|pihole.home.keithjackson.dev|192.168.2.20|1|false
A|plex.home.keithjackson.dev|192.168.2.20|1|false
A|qbittorrent.home.keithjackson.dev|192.168.2.20|1|false
A|radarr.home.keithjackson.dev|192.168.2.20|1|false
A|seerr.home.keithjackson.dev|192.168.2.20|1|false
A|semaphore.home.keithjackson.dev|192.168.2.20|1|false
A|sonarr.home.keithjackson.dev|192.168.2.20|1|false
A|unifi.home.keithjackson.dev|192.168.2.21|1|false
TXT|a-auth.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/oauth2-proxy/oauth2-proxy"|1|false
TXT|a-crafty-mc.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=service/crafty/crafty-l4-tcp"|1|false
TXT|a-crafty.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/crafty/crafty"|1|false
TXT|a-gitea.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/gitea/gitea-3000"|1|false
TXT|a-grafana.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/monitoring/grafana"|1|false
TXT|a-headlamp.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/headlamp/headlamp"|1|false
TXT|a-home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/homepage/homepage"|1|false
TXT|a-jackett.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/jackett/jackett-9117"|1|false
TXT|a-keycloak.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/keycloak/keycloak"|1|false
TXT|a-nextcloud.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/nextcloud/nextcloud-80"|1|false
TXT|a-pihole.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/pihole/pihole-80"|1|false
TXT|a-plex.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/plex/plex-32400"|1|false
TXT|a-qbittorrent.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/qbittorrent/qbittorrent-8080"|1|false
TXT|a-radarr.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/radarr/radarr-7878"|1|false
TXT|a-seerr.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/seerr/seerr-5055"|1|false
TXT|a-semaphore.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/semaphore/semaphore-3000"|1|false
TXT|a-sonarr.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=httproute/sonarr/sonarr-8989"|1|false
TXT|a-unifi.home.keithjackson.dev|"heritage=external-dns,external-dns/owner=prod,external-dns/resource=service/unifi/unifi-l4-tcp"|1|false
+147
View File
@@ -0,0 +1,147 @@
# CLAUDE.md — how work lands in this repo
Read this before doing anything durable. It is the delivery contract for AI coding
sessions and agents. `DECISIONS-LOG.md`'s last entry is the current PM handoff and
takes precedence on *what* to work on; this file governs *how* it lands.
## 1. Never push to `main`. Ever.
Every durable change reaches `main` exactly one way:
branch -> push the branch -> open a PR -> CI gate green -> human approval -> merge
There is no other path. Specifically:
- **Do not `git push origin main`.**
- **Do not `git push origin HEAD:main`.** Same thing wearing a hat.
- **Do not merge your own PR.** A human approves and clicks merge.
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
> **Many runbooks under `runbooks/` still say `git push origin main` IS the deploy.**
> That wording is **stale** — it predates the PR flow. It describes what Flux does
> (main is what the cluster follows), not what you are permitted to do. Honour this
> file, not that line. Fixing those runbooks is tracked work.
## 2. Enforcement (as of 2026-09-29: the rule is now MACHINE-ENFORCED)
Verified against the live Gitea on 2026-09-29:
| Expected | Actual |
|---|---|
| `main` protected, direct push rejected | ✅ `protected: true`, `user_can_push: false` — **verified by a real rejected push**, not by the API's word |
| PR blocked until the gate passes | ✅ `enable_status_check: true`, context `gate / gate (pull_request)` |
| Gate runs on every PR | ✅ runner `incluster-dind` online; ~1m35s |
| Approval required before merge | ✅ `required_approvals: 1` |
All four were FALSE as recently as 2026-09-29, while this repo's own log asserted they were
true. They are true now because each was measured, and the push rejection was provoked rather
than assumed. **Re-measure before relying on any of them** — see §2b.
Still not covered: `vars.CLUSTER_AVAILABLE` is unset, so **L2/L3 conformance does not run in
CI**. A green PR therefore proves the L0/L1 offline subset, not runtime behaviour.
**Consequence for you:** CI now runs, but it does not yet *block* anything. Always run
the toolbox gate yourself and quote its numbers. Never describe a PR as "green" on the
strength of a check you have not seen report — and note that a **skipped** Gitea job
reports as `success`, so a green tick is not by itself evidence that anything ran.
## 2b. Never infer platform state from this repository
**This repo is not evidence about the live platform.** Neither is `DECISIONS-LOG.md`.
Both describe what someone believed at the time of writing, and both have been
confidently wrong for a month at a stretch.
On 2026-09-29 the log stated the merge gate was a required status check with a runner
online. The API said `branch_protections: []` and **zero** runners. Everything downstream
of that belief — "the gate enforces this", "your push would be rejected", "CI covers it" —
was wrong, and nothing in the tree could have revealed it, because the tree was the source
of the error.
**So, before asserting that any gate, protection, runner, secret, or workload is in a
given state: query the thing itself.**
| Claim about... | Check |
|---|---|
| branch protection / required checks | `GET /api/v1/repos/{owner}/{repo}/branches/main` |
| a runner exists | `GET /api/v1/admin/actions/runners` |
| CI actually ran | the commit's `/statuses` — and read the **description**; a *skipped* Gitea job reports `success` with "Has been skipped" |
| a workload / Secret / env | `kubectl --context k3s-prod ...` (read-only is fine for agents) |
| Flux applied your commit | compare `.status.lastAppliedRevision` against the GitRepository's artifact revision |
Quote the measured field, not the prose. If you cannot measure it, say so explicitly —
"unverified" is a legitimate report, and a far better one than a confident guess that
matches the docs.
**A green tick is not proof anything ran.** Read what actually executed.
## 3. Gate before you push
./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'
Exit codes are a contract — do not flatten them:
| rc | Meaning |
|---|---|
| 0 | PASS |
| 1 | BLOCKED — something failed |
| 3 | UNDETERMINED — a stage did not run. **Never report this as green.** |
`spec-immutability` reaches rc 3 whenever the sibling spec repo is not checked out
beside this one; that single skip is expected and tolerated. Any *other* skip is a
real gap in your run.
**Never gate on the host.** `promtool` and `kustomize` are not on the host `$PATH`,
so checks SKIP and you get a false rc 3. Only the pinned toolbox result counts.
### Running the gate from a git worktree
Two unrelated failures, both with known workarounds — put both in any agent brief:
1. **`dev.sh` refuses to start:** `infra/ansible/.cache/prod.kubeconfig` is untracked
and exists only in the main checkout. Do **not** copy it (it is a credential).
Use `DEV_KUBECONFIG=/dev/null` — the gate contacts no cluster, so L2/L3 scaffold-skip
normally.
2. **Every `git` call inside the container fails:** a worktree's `.git` is a pointer to a
gitdir outside the mounted root. `dev.sh` takes no extra-mount flag, so inject one via
the `ENGINE` hook — a shim that intercepts `run` and re-execs
`podman run -v /path/to/main/.git:/path/to/main/.git:ro "$@"`. A traceback in
`tracked_files()` is this bug, not a broken tree.
## 4. Branch naming
Use `feat/`, `fix/`, `docs/`, `chore/`. Note that `.gitea/workflows/gate.yml`'s **push**
trigger lists `feature/**` — not `feat/**` — so branch pushes get no fast-feedback run.
The `pull_request` trigger fires regardless of branch name, so the PR gate is unaffected.
## 5. Cluster access
`kubectl` does not work on the operator workstation; the toolchain lives only in the
pinned container. Always name the context explicitly:
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes'
`dev.sh` merges both clusters and picks a current-context **per invocation**; that
default has already changed once. Never rely on it. Do not run two `dev.sh`
invocations concurrently.
**Mutating `kubectl` and `flux reconcile` are operator actions, not agent actions.**
## 6. Secrets
- **Never `sops -d` a file into a terminal, a log, or a transcript.**
- **Never print a Secret's `.data`/`.stringData`** when checking whether it exists —
list key names or object names only. A password has been leaked into a transcript
this way before.
- Encrypted files are `*.sops.yaml` or live under `secrets/`. The root `.sops.yaml` is
the single creation-rules file; do not add a second one in a subtree.
- A short-lived credential (e.g. a plex.tv claim token, ~minutes) cannot survive the PR
flow. Do not design around committing one — read the expiry from the issuing page and
treat a burnt token as free to retry.
## 7. Before you finish
Append a handoff entry to `DECISIONS-LOG.md` describing what you changed, what you
verified, and what you did **not** verify. State gaps plainly — an honest
"UNVERIFIED" is worth more than an optimistic "green", and this repo has already been
burned by the difference.
+1044
View File
File diff suppressed because it is too large. Load diff
+147 -26
View File
@@ -41,10 +41,41 @@ spec:
# disk KubeVirt cannot migrate and eviction would hard-stop the guest.
evictionStrategy: LiveMigrate
domain:
# =====================================================================================
# FIRMWARE UUID == THE CLOUD-INIT INSTANCE-ID. THIS IS THE RE-RUN LEVER. READ BEFORE EDIT.
# =====================================================================================
# MEASURED on prod 2026-09-14, not assumed:
# * the NoCloud seed KubeVirt builds for this VM carries
# {"instance-id":"de5c3338-6d32-4956-89d1-12c8272b2c07","local-hostname":"unifi"}
# (read out of noCloud.iso inside virt-launcher-unifi-gp594), and that value was
# EXACTLY `spec.template.spec.domain.firmware.uuid`, which KubeVirt's mutating webhook
# had stamped onto the VM OBJECT — so it is PERSISTENT, not per-VMI (the VMI's own uid
# was b532fd7c-…, a different value). It is not a hash of the VM name either: the four
# standard uuid5 namespaces over "unifi" / "unifi.unifi" all produce something else.
# * cloud-init's NoCloud datasource decides "is this the same instance?" by comparing the
# cached instance-id against the guest's DMI product_uuid — which IS this firmware UUID.
#
# CONSEQUENCE, and it is the whole reason the previous unattended attempt could never have
# worked: EDITING `userData` BELOW AND RESTARTING THE VM CHANGES NOTHING. cloud-init sees an
# unchanged instance-id, restores its CACHED datasource, never re-reads the seed, and skips
# every per-instance module (write_files, runcmd). The new payload is silently ignored.
#
# So the UUID is pinned HERE, in git, and BUMPING IT IS THE SUPPORTED WAY to make the guest
# process a new cloud-init payload: change the trailing hex, commit, restart the VM. Pinning
# it also makes a rebuild reproducible — a fresh apply would otherwise take a random
# webhook-generated UUID and give the guest a different identity every time.
#
# The value below is DELIBERATELY NOT the de5c3338-…c07 the live guest booted with: that
# difference is what makes the next boot a NEW cloud-init instance and installs the
# `install-uos` helper + its systemd unit. `serial` is pinned to the value the webhook
# already stamped, so this change does not also churn the guest's DMI serial.
firmware:
uuid: "de5c3338-6d32-4956-89d1-12c8272b2c11"
serial: "24bb12ac-d311-45f6-a75b-90a9e1b63e18"
# FLAG (verify-at-bringup): Ubuntu 24.04 amd64 cloud images are hybrid BIOS+UEFI bootable,
# so KubeVirt's default SeaBIOS SHOULD boot cleanly (unlike the Fedora containerdisk, which
# is UEFI-only and needed an explicit efi block). If the guest drops to emergency/no-boot,
# add: firmware: { bootloader: { efi: { secureBoot: false } } }
# is UEFI-only and needed an explicit efi block). RESOLVED at bring-up: boots as shipped;
# the guest reports Ubuntu 24.04.4 LTS.
cpu:
cores: 2
memory:
@@ -58,9 +89,28 @@ spec:
- name: cloudinit
disk:
bus: virtio
# The operator's installer URL (+ optional SSH key), delivered as a tiny read-only
# iso9660 disk — see the `uosdata` volume near the bottom of this file.
- name: uosdata
disk:
bus: virtio
interfaces:
# MAC PINNED 2026-09-18, and this is a root-cause fix, not cosmetics.
# KubeVirt generates a fresh MAC for every VMI when none is pinned. cloud-init writes
# /etc/netplan/50-cloud-init.yaml with a `match` on the MAC it saw, and it will NOT rewrite
# that file while the NoCloud instance-id is unchanged — and the instance-id here is
# domain.firmware.uuid, which is stable across restarts by design. So each VMI recreation
# handed the guest a NEW MAC that the retained netplan did not match, leaving enp1s0
# administratively DOWN with no address and no DHCP attempt.
# That is exactly what the evidence shows for the boot of 2026-09-12: cloud-init's own
# device table reports `enp1s0 | Up: False | Address: .`, no apt failures are logged
# (because the per-instance modules were skipped), yet UOS is installed and
# qemu-guest-agent runs — both of which required a working network on an EARLIER VMI.
# Pinning the MAC to the value the current VMI already holds makes the guest's netplan
# match permanently, so this cannot recur on the next recreation.
- name: default
masquerade: {}
macAddress: a6:d7:c0:b3:72:fa
# Declaring ports on a masquerade iface (a) restricts pod->VM forwarding to exactly
# these and (b) surfaces them as containerPorts on the virt-launcher pod so the
# LoadBalancer Services + CiliumNetworkPolicy ingress can target/match them.
@@ -83,41 +133,112 @@ spec:
- name: rootdisk
dataVolume:
name: unifi-rootdisk
# =====================================================================================
# THE OPERATOR'S ONE FIELD — SOPS Secret `unifi-uos-installer`, delivered as a DISK.
# =====================================================================================
# KubeVirt exposes a Secret as a small read-only iso9660 disk whose volume label we choose;
# each Secret key becomes a file on it. The guest mounts it by label (LABEL=UOSDATA) and
# reads `installer-url` plus an optional `console-password`. The Secret ships ENCRYPTED with
# REPLACE-ME placeholders, and the guest-side script treats anything that is not an https
# URL as "not provided yet" and exits 0 — so the VM boots and stays healthy until the
# operator fills it in. See unifi-uos-installer.sops.yaml.example next to this file.
#
# `console-password` exists because THERE IS CURRENTLY NO WAY INTO THIS GUEST. The stock
# Ubuntu cloud image ships a locked `ubuntu` account, this cloud-init never installed a key,
# and SSH could not help anyway: the masquerade interface above forwards ONLY the declared
# ports, and 22 is not one of them — probed from inside virt-launcher on 2026-09-14, the
# guest answers RST on 22 exactly as it does on the UniFi ports. The serial console is the
# only door, and a console needs a password. Set it and `virtctl -n unifi console unifi`
# works; `kubectl -n unifi logs virt-launcher-unifi-… -c guest-console-log` already gives
# read-only console output with no virtctl at all.
#
# WHY A SEPARATE DISK AND NOT `cloudInitNoCloud.secretRef` (which would also lift the
# 2048-byte inline cap): secretRef moves the ENTIRE cloud-config into ciphertext. Two
# concrete costs. (a) The shell below stops being reviewable in a diff and falls outside the
# L0 dollar-collapse guard, which can only read plaintext. (b) Flux runs its postBuild
# envsubst over the DECRYPTED resources, so the escape contract in
# clusters/base/apps-vms.yaml would still govern text nobody can see in git — the exact
# shape of the 2026-09-13 nextcloud-db incident. Keeping the script in plaintext and putting
# ONLY the operator's two values behind SOPS keeps both properties.
#
# A CHANGED SECRET REACHES THE GUEST ON THE NEXT VMI START with NO firmware-UUID bump: the
# disk is rebuilt from the Secret every time the VMI starts, and the systemd unit below
# re-reads it on EVERY boot instead of relying on cloud-init (which will not re-run — see
# the firmware block above). The UUID bump is a ONE-TIME step, to get that unit installed.
- name: uosdata
secret:
secretName: unifi-uos-installer
volumeLabel: UOSDATA
- name: cloudinit
cloudInitNoCloud:
# INLINE (kept < KubeVirt's 2048-byte userData limit — so NO SSH key here; add one back via
# userDataSecretRef only if needed). qemu-guest-agent gives the KubeVirt AgentConnected
# signal (proves cloud-init ran); install-uos resolves the latest UOS linux-x64 build from
# Ubiquiti's PUBLIC firmware API (no auth) and installs it non-interactively (UI on :11443).
# NOTE: apps-vms NOW carries postBuild.substituteFrom cluster-vars (needed for
# ${UNIFI_L4_VIP} in 50-service-l4.yaml), so Flux envsubst DOES run over this userData.
# Every shell brace-expansion below is therefore escaped as $${...}, which envsubst emits
# as a literal $${...} — verified with `flux envsubst`. Bare $VAR and $(...) are left alone
# by envsubst and need no escaping. NEVER write an unescaped $${...} here, and never write
# an INDEXED expansion unescaped: envsubst hard-fails on `[` ("missing closing brace")
# and would break the whole apps-vms apply. CORRECTION (2026-09-01, measured against
# flux 2.4.0 `envsubst --strict`): escaping IS total — $${ARR[0]} emits the literal
# text safely. Only the UNESCAPED indexed form is fatal. The earlier "escaped or not"
# wording in clusters/base/apps-vms.yaml overstated it.
# INLINE, and it MUST stay <= 2048 bytes. That ceiling was MEASURED against the
# DEPLOYED KubeVirt v1.9.0 rather than quoted: a server-side dry-run apply of an
# otherwise-identical VM accepted 2048 bytes of userData and rejected 2049 with
# "cloudInitNoCloud userdata exceeds 2048 byte limit. Should use UserDataSecretRef for
# larger data." The payload below is ~1.5 KB; keep additions small.
#
# WHAT IT DOES: installs qemu-guest-agent (the KubeVirt AgentConnected signal, which is
# also the proof cloud-init ran) and preps Podman for UOS, then writes `install-uos`
# plus a systemd unit that runs it on EVERY boot. The unit — not cloud-init — is what
# makes a later URL change take effect, because cloud-init will not re-run per-instance
# modules while the instance-id is unchanged.
#
# `install-uos` IS IDEMPOTENT AND WILL NOT REINSTALL OVER A WORKING CONTROLLER. It bails
# out the moment systemd knows a `uosserver.service` — which the live guest ALREADY has:
# the serial console log for the current boot shows both `uosserver.service` and
# `uosserver-updater.service` starting, and the root filesystem is 6.3 GB of 40 GB used.
# UOS is installed here; see the README for what is actually wrong (the guest lost its
# IP). A stamp file at /var/lib/uos-installed is the second, weaker guard for the case
# where the unit name changes in some future UOS release.
#
# ENVSUBST: apps-vms declares postBuild.substituteFrom (for ${UNIFI_L4_VIP} in
# 50-service-l4.yaml), so Flux's envsubst DOES run over this text. ${BASE_DOMAIN} below
# is a deliberate substitution. Everything else is bare `$name` or `$(cmd)`, both of
# which envsubst leaves untouched — verified against the live VMI, whose rendered
# userData still carries its shell variables verbatim. There is deliberately NO
# brace-form shell expansion left in this payload, so nothing here needs escaping; if
# you add one, escape it per the contract in clusters/base/apps-vms.yaml, and NEVER
# write an indexed expansion.
userData: |-
#cloud-config
hostname: unifi
fqdn: unifi.${BASE_DOMAIN}
package_update: true
package_upgrade: false
packages: [qemu-guest-agent, podman, uidmap, slirp4netns, ca-certificates, curl, jq, cloud-guest-utils]
packages: [qemu-guest-agent, podman, uidmap, slirp4netns, ca-certificates, curl]
write_files:
- path: /usr/local/sbin/install-uos
permissions: '0755'
content: |
#!/usr/bin/env bash
#!/bin/bash
set -euo pipefail
if [ -z "$${UOS_URL:-}" ]; then
UOS_URL="$(curl -fsSL https://fw-update.ui.com/api/firmware-latest | jq -r '._embedded.firmware|map(select(.product=="unifi-os-server" and .platform=="linux-x64"))|sort_by(.version_major,.version_minor,.version_patch)|last._links.data.href')"
fi
[ -n "$${UOS_URL}" ] && [ "$${UOS_URL}" != "null" ] || { echo "no UOS URL"; exit 1; }
t="$(mktemp)"; echo "downloading $${UOS_URL}"; curl -fL --retry 5 --retry-delay 10 -o "$t" "$UOS_URL"; chmod +x "$t"; echo "installing"; yes | "$t"; echo "done"
d=/run/uos; mkdir -p $d
mountpoint -q $d || mount -o ro LABEL=UOSDATA $d
p=$(tr -d '\r\n' < $d/console-password || true)
case "$p" in ""|REPLACE-ME) ;; *) echo "ubuntu:$p" | chpasswd ;; esac
systemctl cat uosserver.service >/dev/null 2>&1 && exit 0
s=/var/lib/uos-installed
[ -e $s ] && exit 0
u=$(tr -d '\r\n' < $d/installer-url || true)
case "$u" in https://*) ;; *) echo "UOS installer-url not set yet"; exit 0;; esac
t=$(mktemp)
curl -fL --retry 5 --retry-delay 10 -o $t "$u"
chmod +x $t
yes y | $t install || yes y | $t
rm -f $t; date -Is > $s
- path: /etc/systemd/system/uos-install.service
content: |
[Unit]
Description=Install UniFi OS Server
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/install-uos
StandardOutput=journal+console
[Install]
WantedBy=multi-user.target
runcmd:
- [ systemctl, enable, --now, qemu-guest-agent ]
- [ systemctl, enable, --now, podman.socket ]
- [ bash, -lc, "/usr/local/sbin/install-uos 2>&1 | tee /var/log/uos-install.log > /dev/console" ]
- [systemctl, enable, --now, qemu-guest-agent]
- [systemctl, enable, --now, podman.socket]
- [systemctl, enable, --now, uos-install.service]
+57 -4
View File
@@ -21,8 +21,61 @@
# k3s v1.31 — but the shared-key split is the lower-risk, Cilium-native pattern.)
#
# selector kubevirt.io/vm=unifi -> the virt-launcher pod; masquerade forwards the declared ports
# into the guest. externalTrafficPolicy: Local preserves the LAN client source IP and keeps the
# VIP pinned to the node running the VM (also what L2 announcement does).
# into the guest.
#
# -------------------------------------------------------------------------------------------
# externalTrafficPolicy: Cluster — CORRECTED 2026-09-14 after an 8-DAY LAN OUTAGE.
# -------------------------------------------------------------------------------------------
# This file used to say `Local`, justified as "preserves the LAN client source IP and keeps the
# VIP pinned to the node running the VM (also what L2 announcement does)". BOTH HALVES OF THAT
# WERE FALSE ON THIS CLUSTER, and the second one took the UniFi controller off the LAN entirely.
#
# 1) L2 ANNOUNCEMENT DOES NOT FOLLOW THE BACKEND. Cilium's L2 announcer elects one lease holder
# per Service among the nodes matching the CiliumL2AnnouncementPolicy `default-l2` — which
# matches ALL linux nodes — and it does NOT filter that set by which nodes hold a backend.
# MEASURED on prod 2026-09-14: the unifi VM ran on k3s-prod-1 while BOTH
# `cilium-l2announce-unifi-unifi-l4-tcp` and `-udp` were held by k3s-prod-2. With `Local`, an
# announcing node that holds no backend has nowhere to send the packet and answers ICMP port
# unreachable. 192.168.2.21:8443, :8080 and :11443 were dead from the LAN for up to 8 days
# (the leases were 8 days old); the operator's controller UI and device inform path were down.
# It does NOT self-heal: the lease holder keeps RENEWING after the backend moves, so only
# deleting the Lease forces a re-election — and that re-election is random, so it can pick a
# backend-less node again. See apps/wave-0/pihole-dns.yaml, which hit this first and is the
# reference implementation this file now follows.
#
# 2) `Local` WAS NEVER PRESERVING THE CLIENT IP HERE ANYWAY — this is a KubeVirt VM. The
# interface in 30-virtualmachine.yaml is `masquerade: {}`, which is KubeVirt's NAT binding: it
# SNATs everything from the virt-launcher pod into the guest over a private link, so the UniFi
# controller has always seen the pod-side masquerade gateway as the source, never the LAN
# client. There is a SECOND translation below the Service, and it already destroyed the very
# thing `Local` was being kept for. So the client-IP cost of this change is ZERO for UniFi:
# it is giving up an address the guest could not see in the first place.
#
# Nothing UniFi depends on breaks: device identity in the adoption/inform flow is the device
# MAC (and the device reports its own IP inside the inform payload), not the packet source,
# and guest-portal authorization runs on the gateway appliance, not on this controller.
#
# `Cluster` makes EVERY node able to serve the VIP by forwarding to wherever the VM runs, which
# is what makes this survive a reschedule and a rebuild.
#
# NO CHANGE IS NEEDED IN 40-networkpolicy.yaml, AND THAT WAS VERIFIED RATHER THAN ASSUMED.
# Under `Cluster` the forwarded second hop is sourced from the ingress node and carries Cilium's
# reserved `remote-node` identity. pihole-dns.yaml has to name `remote-node` explicitly in its
# CiliumNetworkPolicy — but that is because its policy deliberately omits `cluster`.
# unifi-allow-l4-ingress admits `cluster`, and Cilium's `cluster` entity SUBSUMES `remote-node`
# (along with host/init/unmanaged). Confirmed on prod 2026-09-14 by dumping the endpoint's
# datapath policy map (`cilium-dbg bpf policy get`) while the CNP was in its unmodified state:
# `reserved:remote-node` was already an Allow on the L4 ports. Adding `remote-node` to that rule
# would be a no-op, so it is deliberately NOT added here — do not "fix" it.
#
# NO loadBalancerSourceRanges HERE, DELIBERATELY — and this is where this file departs from
# pihole-dns.yaml. PiHole added `loadBalancerSourceRanges: [${LAN_CIDR}]` because it was giving
# up a restriction it actually had and genuinely needed to be LAN-only. These Services never had
# a source restriction at all (unifi-allow-l4-ingress admits `world`), so `Cluster` removes no
# protection and there is nothing to compensate for. Adding a LAN-only filter during an outage
# fix would be a NEW restriction that could silently break a remote site's device informing over
# WAN — trading this outage for a subtler one. If UniFi should be LAN-only, that is its own
# change, made deliberately and tested against the device fleet.
#
# VIP: ${UNIFI_L4_VIP} (cluster-vars, R-ENV-02; dev 192.168.1.54, confirmed free, inside
# default-pool and reserved in tofu — the Gateway already holds the pool's first IP, dev .53).
@@ -49,7 +102,7 @@ metadata:
external-dns.alpha.kubernetes.io/hostname: "unifi.${BASE_DOMAIN}"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
externalTrafficPolicy: Cluster
selector:
kubevirt.io/vm: unifi
ports:
@@ -72,7 +125,7 @@ metadata:
lbipam.cilium.io/ips: "${UNIFI_L4_VIP}"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
externalTrafficPolicy: Cluster
selector:
kubevirt.io/vm: unifi
ports:
+202 -74
View File
@@ -1,18 +1,48 @@
# UniFi Network controller (UniFi OS Server) — VM onboarding
> **LIVE — partially.** Reconciled by the **non-gating `apps-vms`** Flux Kustomization
> (`clusters/base/apps-vms.yaml`, `dependsOn: platform-config`, `wait: false`). VMs are a separate
> lifecycle from the rendered app waves, and UniFi has no in-cluster dependents, so a slow import
> or install cannot stall the app tier.
> **LIVE — but the controller is unreachable, and NOT for the reason this file used to claim.**
> Reconciled by the **non-gating `apps-vms`** Flux Kustomization (`clusters/base/apps-vms.yaml`,
> `dependsOn: platform-config` + `platform-config-cdi`, `wait: false`). VMs are a separate lifecycle
> from the rendered app waves, and UniFi has no in-cluster dependents, so a slow import or install
> cannot stall the app tier.
>
> **Status (verified 2026-08-26):**
> - `longhorn-vm` StorageClass — **live** (RWX / Block / migratable).
> - CDI import — **Succeeded** (100%); `unifi-rootdisk` PVC Bound, 40Gi, RWX, `longhorn-vm`.
> - VM — **Running**, guest agent connected, Ubuntu 24.04.4 LTS, live-migratable, on `k3s-dev-1`.
> - L4 LoadBalancer VIP `192.168.1.54` — **allocated** on both Services.
> - **UniFi OS Server itself is NOT installed.** Port `11443` does not answer from in-cluster
> (the CNP explicitly permits `cluster` → `:11443`, so this is "nothing listening", not policy).
> The guest is a bare, prepped Ubuntu. **This is the one remaining step** — see Open question 1.
> **Status (re-verified against live prod 2026-09-14):**
> - `longhorn-vm` StorageClass — **live**; `unifi-rootdisk` PVC Bound, **40Gi, RWX, Block**.
> - CDI import — **Succeeded** (100%).
> - VM — **Running** on **`k3s-prod-1`**, guest agent connected, Ubuntu 24.04.4 LTS,
> `LiveMigratable=True` / `StorageLiveMigratable=True`. VMI age 34h, VM 8d.
> - L4 LoadBalancer VIP **`192.168.2.21`** — allocated and held on both Services.
> - **UniFi OS Server IS INSTALLED.** This reverses the previous entry here. See below.
> - **The guest has no IP address.** That — not a missing install — is why `:11443`, `:8443` and
> `:8080` refuse connections.
## What is actually wrong (2026-09-14)
The earlier note in this file said "UOS is NOT installed; the guest is a bare, prepped Ubuntu".
That was wrong, and acting on it would have meant reinstalling over a working controller.
Evidence, all read-only, from the live cluster:
| Claim | How it was checked | Result |
|---|---|---|
| UOS is installed | `kubectl -n unifi logs virt-launcher-unifi-… -c guest-console-log` | the current boot starts **`uosserver.service` — UniFi OS Server Service** and **`uosserver-updater.service`** |
| …and it is a real install, not a stub | KubeVirt guest-agent `filesystemlist` subresource | `/` is **6.3 GB used of 40 GB** — a bare Ubuntu cloud image with our packages is ~2 GB |
| the guest has no IP | `kubectl -n unifi get vmi unifi -o json` → `.status.interfaces` | `enp1s0`, `linkState: up`, **no `ipAddress` field at all**; the `kubectl get vmi` IP column is empty |
| …confirmed at the link layer | `kubectl -n unifi exec virt-launcher-… -c compute -- ip neigh` | **`10.0.2.2 dev k6t-eth0 FAILED`** — the guest does not answer ARP for its masquerade address |
| the ports are dead end-to-end | `/dev/tcp` probes from inside virt-launcher, to the pod IP **and** to `10.0.2.2` directly | 11443 / 8443 / 8080 / **22** all refused — i.e. the VIP and the CNP are exonerated |
So the controller is installed and its units start; nothing downstream of the guest's own network
stack can work. The interface was fine at boot (the console log reaches `network-online.target` at
~10s and cloud-init completes at 18.5s), so the address was lost afterwards — the obvious suspect is
UOS taking over host networking, but **that has not been proven and cannot be, from outside the
guest.** Diagnosing it needs a shell, which is the second problem below.
**There is currently no way into this guest.** The shipped cloud-init installs no SSH key and sets
no password, so the stock `ubuntu` account is locked and a serial console reaches a login prompt
nobody can answer. SSH would not rescue it either: the masquerade interface forwards **only** the
ports declared in `30-virtualmachine.yaml`, and 22 is not one of them — the probe above shows the
guest RSTs on 22 exactly as it does on 11443. Closing that hole is what `console-password` in the
new Secret is for.
## What this is
@@ -29,24 +59,39 @@ datastore** inside a Podman container stack — so **no separate MongoDB compani
| `namespace.yaml` | `unifi` namespace: app-namespace label (Kyverno + default-deny apply), `gateway-access=shared`, backup annotation |
| `10-default-deny.yaml` | R-NET-01 baseline: deny all, allow only DNS |
| `20-datavolume.yaml` | CDI **http** import of the Ubuntu 24.04 cloud image onto a `longhorn-vm` **RWX/Block** disk (**40Gi**). First real CDI http import on the cluster |
| `30-virtualmachine.yaml` | The VM: 2 vCPU / 4Gi, `running: true`, `evictionStrategy: LiveMigrate`, minimal **inline** cloud-init (SSH key, qemu-guest-agent, Podman prep) |
| `30-virtualmachine.yaml` | The VM: 2 vCPU / 4Gi, `running: true`, `evictionStrategy: LiveMigrate`, a **pinned firmware UUID** (= the cloud-init instance-id), the **UOSDATA secret disk**, and a minimal **inline** cloud-init that installs `install-uos` + a per-boot systemd unit |
| `40-networkpolicy.yaml` | Additive CNP allows: L4 ingress ports, controller world+DNS egress, and CDI-importer egress |
| `50-service-l4.yaml` | Two `type=LoadBalancer` Services (TCP+UDP) sharing **one pinned Cilium VIP** `192.168.1.54`; external-dns A record |
| `50-service-l4.yaml` | Two `type=LoadBalancer` Services (TCP+UDP) sharing **one pinned Cilium VIP** `${UNIFI_L4_VIP}` (prod **192.168.2.21**); external-dns A record |
| `unifi-uos-installer.sops.yaml` | **SOPS-encrypted.** The operator's UOS installer URL + guest console password, mounted into the guest as a disk |
| `unifi-uos-installer.sops.yaml.example` | Plaintext template + the operator hand-off steps (never applied, never encrypted) |
| `kustomization.yaml` | The reconciled resource list (referenced by `apps/vms/kustomization.yaml`) |
The optional L7 HTTPRoute (`90-httproute-optional.yaml`) was **dropped**, not merely disabled: UOS
serves HTTPS on `:11443`, so a Gateway route needs a `BackendTLSPolicy` that is not confirmed on
this Cilium version. Browser access is via the L4 VIP. Re-add it if/when that lands.
The optional L7 HTTPRoute was **dropped, and the file does not exist**: UOS serves HTTPS on
`:11443`, so a Gateway route needs a `BackendTLSPolicy` that is not confirmed on this Cilium
version. Browser access is via the L4 VIP. Re-add it if/when that lands.
### Cloud-init delivery — two constraints learned the hard way
### Cloud-init delivery — three constraints, all measured on the deployed KubeVirt (v1.9.0)
1. **KubeVirt caps inline `userData` at 2048 bytes.** The original rich cloud-init (full Podman
prep + `install-uos` helper) blew past it. A `userDataSecretRef` was tried, then reverted; the
shipped config is a **minimal ~1.1 KB inline `userData`**. Keep additions small or move to a
Secret deliberately.
2. **`apps-vms` must NOT use `postBuild.substituteFrom`.** Flux variable substitution rewrote
`$`-bearing text inside the cloud-init payload and **corrupted it**. `clusters/base/apps-vms.yaml`
therefore carries no `substituteFrom` — unlike every app wave. Do not "restore" it for consistency.
1. **Inline `userData` is capped at 2048 bytes.** MEASURED, not quoted: a server-side dry-run apply
of an otherwise-identical VM accepted 2048 bytes and rejected 2049 with *"cloudInitNoCloud
userdata exceeds 2048 byte limit. Should use UserDataSecretRef for larger data."* The shipped
payload is ~1.45 KB. The API field for the escape hatch is spelled **`secretRef`** (the Go name
is `UserDataSecretRef`); `userDataSecretRef` is not a YAML key and trying it will silently
validate as an unknown field.
2. **The firmware UUID is the cloud-init instance-id, and editing `userData` alone changes
nothing.** The NoCloud seed's `instance-id` is exactly
`spec.template.spec.domain.firmware.uuid`, which KubeVirt's webhook stamps onto the **VM object**
(so it survives restarts), and cloud-init's NoCloud datasource compares it to the guest's DMI
product UUID. Same id → cached datasource restored, new payload never read, `write_files` and
`runcmd` skipped. This is why the previously shipped "unattended install" could never have taken
effect on a second boot. The UUID is now **pinned in git**, with bumping it as the documented way
to force a re-run. See the long comment in `30-virtualmachine.yaml`.
3. **`apps-vms` DOES use `postBuild.substituteFrom`.** This file used to say it must not — that has
been false since 2026-08-31, when `${UNIFI_L4_VIP}` landed in `50-service-l4.yaml`. Flux's
envsubst therefore runs over the cloud-init payload, and the escape contract in
`clusters/base/apps-vms.yaml` is in force. Bare `$name` and `$(cmd)` are left alone (verified
against the live VMI's rendered userData); brace-form shell expansion must be escaped; an
indexed expansion is a hard error that fails the whole Kustomization.
### Node prerequisites this VM forced onto the cluster
@@ -56,15 +101,31 @@ this Cilium version. Browser access is via the L4 VIP. Re-add it if/when that la
- **`longhorn-scratch`** — a single-replica StorageClass for CDI scratch space; the default
3-replica class made the import fail with insufficient storage.
## UOS install method (researched)
## UOS install method (researched 2026-09-14)
- **Host:** Ubuntu 24.04 LTS x86-64, systemd, **Podman 4.9.3+**, `slirp4netns` 1.2+, `uidmap`. Min
2 GB RAM / 10 GB disk (we give 4 GB / 60 GB). Podman must be at `/usr/bin/podman`.
- **Install:** download the signed installer from `fw-download.ubnt.com` (URL obtained from
`ui.com/download/software/unifi-os-server`), `chmod +x`, run **as root**. UI then on
**`https://<host>:11443`**.
- **Confidence:** *High* on requirements, ports, and the download→chmod→run shape (multiple
independent 2025–2026 sources agree). *Lower* on **fully unattended** install — see risks.
- **Host:** Ubuntu 24.04 LTS x86-64, systemd, **Podman 4.9.3+**, `slirp4netns` 1.2+, `uidmap`.
Vendor/community minimum is 2 vCPU / 4 GB RAM / ~25 GB disk; we give **2 vCPU / 4 GiB / 40Gi**
(the 40Gi figure is the DataVolume and the Bound PVC — an earlier "60 GB" claim here was wrong).
Podman must be at `/usr/bin/podman`.
- **Install:** obtain the download link from `ui.com/download/software/unifi-os-server` while signed
in to a (free) Ubiquiti account, `chmod +x`, run **as root**. UI then on **`https://<host>:11443`**.
- **Is the URL a short-lived signed link?** *Probably not.* The links look like
`https://fw-download.ubnt.com/data/unifi-os-server/<hash>-linux-x64-<version>-<uuid>.<n>-x64` with
**no signature query string**, and public community installers hard-code them. It is
account-derived and per-release, not time-limited. It is kept in SOPS anyway — cheap, and it keeps
account-derived material out of plaintext config — but do **not** build logic around expiry.
- **Can it run unattended? Yes in practice, no officially — and this is the honest answer.**
Ubiquiti documents no `--silent` flag, no answer file and no environment variables. What the
ecosystem actually does is pipe confirmations in: the widely used community installer
(`dgodibadze/UniFi_OS_Server`) runs `yes y | "$file" install`, and vendor-adjacent walkthroughs run
either `./<binary> install` or a bare `sudo ./<binary>`. `install-uos` therefore does
`yes y | $t install || yes y | $t` — the documented-by-practice form first, the bare form as a
fallback for builds that take no subcommand. **This has not been executed end-to-end on our guest**
(no URL has been supplied, and UOS is already installed here), so treat it as "established by
convergent third-party practice", not as verified-by-us.
- The previously shipped helper had two defects worth recording: it resolved the URL from
`fw-update.ui.com/api/firmware-latest`, an endpoint nothing in this repo ever verified lists
`unifi-os-server`, and it invoked the binary with **no `install` subcommand**.
### Ports (device adoption needs the L4 ones — the L7 Gateway cannot express them)
@@ -78,76 +139,143 @@ this Cilium version. Browser access is via the L4 VIP. Re-add it if/when that la
| 3478 | UDP | **STUN** (device ⇄ controller) |
| 10001 | UDP | **L2 device discovery** |
**22/TCP is deliberately absent**, and that is load-bearing: a masquerade interface forwards only
the ports it declares, so SSH into this guest is impossible by construction. Use the serial console.
## L4 exposure design (the hard part)
The shared Gateway is L7 HTTPS only (`:443` host-routed). It **cannot** carry raw TCP `8080`/`6789`
or any UDP (`3478`/`10001`) — exactly the ports adoption rides. So:
- **One VIP, two Services.** `unifi-l4-tcp` + `unifi-l4-udp` share a single LoadBalancer IP via
Cilium `lbipam.cilium.io/sharing-key: unifi`, pinned to **`192.168.1.54`** (`lbipam.cilium.io/ips`).
The VIP is pulled from the existing `default-pool` (192.168.1.53–.60) and **ARP-announced on the
flat LAN by the existing `default-l2` CiliumL2AnnouncementPolicy** — no new Cilium config.
Cilium `lbipam.cilium.io/sharing-key: unifi`, pinned through `${UNIFI_L4_VIP}`
(`lbipam.cilium.io/ips`) — **192.168.2.21 on prod**. The VIP comes from the existing
`default-pool`, whose live prod block is **192.168.2.20–192.168.2.30** with **192.168.2.20 held by
the shared Gateway** (`cilium-gateway-shared`), and is ARP-announced on the flat LAN by the
existing `default-l2` CiliumL2AnnouncementPolicy — no new Cilium config. (The `192.168.1.53–.60`
/ `.53` figures this section used to quote were **dev** addressing and are gone; dev has been
decommissioned. Neighbours in the prod pool today: `.22` crafty, `.23` pihole-dns.)
- `selector: kubevirt.io/vm=unifi` → the virt-launcher pod; the VM's `masquerade` iface forwards the
declared ports into the guest. `externalTrafficPolicy: Local` preserves LAN client source IP.
- **Device-adoption story:** point devices' **inform host** at the VIP (`http://192.168.1.54:8080/inform`)
— via the controller's *Override inform host* setting, `set-inform` on the device, or DHCP option 43.
Because the VIP is L2-announced on the devices' own subnet (192.168.1.0/24), unicast inform + STUN
reach it. **Limitation:** broadcast-based L2 auto-discovery (10001/udp) does **not** cross the pod
masquerade boundary, so zero-touch "device just appears" adoption is unreliable — expect to use
explicit inform. Devices on other VLANs must route to the VIP and be inform-pointed anyway.
- **Reusable pattern (== the deferred `minecraft` :25565 problem):** L4-only workload → Cilium
LoadBalancer on a pinned, L2-announced VIP with a per-app sharing key, A record via external-dns.
`minecraft` is the single-TCP-port special case of this multi-port TCP+UDP design.
declared ports into the guest.
- **`externalTrafficPolicy: Cluster`, corrected 2026-09-14 after an 8-day LAN outage.** This file
used to advertise `Local` "to preserve the LAN client source IP". Both halves were false here:
Cilium's L2 announcer elects a lease holder without regard to which node holds a backend (the
leases sat on `k3s-prod-2` while the VM ran on `k3s-prod-1`, so `Local` had nowhere to forward),
and KubeVirt's `masquerade` binding had already NATed the client IP away before the guest ever saw
it. The full reasoning — including why `40-networkpolicy.yaml` needs no `remote-node` entry and
why no `loadBalancerSourceRanges` was added — is in `50-service-l4.yaml`.
- **Device-adoption story:** point devices' **inform host** at the VIP
(`http://192.168.2.21:8080/inform`) — via the controller's *Override inform host* setting,
`set-inform` on the device, or DHCP option 43. Because the VIP is L2-announced on the devices' own
subnet (**192.168.2.0/24**, `LAN_CIDR`), unicast inform + STUN reach it. **Limitation:**
broadcast-based L2 auto-discovery (10001/udp) does **not** cross the pod masquerade boundary, so
zero-touch "device just appears" adoption is unreliable — expect to use explicit inform. Devices
on other VLANs must route to the VIP and be inform-pointed anyway.
- **Reusable pattern:** L4-only workload → Cilium LoadBalancer on a pinned, L2-announced VIP with a
per-app sharing key, A record via external-dns. `crafty` (`.22`) is the same shape and is live.
**UI access:** `https://unifi.home.keithjackson.dev:11443` (A record → VIP). The port-less
`https://unifi.home.keithjackson.dev` via the Gateway is optional and needs a BackendTLSPolicy
(UOS is HTTPS-only on 11443) — see `90-httproute-optional.yaml`.
**UI access:** `https://unifi.home.keithjackson.dev:11443`. That name resolves to the VIP and not to
the Gateway on purpose: both `platform/coredns/coredns-custom.yaml` (in-cluster) and
`apps/wave-0/pihole.yaml` (LAN split-horizon) carve `unifi.${BASE_DOMAIN}` out of the
zone-wide `${GATEWAY_VIP}` answer and point it at `${UNIFI_L4_VIP}`. `${BASE_DOMAIN}` on prod is the
apex `home.keithjackson.dev` (confirmed live in the `cluster-vars` ConfigMap) after the 2026-09-13
flip, so this hostname is current, not a dev leftover.
## The operator's one field — installing UOS without a shell
`unifi-uos-installer.sops.yaml` is a SOPS-encrypted Secret with two keys. KubeVirt mounts it into
the guest as a read-only iso9660 disk labelled `UOSDATA` (one file per key), and an in-guest
`uos-install.service` reads it **on every boot**:
```bash
sops apps/vms/unifi/unifi-uos-installer.sops.yaml # fill installer-url and/or console-password
git commit && git push # merge to the branch prod's Flux tracks
kubectl -n unifi delete vmi unifi # `running: true` recreates it; no virtctl needed
kubectl -n unifi logs -f $(kubectl -n unifi get pod -l kubevirt.io=virt-launcher -o name | head -1) \
-c guest-console-log # watch the boot + the install, read-only
```
Design notes that matter if you change this:
- **The Secret is a disk, not `cloudInitNoCloud.secretRef`.** `secretRef` would lift the 2048-byte
cap but would move the whole cloud-config into ciphertext: unreviewable in diffs, invisible to the
L0 dollar-collapse guard, and still subject to Flux's envsubst (which runs over **decrypted**
resources) — the exact shape of the 2026-09-13 `nextcloud-db` incident. Script in plaintext,
operator values in SOPS.
- **A URL change needs no UUID bump.** The disk is rebuilt from the Secret on every VMI start and
the systemd unit re-reads it every boot. Only a change to the cloud-init payload itself needs the
firmware-UUID bump.
- **`install-uos` will not reinstall over a working controller.** It exits as soon as systemd knows
a `uosserver.service`, with `/var/lib/uos-installed` as a weaker second guard. Given the live
guest already has UOS, this guard is the difference between "safe to merge" and "destroys the
controller on next boot".
- **`apps-vms` had no SOPS `decryption` stanza** — this is the first encrypted content under
`apps/vms/`, so the stanza was added to both `clusters/dev/kustomization.yaml` and
`clusters/prod/kustomization.yaml`. Without it kustomize hands Flux the raw SOPS document and the
guest mounts a disk full of ciphertext, with the cluster reporting everything healthy.
## Open questions / risks
1. **Unattended install (HIGH) — STILL OPEN. This is the blocker.** The installer is a **signed,
account-tied** URL that **rotates/expires** (cannot be committed) and is documented as
**interactive** (no official silent flag). The shipped cloud-init had to be cut to ~1.1 KB for
the inline `userData` limit, so the `install-uos` helper is **no longer baked into the guest**.
**Decision needed:** operator runs the install by hand at bring-up (current de-facto answer), or
invest in a verified silent path (Secret-delivered cloud-init + a fetched-at-boot URL).
2. **CDI http import (MEDIUM) — RESOLVED in practice.** The import succeeded (first http/qcow2 CDI
1. **The guest lost its IP (HIGH) — THE LIVE BLOCKER, and it needs the operator.** Diagnosis
requires a shell, which requires `console-password` above plus one restart. First things to run
on the console: `ip -4 a`, `networkctl status enp1s0`, `journalctl -u systemd-networkd`,
`ls /etc/netplan`, and `systemctl status uosserver`. A plain restart may well restore DHCP on its
own (the address survived the first ~34h of this boot's life), but that would hide the cause, so
capture the state before rebooting a second time if you can.
2. **Unattended install (MEDIUM) — mechanism shipped, end-to-end run NOT witnessed.** See the
install-method section: no official silent flag exists, `yes y | <installer> install` is the
convergent community practice, and it has not been executed on our guest. Treat the first real
install as something to watch on the console log, not as a fire-and-forget.
3. **CDI http import (LOW) — RESOLVED in practice.** The import succeeded (first http/qcow2 CDI
import on this cluster). Still no checksum on an http source, and the URL tracks a *moving*
Ubuntu 24.04 build — pin a dated build dir if reproducibility matters. Needed two cluster-level
fixes (`nonroot-devices`, `longhorn-scratch`) — see above.
3. **Boot firmware (LOW) — RESOLVED.** Boots as shipped; running Ubuntu 24.04.4 LTS.
4. **VIP `192.168.1.54` (LOW) — RESOLVED.** Allocated and held by both Services, inside the
`192.168.1.53–.60` pool (`.53` is the shared Gateway).
5. **Longhorn RWX prerequisite (was BLOCKING) — RESOLVED.** `longhorn-vm` is live (RWX / Block /
4. **Boot firmware (LOW) — RESOLVED.** Boots as shipped; running Ubuntu 24.04.4 LTS.
5. **VIP `192.168.2.21` (LOW) — RESOLVED.** Allocated and held by both Services, inside the live
prod pool `192.168.2.20–.30`, not colliding with the Gateway (`.20`), crafty (`.22`), pihole-dns
(`.23`) or the node addresses (`.10/.11/.12`).
6. **Longhorn RWX prerequisite (was BLOCKING) — RESOLVED.** `longhorn-vm` is live (RWX / Block /
migratable); the VMI reports `LiveMigratable=True` and `StorageLiveMigratable=True`.
6. **Licensing (LOW).** UOS self-host is free; a Ubiquiti account is needed only to fetch the
7. **Licensing (LOW).** UOS self-host is free; a Ubiquiti account is needed only to fetch the
installer and for optional remote access.
7. **Broadcast adoption (LOW, by design).** L2 auto-discovery (`10001/udp`) does not cross the pod
8. **Broadcast adoption (LOW, by design).** L2 auto-discovery (`10001/udp`) does not cross the pod
masquerade boundary — see the L4 section. Expect explicit inform-host adoption.
9. **No `virtctl` in the devcontainer (LOW).** Restart and read-only console both work with plain
`kubectl` (above). An *interactive* console — the thing `console-password` unlocks — needs
`virtctl` on the operator's path; it is a single static binary from the KubeVirt release matching
the cluster's **v1.9.0**.
## Remaining bring-up
Steps 1–4 below are **done**. The sequence is kept because it is also the recovery/rebuild runbook.
Steps 1–5 are **done**. The sequence is kept because it is also the recovery/rebuild runbook, so it
is written for a from-scratch cluster; the prod addresses are the live ones.
1. ~~`longhorn-vm` StorageClass live; `192.168.1.54` free.~~ **Done.**
1. ~~`longhorn-vm` StorageClass live; a free address in the LB pool.~~ **Done** (`192.168.2.21`).
2. ~~Wire into a reconciled path~~ — **done**, via `apps/vms/` + the non-gating `apps-vms`
Kustomization (not an app wave).
Kustomization (not an app wave), including its SOPS `decryption` stanza in both overlays.
3. ~~CDI import~~ — **done**: `kubectl -n unifi get dv unifi-rootdisk` → `Succeeded`.
4. ~~VM + VIP~~ — **done**: `kubectl -n unifi get vmi unifi` → `Running`; both Services show
`192.168.1.54`.
5. **→ NEXT: install UOS in the guest.** SSH in (the inline cloud-init installs the operator's key
and `qemu-guest-agent`) or `virtctl console unifi`, fetch the signed installer URL from
ui.com/download/software/unifi-os-server, then `chmod +x` and run it **as root**.
6. Verify the UI: `https://unifi.home.keithjackson.dev:11443` (or `https://192.168.1.54:11443`),
`192.168.2.21`.
5. ~~Install UOS in the guest~~ — **done** (it was already done when this file claimed otherwise;
`uosserver.service` starts on every boot). On a REBUILD this is where the Secret does the work:
fill `installer-url`, commit, and the guest installs itself on first boot.
6. **→ NEXT, and it is the operator's:** fill `console-password`, restart the VM once, get on the
console and find out why `enp1s0` has no address. Until that is fixed nothing below can pass.
7. Verify the UI: `https://unifi.home.keithjackson.dev:11443` (or `https://192.168.2.21:11443`),
complete the first-run wizard.
7. Verify DNS: `dig unifi.home.keithjackson.dev` → `192.168.1.54` on the cluster LAN.
8. Adopt a device: set its inform to `http://192.168.1.54:8080/inform`; confirm it appears + STUN
8. Verify DNS: `dig unifi.home.keithjackson.dev` → `192.168.2.21` from a LAN client (pihole
split-horizon) and from in-cluster (coredns-custom).
9. Adopt a device: set its inform to `http://192.168.2.21:8080/inform`; confirm it appears + STUN
connects (green). Test guest portal / throughput if used.
9. Verify default-deny holds: only the declared L4 ports reachable; VM egress limited to world+DNS.
10. (Optional) Re-add the L7 HTTPRoute once `BackendTLSPolicy` is confirmed on this Cilium version.
10. Verify default-deny holds: only the declared L4 ports reachable; VM egress limited to world+DNS.
11. (Optional) Re-add the L7 HTTPRoute once `BackendTLSPolicy` is confirmed on this Cilium version.
**Quick health check today:**
```bash
kubectl -n unifi get dv,vmi,svc
kubectl -n unifi get vmi unifi -o jsonpath='{.status.guestOSInfo.prettyName}{"\n"}'
# the two that would have caught the current failure:
kubectl -n unifi get vmi unifi -o jsonpath='{.status.interfaces}{"\n"}' # expect an ipAddress
kubectl -n unifi exec "$(kubectl -n unifi get pod -l kubevirt.io=virt-launcher -o name | head -1)" \
-c compute -- ip neigh # expect 10.0.2.2 REACHABLE
```
+8
View File
@@ -11,3 +11,11 @@ resources:
- 30-virtualmachine.yaml
- 40-networkpolicy.yaml
- 50-service-l4.yaml
# SOPS-encrypted. Carries the operator's UOS installer URL (+ optional SSH key) and is mounted
# into the guest as the UOSDATA disk by 30-virtualmachine.yaml. Decrypted by the `apps-vms` Flux
# Kustomization, which clusters/dev and clusters/prod patch with the `decryption` stanza — the
# FIRST encrypted content under apps/vms, so that patch was added with this file. The plaintext
# template + the operator hand-off steps live in the `.example` sibling of the file below, which
# is deliberately NOT named here: the L0 no-plaintext-secrets check fails any kustomization whose
# text contains an example template's filename, on the assumption it is being applied.
- unifi-uos-installer.sops.yaml
@@ -0,0 +1,29 @@
apiVersion: v1
kind: Secret
metadata:
name: unifi-uos-installer
namespace: unifi
type: Opaque
stringData:
installer-url: ENC[AES256_GCM,data:pZo/fRSnUmZj2w==,iv:tBWMAL0NwCURCmfzx3UAtryCovcR+Rmh4DdtUiCi4Ig=,tag:ZfLicc3pBWujPHKqfSNOag==,type:str]
console-password: ENC[AES256_GCM,data:rQ6v6VjG4uoEShHic+zM,iv:nV7mTJFRsLvd67uZT+KyMrOhUVkERVv53LqsvcE5Qfs=,tag:9J/t2je48DRTVmppPqDfFQ==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB5M1RwWGhRKzNVenpzS25w
WW4vNHFURy9hMjRGeW0wdUJSaHN4MDFhSHlBCm1udi9NOWJ3RGdvOVNnRG9ZUlp4
TnVXRi90eFNSMjNVYVpUSmtFTURtU2MKLS0tIExPN2NRZW9CQXBKdUtCUkZSTXVO
cE9IcTM2RG9oMmt2Y0xsRExzUjYwUlEK6vq56tnnYvruNcCgijRUsfi3IreXt7fk
kgDevz3fOKkEJ3Y8+Mx3RBf3j6Za7L1lW7ft9TyTkBelRpj9bRD1Pg==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-09-19T02:47:13Z"
mac: ENC[AES256_GCM,data:DVJZQlHHb0U/F8oQ2iwPSTCvzHNynGZFW4MSHzW+LmNt8ozA7n2/U9prhRdrq3a1MHlaoGAkOFMGoQdZRR83CtZyP6oFoLiAMsRvNjNMgkaT1E3D7dzhjxAiTb+vQk9O467bH3ofm1+I/Ak3VFrNdjTtWFkWltW7MHB8njgPRPM=,iv:Sve3Z6RHKSWcdcEZtphBBmhRxjhfJHu9Dus2vb6K4+0=,tag:OrsGBPJVF1jtOAXCWNXG2Q==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
@@ -0,0 +1,71 @@
# =============================================================================
# TEMPLATE — NOT APPLIED, NOT ENCRYPTED (.yaml.example is ignored by kustomize and never
# auto-encrypted by SOPS). Reference scaffold + OPERATOR HAND-OFF.
#
# WHAT THIS IS: the two values that (a) let the UniFi VM install UniFi OS Server by itself, with
# nobody typing commands inside the guest, and (b) give the operator a way INTO that guest at all.
# KubeVirt mounts this Secret into the VM as a tiny read-only iso9660 disk labelled `UOSDATA` (see
# the `uosdata` volume in 30-virtualmachine.yaml); each key below becomes a file of the same name on
# that disk, and the in-guest `uos-install.service` reads them on EVERY boot.
#
# READ THIS FIRST — UOS IS ALREADY INSTALLED ON THE LIVE PROD GUEST (verified 2026-09-14, see the
# README "What is actually wrong"). `installer-url` is therefore NOT the fix for today's outage; it
# is the rebuild path and the "the install never happened" path. `install-uos` refuses to run the
# installer at all while systemd knows a `uosserver.service`, so filling this in cannot clobber a
# working controller. `console-password` IS useful today: it is the only way to get a shell.
#
# WHY THE URL IS BEHIND SOPS: it is obtained through the operator's own (free) Ubiquiti account
# session at ui.com/download/software/unifi-os-server, it identifies a specific build, and it is not
# ours to redistribute. Treat it as account-derived material, not as public configuration.
# HONEST CAVEAT, checked 2026-09-14: the resulting `https://fw-download.ubnt.com/data/
# unifi-os-server/<hash>-linux-x64-<version>-<uuid>.<n>-x64` URL carries NO signature query string,
# and public community install scripts hard-code these URLs — so it does not behave like a
# short-lived signed link. It does change every release. Encrypting it costs nothing and keeps the
# repo's "account-derived material is not config" line clean, but do NOT design around "it expires".
#
# HAND-OFF STEPS (operator) — this is the whole job:
# (a) Sign in at https://ui.com/download/software/unifi-os-server, pick the Linux (x64) build, and
# copy the download link (right-click -> Copy link address). Do NOT download it here; the guest
# fetches it itself.
# (b) Open the encrypted sibling in your editor:
# sops apps/vms/unifi/unifi-uos-installer.sops.yaml
# (c) Replace the two REPLACE-ME values. Either may be left as the placeholder — the guest skips a
# placeholder rather than failing.
# (d) Save/quit (sops re-encrypts in place), commit, push, merge to the branch prod tracks.
# (e) Restart the VM once so the new cloud-init instance and the new disk are picked up:
# kubectl -n unifi delete vmi unifi # `running: true` recreates it; no virtctl needed
# Watch it come up WITHOUT virtctl:
# kubectl -n unifi logs -f $(kubectl -n unifi get pod -l kubevirt.io=virt-launcher \
# -o name | head -1) -c guest-console-log
# (f) UI: https://192.168.2.21:11443 (prod) or https://unifi.home.keithjackson.dev:11443.
#
# IF YOU LATER NEED A NEW URL (new release, dead link): repeat (b)-(e). No firmware-UUID bump is
# needed for that — the disk is rebuilt from this Secret on every VMI start and the systemd unit
# re-reads it every boot. The UUID bump in 30-virtualmachine.yaml is only needed when the cloud-init
# payload ITSELF changes.
#
# The file is already listed under `resources:` in apps/vms/unifi/kustomization.yaml, and `apps-vms`
# is patched with the SOPS `decryption` stanza in clusters/dev + clusters/prod.
# =============================================================================
apiVersion: v1
kind: Secret
metadata:
name: unifi-uos-installer
namespace: unifi
type: Opaque
stringData:
# KEY NAMES ARE LOAD-BEARING: each key becomes a FILE of that exact name on the UOSDATA disk, and
# /usr/local/sbin/install-uos reads them by path. Renaming a key breaks the install silently.
#
# The signed-in download link for UniFi OS Server, Linux x64. Anything that is not an https URL
# (this placeholder included) is treated by the guest as "not provided yet": the unit logs
# "UOS installer-url not set yet" and exits 0, so the VM stays healthy and simply does not install.
installer-url: REPLACE-ME
# Password set on the guest's `ubuntu` account on every boot, for `virtctl -n unifi console unifi`.
# THIS IS THE ONLY DOOR INTO THE GUEST. The stock Ubuntu 24.04 cloud image ships `ubuntu` locked,
# no key was ever installed, and an SSH key would not help: the VM's masquerade interface forwards
# only the ports declared in 30-virtualmachine.yaml and 22 is not among them (probed from inside
# virt-launcher, 2026-09-14 — the guest RSTs on 22 just as it does on 11443). Generate one with
# openssl rand -base64 24
# Leave it as REPLACE-ME to keep the account locked; the script skips the placeholder by name.
console-password: REPLACE-ME
+1
View File
@@ -268,6 +268,7 @@ metadata:
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: media-users,platform-admins
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
+1
View File
@@ -17,6 +17,7 @@ resources:
- pihole-dns.yaml
- pihole-oauth2-proxy-credentials.sops.yaml
- pihole.yaml
- plex-claim-secret.sops.yaml
- plex.yaml
- qbittorrent-bootstrap-secret.sops.yaml
- qbittorrent-oauth2-proxy-credentials.sops.yaml
+1
View File
@@ -232,6 +232,7 @@ metadata:
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: platform-admins
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
+77
View File
@@ -0,0 +1,77 @@
# Plex plex.tv server-claim token (WS-APPS). SOPS-encrypted (age).
# Consumed by catalog/plex.yaml as env PLEX_CLAIM via valueFrom.secretKeyRef.
#
# NOTE ON THESE COMMENTS: they carry NO dollar-brace expressions, deliberately. This file is applied
# by the apps-wave-0 Flux Kustomization, which runs envsubst over the built output, and kustomize
# preserves body comments into that stream (see conformance/tests/l0-static/flux-postbuild-
# substitution). A braced token quoted from the upstream shell script would be a live hazard here,
# not prose, so the upstream conditions below are described in words.
#
# THIS FILE NO LONGER HOLDS THE PLACEHOLDER. Corrected 2026-09-29: the server was CLAIMED on that
# date, and the value here is the SPENT token from that claim. It is inert and safe to leave --
# upstream gates the exchange on PlexOnlineToken being ABSENT from /config, and it is now present on
# the plex-config PVC, so this value is a NO-OP on every start whatever it holds. Deleting the Secret
# would NOT be safe (see below), which is why a spent token stays here rather than being blanked.
#
# The claim token is SHORT-LIVED -- minutes. Read the countdown that plex.tv/claim renders when it
# issues one; do NOT budget against a number written in this tree. Earlier revisions of this comment
# asserted a "~4-minute TTL" as established fact with no citation, and upstream plexinc/pms-docker
# documents no expiry at all. That uncited number was load-bearing for the whole procedure design.
#
# This file exists to make the Secret — and therefore the env reference — ALWAYS RESOLVABLE IN THE
# FLUX GRAPH, not to carry a working token. Re-claiming (only needed after a /config wipe) is
# scripted: infra/plex/claim-ship.sh, measured at ~2m45s end to end.
#
# WHY SHIPPING A PLACEHOLDER IS SAFE (verified against plexinc/pms-docker branch `master`, the
# s6-overlay v2 script root/etc/cont-init.d/40-plex-first-run, cross-checked byte-identical against
# the same file extracted from the running `plexinc/pms-docker:latest` image):
# - line 106 guards the claim exchange on TWO conditions: PLEX_CLAIM non-empty AND the
# PlexOnlineToken pref read out of /config empty. Once the server is claimed that block is
# skipped entirely, so whatever this Secret holds is a NO-OP on every later start. Upstream
# README: "If server is already logged in, this parameter is ignored."
# - on a still-UNCLAIMED /config, an invalid or expired token makes plex.tv answer 403 with an
# empty body. curl's exit status is never checked, the sed for the authentication-token element
# yields the empty string, and the success branch at line 120 simply does not fire. There is no
# else branch and no non-zero exit; the script has no `set -e` and ends by touching
# /.firstRunComplete and echoing, so it exits 0. The server comes up UNCLAIMED and serving.
# It does NOT crashloop.
# - the image never sets S6_BEHAVIOUR_IF_STAGE2_FAILS, whose default is 0 ("continue silently even
# if a cont-init script failed"), so even a hypothetical non-zero exit would not halt startup.
# A MISSING Secret would NOT be safe: the ServiceDefinition schema's valueFrom.secretKeyRef shape
# has no `optional` field, so an absent Secret is a hard CreateContainerConfigError. That is exactly
# why this file is committed and listed in apps/wave-0/kustomization.yaml instead of being created
# by hand at claim time.
#
# INJECTION / ROTATION (operator, not agents) — runbooks/plex-claim.md has the timed procedure:
# infra/devcontainer/dev.sh run sops apps/wave-0/plex-claim-secret.sops.yaml
# Never decrypt this file into a shell, a log, or a shared terminal.
apiVersion: v1
kind: Secret
metadata:
name: plex-claim
namespace: plex
labels:
app.kubernetes.io/name: plex
type: Opaque
stringData:
PLEX_CLAIM: ENC[AES256_GCM,data:nLmzeUBWRhRjUakrGiGzQpQuqZNcIYvRH6M=,iv:z0on/dArsNejNLrp3V1nSiNLFzKcREhPuMO1634CwQw=,tag:Ih23CT1JYna/5lfYQUb1Dw==,type:str]
sops:
kms: []
gcp_kms: []
azure_kv: []
hc_vault: []
age:
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2dWtqOVNQalZ2QlRxOXJa
cDc1MTdtWENkdWIxS1JId3dIU1JDSmk0ODNrCk1YUzhFWXR3bUxyV2NNcmFOakdz
THl1Q284dFJxTy9Rd1h0eXJJaVZtdmcKLS0tIERuN0ZFeW9zdi9aNFRYNWxmUS9s
QUtYdHEvN042ajNoNktOLzlYQmQrc1kK4NHozMbXVzNGYbUBRbK6a9Uw/sVPAfe6
RW5KWzZ4I/hloqjN3zzVISoSMieMkBGDnF5qZS5N6FqIc6/HrsDD8A==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-09-29T06:31:16Z"
mac: ENC[AES256_GCM,data:62BmBAq45CD2g6LDyx+Ho4bTfrhJcaro/RB/iM+OaRc4a/D83MhjbcBL11Uz/O726inqhkvCKDJ/fFemoc50JDbtC79cYMaxrGJNjFM0sdckWrowPvh6ie7HHHUBRBaefl+gNfnc+pbvcncvRzwA4RyzcZse1wbxKfHN8FjEopE=,iv:h0cMShs3tIgnY6eruUYymYrcLFzQZLk/uXQJ/Jsd5ek=,tag:nZ9Pau7p4xWT1QCN1vv2vA==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
+5
View File
@@ -132,6 +132,11 @@ spec:
value: Etc/UTC
- name: ALLOWED_NETWORKS
value: 10.0.0.0/8,192.168.0.0/16
- name: PLEX_CLAIM
valueFrom:
secretKeyRef:
name: plex-claim
key: PLEX_CLAIM
volumeMounts:
- name: config
mountPath: /config
+1
View File
@@ -344,6 +344,7 @@ metadata:
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: media-users,platform-admins
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
@@ -4,7 +4,13 @@
# against Gitea. Flux decrypts it via the sops-age key at reconcile (apps Kustomization
# decryption is wired in clusters/dev/kustomization.yaml). Rotate by resetting the token
# in Gitea (admin -> actions -> runners) and re-encrypting a fresh value here.
# Recover/inspect: infra/devcontainer/dev.sh run sops -d apps/gitea-actions-runner-secret.sops.yaml
# ROTATE: ./infra/gitea/runner-token-rotate.sh -- it mints a fresh instance-level token from the
# Gitea API and writes it here via `sops set`, without the value ever being printed.
# Do NOT `sops -d` this file into a terminal, a log, or an agent transcript. To check only that
# it still decrypts, redirect: `sops -d <file> > /dev/null`.
# NOTE: a fresh token alone may not re-register the runner. act_runner registers ONCE and keeps
# its identity in /data/.runner on the act-runner-data PVC; if Gitea no longer holds a matching
# row it skips registration silently. Clearing that PVC is what forces a real re-registration.
apiVersion: v1
kind: Secret
metadata:
@@ -14,7 +20,7 @@ metadata:
app.kubernetes.io/name: act-runner
type: Opaque
stringData:
token: ENC[AES256_GCM,data:W48mEtBqGeukX13lMhrhrer+zB21cLVp0acy3m1Xy/oo89DqO8FX/Q==,iv:wfTUU7RiFLdEc1fmEFS9DfE2SmGCFLPK17m0IKspft0=,tag:UhoXA47p7386KHcYNbPzCg==,type:str]
token: ENC[AES256_GCM,data:f574mAUkLvMGq0TYycwCd4tduMuBgQLiTg/140kuRDgFUYYm1ZoSjQ==,iv:Tn7gFWBLm2hHk/BbfVX7qIZegwZp9ah1cMou5i2O91E=,tag:TkdSiMI4sdCNn2JuN++Pig==,type:str]
sops:
kms: []
gcp_kms: []
@@ -30,8 +36,8 @@ sops:
MDZLUE52QzJXbFlrNWxOcVhJbDNnY00KxCpFkbhhCuk4Fa8CC3C4+R9AcsUnaXQR
UjAfwKFoXAneXUih7TTXMbR/6+VgQBww5Bmwpn7jVLnzP0N0pIZVTA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2026-08-26T04:48:14Z"
mac: ENC[AES256_GCM,data:b99/zkL332RIT3FYBa1sR3o9+pwlmrUgKvVHawbjWYSJZ7ERUfO+LXuhKJ0HKrp6i1JfkurZlRO27iBcfrBisAXS9KLaqmsCJf4kKfFc4skANrmKCzprrXUmZexr1uiE8eDUzjNCLNxSCZfnFR6d/AkMXJqi6Hsj9/O+wH2lWsc=,iv:3DIPszSdxK81ERO3o/nTs33qQYHaAKLNcGP4qlZViFc=,tag:/B6VKkzMUwh0byNUicS5wQ==,type:str]
lastmodified: "2026-09-29T05:48:35Z"
mac: ENC[AES256_GCM,data:nobqr794nrdRYFrd8Ct8bw9M8NtftJXawRGk9zrL+pTWahwlWrwXVtOAxMGEPVojz3WemsxTJESzmzy0gcT1QcrjpYTsF2MVHcr6+umk8q9gAh2enyxy/0QkYiSt6XjvRVqfqleLanEkeIq1FPf2zhSLSJaiw4/oZTfkCHD1/Zg=,iv:JxaP0htmGcvE4ZU1YAmGF4A6JkejV29NNygyMl1OKF8=,tag:G70NvcPyVuagXhugWoUfTw==,type:str]
pgp: []
encrypted_regex: ^(data|stringData)$
version: 3.9.4
+69 -106
View File
@@ -99,70 +99,79 @@ spec:
# CI holds no cluster credentials — do not mount a SA token into the runner or
# (via the shared netns) the job containers (R-SEC-02, docs/14).
automountServiceAccountToken: false
# ---- Docker-in-Docker: the job executor daemon (privileged) -------------------
#
# NATIVE SIDECAR (k3s v1.36; sidecar containers are GA). It is an initContainer with
# `restartPolicy: Always`, which means: it starts BEFORE the regular containers, keeps
# running alongside them, and is terminated after them. kubelet holds `runner` until
# this container's startupProbe passes, so the ordering is enforced by the kubelet
# instead of hand-rolled shell in the runner.
#
# This REPLACES two bounded wait-loops that used to live in the runner's `command:`.
# Deleting them also deletes an envsubst hazard: clusters/base/apps-wave-1.yaml runs
# postBuild substitution over this ENTIRE manifest, comments included, and those loops
# depended on bare dollar-names surviving it untouched. There is now no shell here at
# all, so there is nothing to escape or mis-escape.
#
# WHY THE PROBE IS `exec docker ... info` AND NOT `tcpSocket`. A tcpSocket probe would
# re-open the exact gap the old Stage 1 existed to close: dockerd writes the TLS certs
# and only THEN begins listening, so the port can accept a connection while the certs
# are absent or incomplete -- and act_runner hard-exits on a missing
# /certs/client/ca.pem. A full TLS-verified `docker info` proves BOTH that the certs
# are usable and that the daemon answers.
#
# CORRECTION (measured 2026-09-29, after this conversion shipped): the commit that
# introduced this claimed the conversion would REMOVE the one-per-start dockerd line
# `http: TLS handshake error from 127.0.0.1: EOF`. IT DOES NOT. Exactly one still
# appears, immediately after "API listen on [::]:2376" -- it is now this probe's FIRST
# attempt, landing microseconds after dockerd begins listening and cut off by
# timeoutSeconds: 1. The next poll succeeds. It is cosmetic, exactly as cosmetic as it
# was before, and deliberately NOT chased: raising timeoutSeconds to hide one benign
# startup line would slow every subsequent poll. Left documented rather than silenced.
#
# periodSeconds 1 x failureThreshold 60 preserves the previous 60s cap.
initContainers:
- name: dind
restartPolicy: Always # <- this field is what makes it a SIDECAR
image: docker:27-dind
securityContext:
privileged: true
env:
# Generate + publish TLS certs into the shared emptyDir; dockerd then listens
# on tcp://0.0.0.0:2376 (TLS) which the runner reaches at localhost:2376.
- name: DOCKER_TLS_CERTDIR
value: /certs
volumeMounts:
- name: certs
mountPath: /certs
- name: dind-storage
mountPath: /var/lib/docker
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 2Gi
startupProbe:
exec:
command:
- docker
- --host=tcp://127.0.0.1:2376
- --tlsverify
- --tlscacert=/certs/client/ca.pem
- --tlscert=/certs/client/cert.pem
- --tlskey=/certs/client/key.pem
- info
periodSeconds: 1
failureThreshold: 60
containers:
# ---- act_runner: registers with Gitea, dispatches jobs to the DinD daemon ----
- name: runner
image: gitea/act_runner:0.2.11
# TWO-STAGE BOUNDED WAIT on the DinD sidecar, then exec the image's normal
# entrypoint (/sbin/tini -- /opt/act/run.sh), which registers-from-env on first
# boot and runs `act_runner daemon`.
#
# STAGE 1 — the certs must EXIST: act_runner reads /certs/client/ca.pem at startup
# and hard-exits if it is missing (a startup race against the sidecar).
# STAGE 2 — the daemon must actually ACCEPT a connection: the cert FILE appearing is
# necessary but NOT sufficient. dockerd writes the certs and only THEN begins
# listening on :2376, so a runner that proceeds on the file alone still hard-exits
# with "cannot ping the docker daemon at tcp://localhost:2376" — observed
# crashlooping 11 times in ~8 minutes on a fresh pod after a node bounce.
# Both loops are bounded (60 x 1s) and exit 1 on timeout, so a genuinely dead sidecar
# surfaces as a CrashLoopBackOff with a clear message instead of hanging forever.
#
# ENVSUBST SAFETY — the BARE $i / $j below are CORRECT. Do not "tidy" them into the
# braced form or the double-dollar form; both are wrong here, for different reasons.
# clusters/base/apps-wave-1.yaml carries postBuild.substituteFrom cluster-vars, so
# Flux envsubst runs over this ENTIRE manifest — YAML comments very much included.
# Its contract (drone/envsubst; the same one spelled out in clusters/base/apps-vms.yaml,
# and re-verified against this exact file with `flux envsubst`, flux v2.4.0) is:
# - A bare $name is NOT a reference to envsubst: it passes through untouched, and
# that holds even when the name IS set in cluster-vars. So $i and $j reach the
# cluster verbatim and both 60s bounds below are real, enforced bounds.
# - $((i + 1)) is likewise untouched: "(" cannot begin a variable name.
# - A BRACED dollar-name IS a reference and gets replaced — with the empty string
# when the name is unset. One of those here would make a bound read [ "" -ge 60 ],
# which errors, short-circuits the &&, and silently turns the wait UNBOUNDED.
# - A DOUBLE-dollar braced name is envsubst's escape and survives as the braced
# form — still wrong for us, because on any path where envsubst does NOT run
# (a plain `kustomize build` or `kubectl apply -k` of this directory) the shell
# reads that leading double-dollar as its own PID, so a bound would compare a
# string like 1234{i} against 60. The bare form is the only spelling that is
# correct on BOTH paths.
# - A braced name containing punctuation is a HARD ERROR ("unable to parse variable
# name") that fails this whole Kustomization — and this Kustomization gates every
# merge in the repo. So never write one anywhere in this file, INCLUDING in a
# comment. That is why this note names the three forms in words rather than
# showing them: an earlier draft of it spelled them out literally and broke
# `flux envsubst` on the file. (Same trap the indexed-expansion warning in
# clusters/base/apps-vms.yaml describes.)
command:
- /bin/sh
- -c
- |
# Stage 1: the sidecar's TLS client certs must appear in the shared emptyDir.
i=0
until [ -f /certs/client/ca.pem ]; do
i=$((i + 1))
[ "$i" -ge 60 ] && { echo "dind certs never appeared" >&2; exit 1; }
echo "waiting for dind TLS certs ($i)"; sleep 1
done
# Stage 2: dockerd must actually accept a connection on :2376. busybox nc in this
# Alpine-based image (gitea/act_runner:0.2.11) supports -z (scan) and -w (timeout).
j=0
until nc -z -w1 127.0.0.1 2376; do
j=$((j + 1))
[ "$j" -ge 60 ] && { echo "dind daemon never accepted a connection on 2376" >&2; exit 1; }
echo "waiting for dind daemon on :2376 ($j)"; sleep 1
done
exec /sbin/tini -- /opt/act/run.sh
# No `command:` override. The image's own entrypoint (/sbin/tini -- /opt/act/run.sh)
# registers from env on first boot and runs `act_runner daemon`. The two wait-loops
# that used to live here are gone: kubelet now holds this container until the dind
# sidecar's startupProbe passes (see initContainers above).
env:
- name: GITEA_INSTANCE_URL
value: https://gitea.${BASE_DOMAIN}
@@ -198,52 +207,6 @@ spec:
memory: 128Mi
limits:
memory: 512Mi
# ---- Docker-in-Docker: the job executor daemon (privileged) ----
#
# FOLLOW-UP (deliberately NOT bundled into the readiness fix above — own change):
# Convert this container into a NATIVE SIDECAR and delete both shell wait-loops.
# k3s is now v1.36, where sidecar containers are GA, so kubelet can enforce the
# ordering that the loops above hand-roll — which also removes the envsubst hazard
# from this file entirely (no shell, no $i/$j, nothing to escape or mis-escape).
# Shape to build:
# 1. Move this whole container into `initContainers:` as the FIRST entry.
# 2. Add `restartPolicy: Always` to it. That single field is what makes an
# initContainer a sidecar: it starts before the regular containers, keeps
# running alongside them, and is terminated after them.
# 3. Give it a `startupProbe` that succeeds only when dockerd accepts a TLS
# connection — e.g. `exec` a `docker -H tcp://127.0.0.1:2376 --tlsverify
# --tlscacert /certs/client/ca.pem --tlscert /certs/client/cert.pem
# --tlskey /certs/client/key.pem info`. Do NOT use a bare `tcpSocket` probe:
# it would re-open the same "port accepting but TLS/certs not ready" gap that
# Stage 1 above exists to close. `periodSeconds: 1` + `failureThreshold: 60`
# preserves today's 60s cap.
# 4. kubelet then holds the `runner` container until that startupProbe passes, so
# `runner` can drop its `command:` override completely and run the image's
# default entrypoint.
# Why it is a separate change: this Deployment is the ENFORCED MERGE GATE for this
# entire repository. If the conversion is wrong, nothing can merge — so it must land
# alone, where the failure is unambiguous and not entangled with anything else, and
# only after a real workflow run has been observed green on the converted pod.
- name: dind
image: docker:27-dind
securityContext:
privileged: true
env:
# Generate + publish TLS certs into the shared emptyDir; dockerd then listens
# on tcp://0.0.0.0:2376 (TLS) which the runner reaches at localhost:2376.
- name: DOCKER_TLS_CERTDIR
value: /certs
volumeMounts:
- name: certs
mountPath: /certs
- name: dind-storage
mountPath: /var/lib/docker
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 2Gi
volumes:
- name: data
persistentVolumeClaim:
+1
View File
@@ -348,6 +348,7 @@ metadata:
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: media-users,platform-admins
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
+1
View File
@@ -227,6 +227,7 @@ metadata:
annotations:
homelab.keithjackson.dev/auth-mode: oidc
homelab.keithjackson.dev/auth-groups: platform-admins
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
+1
View File
@@ -348,6 +348,7 @@ metadata:
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: media-users,platform-admins
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
+46 -9
View File
@@ -32,11 +32,46 @@ env:
# (10.43.0.0/16); 192.168.0.0/16 covers the home LAN.
- name: ALLOWED_NETWORKS
value: 10.0.0.0/8,192.168.0.0/16
# PLEX_CLAIM is deliberately NOT set here: a https://plex.tv/claim token has a ~4-min TTL, far
# shorter than the commit -> push -> Flux reconcile -> pod-roll cycle, so any token committed to git
# is already expired by the time the pod starts. Claiming is a one-time LIVE step (UI sign-in, or a
# single API POST) against the running server; the claim then persists on the /config PVC and
# survives pod rolls (including the roll this env change causes), so it never belongs in git.
# PLEX_CLAIM — the ONE-SHOT plex.tv server claim, sourced from the SOPS Secret `plex-claim`
# (apps/wave-0/plex-claim-secret.sops.yaml, same wave => applied in the same reconcile as this
# workload). Plex cannot create libraries over the mounted bulk share while the server is
# UNCLAIMED, so this is wired; it was previously left out on the reasoning below, and only one
# half of that reasoning survived contact with the upstream source.
#
# A SHORT TTL IS THE OPERATIONAL CONSTRAINT, not a reason to abstain. A https://plex.tv/claim token
# expires in MINUTES — read the countdown the claim page renders; do not budget against a number
# written in this tree. (This comment previously asserted "~4 minutes" as established fact. It was
# UNCITED, upstream plexinc/pms-docker documents no expiry at all, and that number was load-bearing
# for the whole procedure design — including a since-disproven claim that the delivery pipeline was
# too slow to fit inside it. Measured 2026-09-29: the full loop is ~2m45s and fits.)
#
# The live token is injected by the operator inside that window, with an explicit `flux reconcile`
# to collapse the wait. Scripted end to end as infra/plex/claim-ship.sh; runbooks/plex-claim.md is
# the procedure. SERVER CLAIMED 2026-09-29 — this env is now a permanent no-op (see below), and the
# committed value is the spent token from that claim rather than the original placeholder.
#
# ONE-SHOT, AND SAFE TO SIT HERE EXPIRED. Verified from plexinc/pms-docker's own first-run script
# (branch `master`, root/etc/cont-init.d/40-plex-first-run, confirmed byte-identical to the file in
# the running `:latest` image), NOT assumed:
# - the exchange (line 106) is guarded on TWO conditions: PLEX_CLAIM non-empty AND the
# PlexOnlineToken pref in /config empty. Once claimed, the block is SKIPPED — this env is a
# no-op on every subsequent start, whatever it holds. (Upstream README: "If server is already
# logged in, this parameter is ignored.") The claim itself persists on the /config PVC as before.
# - an invalid/expired token on a still-unclaimed /config gets a 403 with an empty body; curl's
# status is unchecked, the token `sed` yields "", the success branch at line 120 does not fire,
# there is no `else` and no `exit 1`, and the script (no `set -e`) exits 0. It comes up UNCLAIMED and
# SERVING — it does NOT crashloop. The image also leaves S6_BEHAVIOUR_IF_STAGE2_FAILS at its
# default 0, which continues startup even when a cont-init script fails.
# So the earlier "a placeholder claim Secret would crashloop the pod" note was WRONG about the
# placeholder; it was right only about an ABSENT Secret. secretKeyRef here has no `optional` field,
# so the Secret must always exist in the Flux graph — which is why it is committed, encrypted, and
# listed in apps/wave-0/kustomization.yaml rather than hand-created at claim time. No schema change
# is needed: `optional` would only matter if a missing Secret had to be tolerated, and it doesn't.
- name: PLEX_CLAIM
valueFrom:
secretKeyRef:
name: plex-claim
key: PLEX_CLAIM
# ADVERTISE_IP ("Custom server access URLs") is DEFERRED, not accidentally omitted. It tells Plex
# which URL to advertise to plex.tv for REMOTE clients. The only candidate is
# https://plex.home.keithjackson.dev, but that host resolves via split-horizon DNS to the INTERNAL
@@ -56,10 +91,12 @@ storage:
# container's EPHEMERAL layer -> unclaimed + all state lost on restart. The image inits as
# root (s6-svscan PID 1), chowns /config to the plex user, then drops -> no fsGroup needed
# (verified in-cluster: PID1=root, "Plex Media Server"=plex). Persisting /config here also
# makes the claim survive restarts once claimed. To auto-claim on first boot instead of the
# UI sign-in, the operator adds a PLEX_CLAIM env from a fresh https://plex.tv/claim token
# (4-min TTL) — deliberately NOT wired here: the env secretKeyRef pattern has no `optional`,
# so a placeholder/absent claim Secret would crashloop the pod. See operator hand-off.
# makes the claim survive restarts once claimed — which is what makes the PLEX_CLAIM env
# above a ONE-SHOT: the token is exchanged for a PlexOnlineToken written here, and the
# exchange is then skipped on every later start. (The older note here claimed a
# placeholder claim Secret "would crashloop the pod"; upstream's first-run script says
# otherwise — see the PLEX_CLAIM comment in `env` above. An ABSENT Secret is the hard
# failure, and that is why one is committed.)
# THE LIBRARY (docs/04 bulk tier). One READ-ONLY mount of the export ROOT, giving /media/movies,
# /media/tv and /media/photos — the whole tree the layout actually has (`ls /mnt/Storage/plex`:
# downloads/ movies/ photos/ tv/).
+179 -2
View File
@@ -80,6 +80,18 @@ BULK_NFS_SERVER = "${BULK_NFS_SERVER}"
BULK_NFS_MOUNT_OPTIONS = ["nfsvers=4.1", "hard", "noatime", "timeo=600", "retrans=2"]
GATEWAY_NAME = "shared"
GATEWAY_NS = "gateway"
# ExternalDNS per-resource scoping (R-DNS-03; see _externaldns_route_annotations for the full WHY
# and for the v0.21.0 source citation). The KEY is external-dns's own `ControllerKey`
# (annotationPrefix + "controller"); external-dns skips any source object whose value is PRESENT
# and != `ControllerValue` ("dns-controller"), so the VALUE only has to be some other string.
# `none` is chosen because it reads as intent in `kubectl get httproute -o yaml` rather than as a
# typo — "no DNS controller owns this route" — and because the one value that must never be
# emitted here is external-dns's own, which a literal `none` cannot drift into.
EXTERNALDNS_CONTROLLER_KEY = "external-dns.alpha.kubernetes.io/controller"
EXTERNALDNS_CONTROLLER_DISOWN = "none"
# The value external-dns claims for itself. Held here ONLY so the renderer and the L0 guard can
# assert the disown value is not it; never emitted.
EXTERNALDNS_CONTROLLER_CLAIMED = "dns-controller"
KP_RELEASE = "kube-prometheus-stack" # ServiceMonitor/PrometheusRule discovery label
MONITORING_NS = "monitoring" # where Prometheus runs; the only admitted scrape source
@@ -836,12 +848,15 @@ def r_in_cluster_client_policies(s: dict) -> list:
def _route_meta(s: dict, route: dict) -> dict:
"""HTTPRoute metadata: auth wiring hints PLUS the ExternalDNS scoping derived from
`exposure` (_externaldns_route_annotations — R-DNS-03: an `internal` service renders no
public DNS record, and now cannot)."""
meta = {
"name": f"{s['name']}-{route['port']}",
"namespace": s["name"],
"labels": {"app.kubernetes.io/name": s["name"]},
}
ann = _auth_annotations(s)
ann = {**_auth_annotations(s), **_externaldns_route_annotations(s)}
if ann:
meta["annotations"] = ann
return meta
@@ -1039,6 +1054,64 @@ def _auth_annotations(s: dict) -> dict:
return {}
def _externaldns_route_annotations(s: dict) -> dict:
"""ExternalDNS scoping, rendered FROM `exposure` (renderer-contract §exposure; R-DNS-03).
`internal` renders NO public DNS record. That sentence was in the contract from the start and
nothing enforced it: ExternalDNS watched EVERY HTTPRoute the shared Gateway carried, so an
`internal` service's route published `<name>.${BASE_DOMAIN}` into the PUBLIC authoritative
Cloudflare zone, answering with the Gateway VIP — an RFC1918 address. Six of them did
(jackett, pihole, qbittorrent, radarr, semaphore, sonarr), which published the internal
subnet, the Gateway VIP and a full service inventory to anyone who asked, and made every one
of those names a DNS-rebind primitive that the SOHO router strips on the way back in.
THE MECHANISM, verified against the DEPLOYED version (chart >=1.21.0 <1.22.0, app v0.21.0 —
`registry.k8s.io/external-dns/external-dns:v0.21.0`, read off the live prod Deployment), not
from memory. external-dns v0.21.0 `source/annotations/processors.go`:
func IsControllerMismatch(entity metav1.ObjectMetaAccessor, rType string) bool {
value, ok := entity.GetObjectMeta().GetAnnotations()[ControllerKey]
if ok && value != ControllerValue { ...; return true }
return false
}
with `ControllerKey = "external-dns.alpha.kubernetes.io/controller"` and
`ControllerValue = "dns-controller"`. `source/gateway.go` calls it per route inside
`Endpoints()`, BEFORE any hostname is resolved:
if annotations.IsControllerMismatch(meta, src.rtKind) { continue }
so an HTTPRoute carrying any other value is not merely unpublished — it never becomes an
endpoint at all, for any hostname it names. `source/service.go` applies the identical check,
so the same annotation scopes the `service` source too if a rendered Service ever grows an
external-dns hostname.
WHY THIS IS STRUCTURAL AND NOT INCIDENTAL. The annotation is derived HERE, from `exposure`,
with no per-service list anywhere: every HTTPRoute this renderer emits for an `internal`
service carries it, and ci/render-golden.sh proves `apps/` reproduces byte-for-byte from
`catalog/`, so an `internal` service cannot EXIST in the applied tree without it. Flipping
`exposure: internal` to `public` is the only edit that can publish a name, which is exactly
the operator-facing declaration R-DNS-03 is written in terms of. The L0 check
conformance/tests/l0-static/internal-services-publish-no-public-dns re-proves all of that on
every gate run, including that this function reads `exposure` rather than naming services.
WHY NOT A CLUSTER-WIDE OPT-IN FILTER (`--label-filter`, chart `labelFilter`). It exists, and
v0.21.0's own `--help` names both sources in use as supporting it ("currently supported by
source types crd, gateway-httproute, ... service and ambassador-host"), so a "publish only
what is labelled" posture is available and is STRONGER by default. It is not taken here
because its blast radius is not this defect's: seven published names come from hand-authored
platform/apps manifests that carry no `exposure` field and that this renderer never sees
(auth/oauth2-proxy, crafty + crafty-mc, grafana, headlamp, the homepage apex, unifi). An
opt-in filter DELETES all seven the moment it is set, and whether each should stay published
is an operator decision, not a renderer one. Per-resource exclusion has exactly the blast
radius of the reported defect. The filter remains the available hardening once those seven
are adjudicated — see platform/externaldns/helmrelease.yaml.
"""
if s["exposure"] == "internal":
return {EXTERNALDNS_CONTROLLER_KEY: EXTERNALDNS_CONTROLLER_DISOWN}
return {}
def r_servicemonitor(s: dict):
"""ServiceMonitor — rendered ONLY when the SD says WHERE metrics are served.
@@ -1771,6 +1844,10 @@ def plan_apps_waves(sds: list, present_files: set) -> dict:
convention:
- `<svc>.yaml` -> wave(svc)
- `<svc>-bootstrap-secret.sops.yaml` -> wave(svc) (consumed by svc's env, same apply)
- `<svc>-claim-secret.sops.yaml` -> wave(svc) (a one-shot upstream-service claim token
consumed by svc's env — plex's plex.tv claim. Same wave as the app for the same reason the
bootstrap secret is: an env `secretKeyRef` has no `optional`, so the Secret must be applied
in the SAME reconcile as the workload or the pod is CreateContainerConfigError.)
- `<svc>-oauth2-proxy-credentials.sops.yaml` -> wave(svc) (forward-auth sidecar OIDC creds)
- `<svc>-db.yaml` -> wave(svc-db) (a strictly-lower wave; CNPG DB)
- `<svc>-db-app.sops.yaml` -> wave(svc-db) (CNPG initdb owner secret)
@@ -1797,6 +1874,11 @@ def plan_apps_waves(sds: list, present_files: set) -> dict:
w = waves[name]
add(w, f"{name}.yaml")
add(w, f"{name}-bootstrap-secret.sops.yaml")
# A one-shot claim token for an UPSTREAM service (plex's plex.tv claim). Same wave as the
# app: it is read by the app's env at pod start, and an env secretKeyRef has no `optional`,
# so a Secret applied in a later wave would leave the pod in CreateContainerConfigError.
# Placed only when the file exists, so services without one are unaffected.
add(w, f"{name}-claim-secret.sops.yaml")
# forward-auth (Option B): the per-app oauth2-proxy sidecar's OIDC client + cookie secret.
# Same wave as the app (the sidecar reads it at pod start); placed only when the file exists,
# so non-forward-auth apps are unaffected. Mirrors the bootstrap-secret naming convention.
@@ -2097,6 +2179,96 @@ def _selftest() -> int:
"a non-exposed service must not render a gateway-ingress allow (least privilege)",
)
# 4b-dns) exposure -> ExternalDNS SCOPING on the HTTPRoute (R-DNS-03). `internal` renders no
# public DNS record and must be INCAPABLE of rendering one: its route carries
# external-dns's own controller annotation set to something other than `dns-controller`,
# which v0.21.0's gateway-httproute source (source/gateway.go ->
# annotations.IsControllerMismatch) uses to skip the route before any hostname is
# resolved. `public` must NOT carry it, or the five public names would vanish instead.
# Both directions are asserted here because only asserting the internal half would pass
# just as happily if the renderer stamped EVERY route and silently unpublished the zone.
dns_int_docs = render(
{
"name": "di",
"source": {"image": "a:1"},
"exposure": "internal",
"ingressRoutes": [{"host": "di.home.keithjackson.dev", "port": 80}],
"auth": {"mode": "none"},
}
)
dns_int_routes = [d[1] for d in dns_int_docs if d[1]["kind"] == "HTTPRoute"]
check(
len(dns_int_routes) == 1,
"an internal exposed service must still render its HTTPRoute (LAN reachability is "
"unchanged — only the PUBLIC record is withdrawn)",
)
if dns_int_routes:
iann = dns_int_routes[0]["metadata"].get("annotations", {})
check(
iann.get(EXTERNALDNS_CONTROLLER_KEY) == EXTERNALDNS_CONTROLLER_DISOWN,
"an internal service's HTTPRoute must carry the ExternalDNS disown annotation",
)
check(
iann.get(EXTERNALDNS_CONTROLLER_KEY) != EXTERNALDNS_CONTROLLER_CLAIMED,
"the disown value must never be external-dns's own `dns-controller` (that CLAIMS "
"the route rather than releasing it — the one value that inverts the guarantee)",
)
dns_pub_docs = render(
{
"name": "dp",
"source": {"image": "a:1"},
"exposure": "public",
"ingressRoutes": [{"host": "dp.home.keithjackson.dev", "port": 80}],
"auth": {"mode": "native"},
}
)
dns_pub_routes = [d[1] for d in dns_pub_docs if d[1]["kind"] == "HTTPRoute"]
check(len(dns_pub_routes) == 1, "a public service must render its HTTPRoute")
if dns_pub_routes:
check(
EXTERNALDNS_CONTROLLER_KEY
not in dns_pub_routes[0]["metadata"].get("annotations", {}),
"a public service's HTTPRoute must NOT carry the ExternalDNS disown annotation",
)
# The DEFAULT posture is `internal` (renderer-contract "Default posture"), so a
# ServiceDefinition that simply omits `exposure` must also be unpublishable. Omitting the
# field is the likeliest way a new service arrives, and it is the case a per-service
# allow-list would miss.
dns_dflt_docs = render(
{
"name": "dd",
"source": {"image": "a:1"},
"ingressRoutes": [{"host": "dd.home.keithjackson.dev", "port": 80}],
"auth": {"mode": "none"},
}
)
dns_dflt_route = next(d[1] for d in dns_dflt_docs if d[1]["kind"] == "HTTPRoute")
check(
dns_dflt_route["metadata"]["annotations"].get(EXTERNALDNS_CONTROLLER_KEY)
== EXTERNALDNS_CONTROLLER_DISOWN,
"a ServiceDefinition that OMITS `exposure` defaults to internal and must also be "
"unpublishable (default posture, renderer-contract)",
)
# The scoping annotation must COEXIST with the auth hints rather than replace them: both are
# merged into one `annotations` map, and a forward-auth internal app is the overlap case.
dns_fa_docs = render(
{
"name": "df",
"source": {"image": "a:1"},
"exposure": "internal",
"ingressRoutes": [{"host": "df.home.keithjackson.dev", "port": 80}],
"auth": {"mode": "forward-auth", "groups": ["platform-admins"]},
}
)
dns_fa_ann = next(
d[1] for d in dns_fa_docs if d[1]["kind"] == "HTTPRoute"
)["metadata"]["annotations"]
check(
dns_fa_ann.get(EXTERNALDNS_CONTROLLER_KEY) == EXTERNALDNS_CONTROLLER_DISOWN
and dns_fa_ann.get(f"{LBL}/auth-mode") == "forward-auth",
"DNS scoping and auth-mode hints must COEXIST on one internal forward-auth route",
)
# 4c) inClusterClients -> one ADDITIVE, least-privilege ingress allow per entry that admits a
# PLATFORM controller's NAMESPACE (not the reserved `ingress` identity, not `all`) to ONE
# declared port. This is what keeps a cluster whose GitOps source is an in-cluster Service
@@ -2385,7 +2557,8 @@ def _selftest() -> int:
# 7a) plan_apps_waves: sidecars inherit their unit's wave; not-onboarded SDs are skipped;
# EXTRA_UNITS anchor strictly above; a file absent from the tree is never placed.
present = {
"a.yaml", "svc.yaml", "svc-bootstrap-secret.sops.yaml",
"a.yaml", "a-claim-secret.sops.yaml",
"svc.yaml", "svc-bootstrap-secret.sops.yaml",
"svc-db.yaml", "svc-db-app.sops.yaml", "svc-db-backup-credentials.sops.yaml",
"svc-db-backup-credentials-prod.sops.yaml",
"gitea.yaml",
@@ -2396,6 +2569,10 @@ def _selftest() -> int:
plan = plan_apps_waves(plan_sds, present)
flat = {f: wv for wv, fs in plan.items() for f in fs}
check(flat.get("a.yaml") == 0, "leaf app must land in wave 0")
check(flat.get("a-claim-secret.sops.yaml") == 0,
"a `<svc>-claim-secret.sops.yaml` (plex's plex.tv claim) must be placed in the SAME wave "
"as its app — an env secretKeyRef has no `optional`, so a later wave means "
"CreateContainerConfigError")
check(flat.get("svc-db.yaml") == 0 and flat.get("svc-db-app.sops.yaml") == 0
and flat.get("svc-db-backup-credentials.sops.yaml") == 0
and flat.get("svc-db-backup-credentials-prod.sops.yaml") == 0,
+14
View File
@@ -1,5 +1,19 @@
# `ci/` — portable merge gate (runnable today, without Gitea)
> **STATUS NOTE (2026-09-29) — read before trusting the "Phase 0" framing below.**
> This document speaks as though no cluster exists and the platform is scaffolding. **A
> three-node k3s cluster has existed for weeks** and Flux has been reconciling against it.
> Gitea is live, the Actions runner `incluster-dind` is online, and `main` is a protected
> branch. Where the text says "Phase 0", "stub", or "no cluster exists", read it as
> describing a **live gap** rather than an expected stage — most importantly, L2/L3
> conformance still does not run in CI (`vars.CLUSTER_AVAILABLE` is unset), which is a real
> coverage hole, not a phase.
>
> This mismatch is not cosmetic. A month of "the gate enforces this" claims rested on
> documentation rather than measurement, and `main` was unprotected with zero runners
> registered the whole time. **Query the live API before asserting platform state.**
**Task T-0.2 · R-TEST-02 (reproducible, single-command) · Owner WS-GITOPS (CI).**
The same merge gate the Gitea Actions workflow runs
+28 -3
View File
@@ -79,12 +79,37 @@ else
cp -a "${KYVERNO_POLICY_DIR}/." "$l0_render/"
find "$l0_render" -type f -name '*.yaml' -print0 \
| xargs -0 sed -i "s/\${KYVERNO_VALIDATION_ACTION}/${KYVERNO_VALIDATION_ACTION}/g"
if kyverno test "$l0_render"; then
ok "Kyverno L0 policy tests passed"
summary "- kyverno-l0: **passed**"
# DO NOT TRUST `kyverno test`'s EXIT CODE ALONE. A policy that fails to LOAD (schema
# error, bad match block, malformed JMESPath) does not fail the run: every result row
# becomes `Skip / Invalid Policy` and the CLI still prints "N tests passed" and exits 0.
#
# MEASURED 2026-09-29 while authoring conformance-trigger-job-shape: the policy had its
# `subjects` in a separate `match.all` entry from its `resources` — a schema error. All 9
# rows came back `Skip / Invalid Policy`, and the summary read:
# Test Summary: 9 tests passed and 0 tests failed
# `kyverno apply` on the same file reported `error: 1` and named the path. So the gate's
# own policy stage would have certified a SECURITY POLICY THAT DOES NOT LOAD as green.
#
# This is not specific to that policy: a typo in restrict-host-namespaces or
# secret-provenance would silently disable it and the gate would stay green. Scan the
# output for the load failure the exit code hides.
l0_kout="$(mktemp)"
if kyverno test "$l0_render" 2>&1 | tee "$l0_kout"; then
if grep -q 'Invalid Policy' "$l0_kout"; then
err "Kyverno reported 'Invalid Policy' — one or more policies FAILED TO LOAD and were"
err " skipped, so their rules were never evaluated. The CLI exits 0 for this; the gate"
err " must not. Run 'kyverno apply <policy> --resource <resources>' for the exact path."
grep -n 'Invalid Policy' "$l0_kout" | head -5 >&2
summary "- kyverno-l0: **FAILED — policy did not load** (Invalid Policy; rules never evaluated)"
rc=1
else
ok "Kyverno L0 policy tests passed"
summary "- kyverno-l0: **passed**"
fi
else
err "Kyverno L0 policy tests failed"; summary "- kyverno-l0: **FAILED**"; rc=1
fi
rm -f "$l0_kout"
rm -rf "$l0_render"
fi
+7 -5
View File
@@ -30,11 +30,13 @@ req_args=(${REQ_ARGS[@]+"${REQ_ARGS[@]}"})
# (conformance/_artifacts/) and does NOT accept --junit/--coverage. Invoke by layer +
# touched subset, then COLLECT its artifacts into the CI artifact dirs.
# --include-scope: union the touched subset with every L1 test DECLARING
# `conformance.home.k3s/scope: whole-repo`. No L1 test declares it today, so this is a no-op
# now — it is wired HERE, once, so the NEXT whole-repo invariant (at any layer) is picked up by
# declaring the annotation, instead of needing someone to remember to hand-write a stage. That
# "someone must remember" is the failure mode this whole change removes; see
# conformance/lib/common.sh. rc 3 = a scope-declared check SKIPPED = UNWIRED, never a pass.
# `conformance.home.k3s/scope: whole-repo`. As of 2026-09-18 ALL THREE L1 tests declare it, so this
# is load-bearing rather than a no-op: every L1 golden is checked on every gate run, whatever the
# touched paths attribute. It was wired HERE first, ahead of any declaration, so picking up a new
# whole-repo invariant needs only the annotation instead of someone remembering to hand-write a
# stage — and that "someone must remember" is exactly how the 2026-09-04 external-dns merge gated
# GREEN while leaving all three goldens stale (see the rationale in the Tests themselves).
# rc 3 = a scope-declared check SKIPPED = UNWIRED, never a pass.
: "${WHOLE_REPO_SCOPE:=whole-repo}"
l1_rc=0
"$CONFORMANCE_CMD" --layer l1 --include-scope "$WHOLE_REPO_SCOPE" \
+59
View File
@@ -136,6 +136,65 @@ printf 'touched set (clusters/ path map):\n%s\n---\n' "$OUT4"
assert_present "R-ENV-02" "$OUT4" "clusters/* attributes R-ENV-02 (selects placeholder-guard-coupling HALF A)"
assert_present "R-GITOPS-01" "$OUT4" "clusters/* attributes R-GITOPS-01"
# ---------------------------------------------------------------------------------------
# PATH MAP — apps/*, the C2 UI, and the platform assembly (added 2026-09-19)
#
# These arms closed the largest remaining fail-open hole in the map. Before them, 49 tracked
# app manifests (apps/wave-0, -1, -2, apps/vms) plus platform/homepage, platform/headlamp,
# platform/gateway, platform/kubevirt, platform/crafty, platform/coredns, the snapshot/backup
# machinery and platform/kustomization.yaml all attributed NOTHING — so a change to any of them
# selected only the whole-repo-scoped checks, and every requirement-scoped L2 behaviour test
# (default-deny, egress class, auth mode, C2 surface) was filtered out of the merge gate.
#
# The C2 case was already documented as a known gap in changed_diff()'s rule-3 comment: the ONLY
# route to R-C2-01/02 was rule 3, i.e. editing the C2 test itself.
#
# As with bootstrap/ above, each fixture cites NO R-ID of its own, so any R-ID in the output came
# from the PATH and not from the content scan.
# apps/vms/* must win over apps/* — `case` takes the FIRST match, so arm ORDER is the property
# under test here, not just the presence of both arms.
git -C "$TMP" checkout -q -b feature-apps-vms main
mkdir -p "$TMP/apps/vms/demo"
printf 'kind: VirtualMachine\nmetadata:\n name: demo\n' > "$TMP/apps/vms/demo/vm.yaml"
git -C "$TMP" add -A && git -C "$TMP" commit -qm "touch apps/vms/"
OUT_VMS="$(REPO_ROOT="$TMP" BASE_REF=main "$SCRIPT" 2>/dev/null)"
printf 'touched set (apps/vms/ path map):\n%s\n---\n' "$OUT_VMS"
assert_present "R-PLAT-03" "$OUT_VMS" "apps/vms/* attributes R-PLAT-03 (KubeVirt VM lifecycle)"
assert_present "R-STATE-01" "$OUT_VMS" "apps/vms/* attributes R-STATE-01 (VM root/data volumes)"
assert_absent "R-PLAT-02" "$OUT_VMS" "apps/vms/* does NOT fall through to the apps/* container arm (order holds)"
# A plain ServiceDefinition under apps/ gets the container-workload set.
git -C "$TMP" checkout -q -b feature-apps main
mkdir -p "$TMP/apps/wave-1"
printf 'kind: ServiceDefinition\nmetadata:\n name: demo\n' > "$TMP/apps/wave-1/demo.yaml"
git -C "$TMP" add -A && git -C "$TMP" commit -qm "touch apps/wave-1/"
OUT_APPS="$(REPO_ROOT="$TMP" BASE_REF=main "$SCRIPT" 2>/dev/null)"
printf 'touched set (apps/ path map):\n%s\n---\n' "$OUT_APPS"
assert_present "R-PLAT-02" "$OUT_APPS" "apps/* attributes R-PLAT-02 (container workload render)"
assert_present "R-NET-01" "$OUT_APPS" "apps/* attributes R-NET-01 (default-deny applies to every app)"
assert_present "R-NET-06" "$OUT_APPS" "apps/* attributes the egress-class requirements"
assert_present "R-IDP-02" "$OUT_APPS" "apps/* attributes the auth-mode requirements"
# homepage / headlamp ARE the command-centre surface.
git -C "$TMP" checkout -q -b feature-c2 main
mkdir -p "$TMP/platform/headlamp"
printf 'kind: Deployment\nmetadata:\n name: headlamp\n' > "$TMP/platform/headlamp/deployment.yaml"
git -C "$TMP" add -A && git -C "$TMP" commit -qm "touch platform/headlamp/"
OUT_C2="$(REPO_ROOT="$TMP" BASE_REF=main "$SCRIPT" 2>/dev/null)"
printf 'touched set (C2 path map):\n%s\n---\n' "$OUT_C2"
assert_present "R-C2-01" "$OUT_C2" "platform/headlamp/* attributes R-C2-01 (was reachable only by editing the C2 test)"
assert_present "R-C2-02" "$OUT_C2" "platform/headlamp/* attributes R-C2-02"
# platform/kustomization.yaml is the assembly: one deleted line disables a whole component.
git -C "$TMP" checkout -q -b feature-assembly main
mkdir -p "$TMP/platform"
printf 'resources:\n - cilium\n - longhorn\n' > "$TMP/platform/kustomization.yaml"
git -C "$TMP" add -A && git -C "$TMP" commit -qm "touch platform/kustomization.yaml"
OUT_ASM="$(REPO_ROOT="$TMP" BASE_REF=main "$SCRIPT" 2>/dev/null)"
printf 'touched set (platform assembly):\n%s\n---\n' "$OUT_ASM"
assert_present "R-GITOPS-01" "$OUT_ASM" "platform/kustomization.yaml attributes R-GITOPS-01 (component assembly)"
# ---------------------------------------------------------------------------------------
# THE ID PATTERN MUST ALLOW A DIGIT IN THE WORKSTREAM SEGMENT (added 2026-09-13)
#
+50
View File
@@ -177,6 +177,23 @@ path_requirements() {
case "$1" in
catalog/*) echo "R-GITOPS-02" ;;
schemas/*) echo "R-GITOPS-02" ;;
# apps/* was UNMAPPED until 2026-09-19, and it is the largest such hole in the map: 49 tracked
# manifests (apps/wave-0, -1, -2, apps/vms) that describe every workload on the cluster. A change
# adding or editing an app attributed NOTHING, so the merge gate ran only the whole-repo-scoped
# checks and none of the requirement-scoped L2 behaviour tests — default-deny enforcement, egress
# class enforcement, auth-mode enforcement — which are exactly what a new or edited app can
# violate. Kyverno admission and the L0 egress-path check are real backstops, but "admission will
# catch it at apply time" is post-merge, and the gate exists to catch it before.
#
# ORDER MATTERS: `case` takes the FIRST match, so apps/vms/* must precede apps/*.
# apps/vms/ are KubeVirt VMs (R-PLAT-03 lifecycle) with root/data volumes (R-STATE-01), still
# subject to default-deny (R-NET-01).
apps/vms/*) echo "R-PLAT-03 R-NET-01 R-STATE-01" ;;
# Everything else under apps/ is a ServiceDefinition rendered by catalog/render.py: a container
# workload (R-PLAT-02) rendered from the catalog (R-GITOPS-02), carrying the egress-class
# (R-NET-05/06/07) and auth-mode (R-IDP-02/R-IDP-06) labels that admission requires, under
# default-deny (R-NET-01).
apps/*) echo "R-PLAT-02 R-GITOPS-02 R-NET-01 R-NET-05 R-NET-06 R-NET-07 R-IDP-02 R-IDP-06" ;;
platform/kyverno/*) echo "R-TEST-01 R-NET-01 R-SEC-01" ;;
platform/cilium/*) echo "R-NET-01 R-NET-02 R-NET-05" ;;
platform/longhorn/*) echo "R-STATE-01 R-STATE-02" ;;
@@ -208,6 +225,39 @@ path_requirements() {
platform/keycloak/*) echo "R-IDP-01 R-IDP-02 R-IDP-03 R-IDP-04 R-IDP-05 R-IDP-06" ;;
platform/oauth2-proxy/*) echo "R-IDP-02 R-IDP-06" ;;
platform/image-automation/*) echo "R-UPD-01 R-UPD-02" ;;
# The C2 UI was UNMAPPED until 2026-09-19. This is the gap named explicitly in changed_diff()'s
# rule-3 comment above: "path_requirements() has no arm for platform/homepage/* or
# platform/headlamp/* either, so the ONLY route to R-C2-01/02 was rule 3 (editing the C2 test
# itself)". homepage + headlamp ARE the command-centre surface (registry: R-C2-01/02 ->
# docs/12-command-center-ui.md), so editing them must select the C2 tests.
platform/homepage/*|platform/headlamp/*) echo "R-C2-01 R-C2-02" ;;
# KubeVirt is the VM platform itself (R-PLAT-03); its virt-* components are also scrape targets.
platform/kubevirt/*) echo "R-PLAT-03 R-OBS-01" ;;
# The shared ingress Gateway: the L2-announced VIP every HTTP service answers on (R-DNS-01), the
# TLS listeners and their certificates (R-TLS-01/02), under default-deny (R-NET-01).
platform/gateway/*) echo "R-DNS-01 R-TLS-01 R-TLS-02 R-NET-01" ;;
# CoreDNS is cluster DNS resolution (R-DNS-01).
platform/coredns/*) echo "R-DNS-01" ;;
# crafty is a first-class app living under platform/: a container workload with a Longhorn PVC and
# its OWN L4 LoadBalancer VIP (Minecraft 25565 is not HTTP, so it does not use the Gateway).
platform/crafty/*) echo "R-PLAT-02 R-NET-01 R-DNS-01 R-STATE-01" ;;
# Snapshot machinery is the storage/DR path: CSI snapshots (R-STATE-01) feeding backup (R-STATE-03).
platform/external-snapshotter/*|platform/backup/*) echo "R-STATE-01 R-STATE-03" ;;
# runbooks/ IS the R-OBS-04 surface: alert-runbooks-resolvable asserts every alert's runbook_url
# resolves to a served page, and runbooks/serving/ is the Deployment that serves them. That check
# is whole-repo-scoped so it runs regardless, but the attribution should still be honest — a
# runbook change is an observability change.
runbooks/*) echo "R-OBS-04" ;;
# platform-config/ is the env-specific configuration Flux substitutes into and applies.
platform-config/*|platform-config-cdi/*) echo "R-ENV-02 R-GITOPS-01" ;;
# platform/kustomization.yaml is the ASSEMBLY: the list of platform components Flux applies.
# Deleting one line there disables an entire component (cilium, longhorn, velero...) while every
# file it points at stays perfectly valid on disk, so nothing else in the map would attribute the
# change. That is the most consequential single-line edit in the repo and it attributed nothing.
platform/kustomization.yaml) echo "R-GITOPS-01" ;;
# .sops.yaml holds the encryption rules (which paths must be encrypted, with which keys). A change
# here can silently stop a new secret path from being encrypted, which is R-SEC-01's whole point.
.sops.yaml) echo "R-SEC-01 R-GITOPS-01" ;;
conformance/*) echo "R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04" ;;
# infra/ was UNMAPPED until 2026-09-12 — the OpenTofu + Ansible that provision the dev
# nodes. conformance/tests/l0-static/dev-nodes-provisioned-as-code reads NOTHING but
+14
View File
@@ -1,5 +1,19 @@
# clusters/
> **STATUS NOTE (2026-09-29) — read before trusting the "Phase 0" framing below.**
> This document speaks as though no cluster exists and the platform is scaffolding. **A
> three-node k3s cluster has existed for weeks** and Flux has been reconciling against it.
> Gitea is live, the Actions runner `incluster-dind` is online, and `main` is a protected
> branch. Where the text says "Phase 0", "stub", or "no cluster exists", read it as
> describing a **live gap** rather than an expected stage — most importantly, L2/L3
> conformance still does not run in CI (`vars.CLUSTER_AVAILABLE` is unset), which is a real
> coverage hole, not a phase.
>
> This mismatch is not cosmetic. A month of "the gate enforces this" claims rested on
> documentation rather than measurement, and `main` was unprotected with zero runners
> registered the whole time. **Query the live API before asserting platform state.**
Flux entrypoints and the environment-overlay model (**R-ENV-02**, **R-GITOPS-01/02/03**).
Governing spec: [`docs/05-gitops-and-delivery.md`](../../home-network-k3s/docs/05-gitops-and-delivery.md),
+31
View File
@@ -0,0 +1,31 @@
---
# Conformance identities (ns + conformance-runner + conformance-trigger RBAC).
#
# NON-GATING, exactly like apps-vms: `wait: false` and its own Kustomization, so a conformance
# identity can never gate the platform or app tiers. A test harness that stalls the estate it is
# meant to verify is worse than no harness.
#
# dependsOn platform-config only — it needs nothing from the app waves, and nothing in the app
# waves needs it.
#
# NOTE: this reconciles RBAC that is INERT until
# platform/kyverno/admission/conformance-trigger-job-shape.yaml is also enforcing. That policy
# ships in the `platform` tier, which reconciles before this. Do not reorder them so that the
# trigger identity can exist while the policy bounding it does not.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: conformance
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 5m
prune: true
wait: false
dependsOn:
- name: platform-config
sourceRef:
kind: GitRepository
name: flux-system
path: ./conformance/incluster
+1
View File
@@ -33,6 +33,7 @@ resources:
- apps-wave-2.yaml # Flux Kustomization -> ./apps/wave-2 (dependsOn apps-wave-1)
# VM tier (KubeVirt) — non-gating (wait:false), separate lifecycle from the app waves.
- apps-vms.yaml # Flux Kustomization -> ./apps/vms (dependsOn platform-config)
- conformance.yaml # Flux Kustomization -> ./conformance/incluster (non-gating; R-SEC-02 trigger identity)
# oauth2-proxy forward-auth — POST-platform tier (dependsOn platform-config, wait:false). A leaf
# service that needs Keycloak + trusted TLS + split-horizon DNS must NOT sit in the gating
# `platform` tier, where it self-gated its own dependencies into a cold-start deadlock (twice).
+17
View File
@@ -122,6 +122,23 @@ patches:
provider: sops
secretRef:
name: sops-age
# apps-vms (the KubeVirt VM tier, clusters/base/apps-vms.yaml) gained its FIRST encrypted Secret
# on 2026-09-14: apps/vms/unifi/unifi-uos-installer.sops.yaml, the operator's UOS installer URL,
# which 30-virtualmachine.yaml mounts into the guest as a disk. Without this stanza kustomize
# would hand Flux the raw SOPS document and the VM would mount a disk full of ciphertext — the
# failure is silent from the cluster's point of view (the Secret applies fine), so the stanza is
# as load-bearing as the file itself. The `sops-age` Secret is out-of-band (name only).
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps-vms
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
- patch: |-
apiVersion: source.toolkit.fluxcd.io/v1
kind: GitRepository
+17
View File
@@ -113,6 +113,23 @@ patches:
provider: sops
secretRef:
name: sops-age
# apps-vms (the KubeVirt VM tier, clusters/base/apps-vms.yaml) gained its FIRST encrypted Secret
# on 2026-09-14: apps/vms/unifi/unifi-uos-installer.sops.yaml, the operator's UOS installer URL,
# which 30-virtualmachine.yaml mounts into the guest as a disk. Without this stanza kustomize
# would hand Flux the raw SOPS document and the VM would mount a disk full of ciphertext — the
# failure is silent from the cluster's point of view (the Secret applies fine), so the stanza is
# as load-bearing as the file itself. Mirrors clusters/dev. `sops-age` is out-of-band (name only).
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: apps-vms
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
# --- GitOps source (operator decision 2026-09-01: prod tracks the SAME BRANCH as dev) ---
#
# ############################################################################################
+14
View File
@@ -1,5 +1,19 @@
# conformance/ — the home-k3s conformance harness (WS-TEST)
> **STATUS NOTE (2026-09-29) — read before trusting the "Phase 0" framing below.**
> This document speaks as though no cluster exists and the platform is scaffolding. **A
> three-node k3s cluster has existed for weeks** and Flux has been reconciling against it.
> Gitea is live, the Actions runner `incluster-dind` is online, and `main` is a protected
> branch. Where the text says "Phase 0", "stub", or "no cluster exists", read it as
> describing a **live gap** rather than an expected stage — most importantly, L2/L3
> conformance still does not run in CI (`vars.CLUSTER_AVAILABLE` is unset), which is a real
> coverage hole, not a phase.
>
> This mismatch is not cosmetic. A month of "the gate enforces this" claims rested on
> documentation rather than measurement, and `main` was unprotected with zero runners
> registered the whole time. **Query the live API before asserting platform state.**
This subtree is the **definition of "done"** for the whole platform. Nothing is accepted
on a demo, a screenshot, or "it reconciled": every requirement is proven by an
**automated, reproducible, behavior-based test** tagged with the requirement ID(s) it
+14
View File
@@ -1,5 +1,19 @@
# In-cluster conformance Job (L2/L3)
> **STATUS NOTE (2026-09-29) — read before trusting the "Phase 0" framing below.**
> This document speaks as though no cluster exists and the platform is scaffolding. **A
> three-node k3s cluster has existed for weeks** and Flux has been reconciling against it.
> Gitea is live, the Actions runner `incluster-dind` is online, and `main` is a protected
> branch. Where the text says "Phase 0", "stub", or "no cluster exists", read it as
> describing a **live gap** rather than an expected stage — most importantly, L2/L3
> conformance still does not run in CI (`vars.CLUSTER_AVAILABLE` is unset), which is a real
> coverage hole, not a phase.
>
> This mismatch is not cosmetic. A month of "the gate enforces this" claims rested on
> documentation rather than measurement, and `main` was unprotected with zero runners
> registered the whole time. **Query the live API before asserting platform state.**
Per **ADR-0011** and **docs/14 "Where each layer runs"**: L0/L1 run in CI without a
cluster, but **L2 behavioral and L3 fault-injection run as an in-cluster Chainsaw Job**
on the dev/test cluster that **CI triggers and waits for**. CI holds **no** cluster
+14
View File
@@ -0,0 +1,14 @@
# Reconciled by clusters/base/conformance.yaml (non-gating, wait:false).
#
# RBAC ONLY. chainsaw-job.yaml is deliberately NOT listed: that manifest is a TEMPLATE the CI
# trigger renders and submits per run (with the commit SHA and the touched --requirements). Flux
# reconciling it would create a permanent Job object and fight the trigger for ownership.
#
# The identities here are inert without
# platform/kyverno/admission/conformance-trigger-job-shape.yaml, which is what actually bounds
# the trigger to the committed run shape. RBAC is the necessary half, that policy the sufficient
# half — see the header of rbac.yaml.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- rbac.yaml
+65
View File
@@ -174,3 +174,68 @@ subjects:
- kind: ServiceAccount
name: conformance-runner
namespace: conformance
---
# =============================================================================
# THE TRIGGER IDENTITY — deliberately NOT conformance-runner.
#
# THE ESCALATION THIS EXISTS TO CLOSE (docs/plans/l2-l3-conformance-ci-wiring.md §4):
# Kubernetes places NO `use` check on a Pod/Job's `serviceAccountName`. So any principal
# that can `create jobs` in this namespace can create a Job with
# `serviceAccountName: conformance-runner` and an ARBITRARY command — i.e. run arbitrary
# code with the runner's privileges (nodes/patch, pods/exec, namespaces/patch, jobs).
# A CI-held token with plain `create jobs` therefore IS the privileged identity, no matter
# how narrow its own RBAC looks. That would break R-SEC-02 ("CI holds no broad cluster
# access") while appearing to satisfy it.
#
# RBAC ALONE CANNOT FIX THIS. The real boundary is the admission policy
# platform/kyverno/admission/conformance-trigger-job-shape.yaml, which admits Jobs from
# THIS ServiceAccount only when they match the committed conformance shape. This Role is
# the necessary half; that policy is the sufficient half. Neither works alone — do not
# grant this Role without that policy reconciled and ENFORCING.
#
# Namespaced Role, bound only in `conformance`. No ClusterRole, deliberately.
# =============================================================================
apiVersion: v1
kind: ServiceAccount
metadata:
name: conformance-trigger
namespace: conformance
labels:
app.kubernetes.io/part-of: conformance
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: conformance-trigger
namespace: conformance
labels:
app.kubernetes.io/part-of: conformance
rules:
# Create the run, and watch it to completion.
- apiGroups: [batch]
resources: [jobs]
verbs: [create, get, list, watch]
# Read the Job's pod and its log — this is how results come back. Deliberately NOT
# pods/exec and NOT `kubectl cp`: the run tars its artifacts to stdout and CI decodes
# them from the log, so no execution channel into the runner pod is ever granted.
- apiGroups: [""]
resources: [pods, pods/log]
verbs: [get, list, watch]
# NOTHING ELSE. In particular NO delete on jobs (a trigger that can delete can hide a
# failed run), no secrets, no configmaps, no exec, no create on pods.
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: conformance-trigger
namespace: conformance
labels:
app.kubernetes.io/part-of: conformance
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: conformance-trigger
subjects:
- kind: ServiceAccount
name: conformance-trigger
namespace: conformance
@@ -0,0 +1,6 @@
[run.sh] Coverage report first (always)
[coverage] 51/51 requirements have a scaffolded test (100.0%); 0 gap(s), 0 safety-critical gap(s).
[coverage] wrote /home/keith/source/home-network-k3s-impl/conformance/_artifacts/coverage/coverage.md and /home/keith/source/home-network-k3s-impl/conformance/_artifacts/coverage/coverage.json
[run.sh] No L0/L1 offline checks match the selection (layer=l3 reqs='R-STATE-02').
ERROR: cluster reachable but 'chainsaw' (v0.2.12) not on PATH.
Install the pinned binary or run via the in-cluster Job (incluster/).
@@ -0,0 +1,4 @@
<testsuites name="chainsaw-report" tests="1" failures="0" errors="0" time="0.000000"><testsuite name="/work/conformance/tests/l3-fault-injection/node-loss" tests="1" failures="0" errors="0" id="0" time="">
<testcase name="node-loss" classname="" time="115.855688" />
</testsuite>
</testsuites>
@@ -0,0 +1,232 @@
[run.sh] Coverage report first (always)
[coverage] 51/51 requirements have a scaffolded test (100.0%); 0 gap(s), 0 safety-critical gap(s).
[coverage] wrote /work/conformance/_artifacts/coverage/coverage.md and /work/conformance/_artifacts/coverage/coverage.json
[run.sh] No L0/L1 offline checks match the selection (layer=l3 reqs='R-STATE-02').
[run.sh] LIGHT pass: no tests selected.
[run.sh] HEAVY pass: 1 test(s) SERIAL (parallel=1) — node-loss
Version: 0.2.12
Loading config (/work/conformance/config/chainsaw-config.yaml)...
- Using test file: chainsaw-test
- TestDirs [/work/conformance/tests/l3-fault-injection/node-loss]
- SkipDelete false
- FailFast false
- ReportFormat 'JUNIT-TEST'
- ReportName 'chainsaw-report-heavy'
- ReportPath '/work/conformance/_artifacts'
- Namespace ''
- FullName false
- IncludeTestRegex ''
- ExcludeTestRegex ''
- ApplyTimeout 1m30s
- AssertTimeout 3m0s
- CleanupTimeout 2m0s
- DeleteTimeout 2m0s
- ErrorTimeout 1m0s
- ExecTimeout 3m0s
- DeletionPropagationPolicy Background
- Parallel 1
- Template true
- NoCluster false
- PauseOnFailure false
Loading tests...
- node-loss (/work/conformance/tests/l3-fault-injection/node-loss)
Loading values...
Running tests...
=== RUN chainsaw
=== PAUSE chainsaw
=== CONT chainsaw
=== RUN chainsaw/node-loss
=== PAUSE chainsaw/node-loss
=== CONT chainsaw/node-loss
l.go:52: | 05:22:26 | node-loss | @chainsaw  | CREATE | OK | v1/Namespace @ chainsaw-advanced-boxer
l.go:52: | 05:22:26 | node-loss | volume-stays-available-across-node-loss | TRY | BEGIN |
l.go:52: | 05:22:26 | node-loss | volume-stays-available-across-node-loss | CMD | RUN |
=== COMMAND
/usr/bin/sh -c set -euo pipefail
NS="$NAMESPACE"
# -- Gate 1: skip cleanly (green) if the longhorn tier is not present yet. --
if ! kubectl get storageclass longhorn >/dev/null 2>&1; then
echo "SKIP: 'longhorn' StorageClass absent — node-loss not applicable on this cluster yet"
echo NODE_LOSS_SKIPPED_OK
exit 0
fi
# -- Gate 2: a 3-replica volume + single-node-loss headroom needs >= 3 Ready nodes. --
READY="$(kubectl get nodes \
-o jsonpath='{range .items[*]}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}' \
| grep -c '^True$' || true)"
echo "ready nodes: ${READY}"
if [ "${READY:-0}" -lt 3 ]; then
echo "SKIP: only ${READY:-0} Ready node(s) (<3) — node-loss resilience not meaningful yet"
echo NODE_LOSS_SKIPPED_OK
exit 0
fi
CANARY="node-loss-canary-$(date +%s%N)"
# -- GIVEN: a Longhorn PVC (platform default SC = 3 replicas across nodes) + a
# writer pod that writes the canary and flushes it to the replicated volume. --
kubectl -n "$NS" apply -f - <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: survivor
spec:
accessModes: [ReadWriteOnce]
storageClassName: longhorn
resources:
requests:
storage: 1Gi
EOF
apply_pod() {
kubectl -n "$NS" apply -f - <<EOF
apiVersion: v1
kind: Pod
metadata:
name: $1
spec:
restartPolicy: Never
containers:
- name: app
image: busybox:1.36.1
command: ["sh", "-c", "sleep 3600"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: survivor
EOF
}
apply_pod writer
kubectl -n "$NS" wait --for=condition=Ready pod/writer --timeout=240s
printf '%s' "$CANARY" | kubectl -n "$NS" exec -i writer -- sh -c 'cat > /data/canary && sync'
echo "wrote canary: $(kubectl -n "$NS" exec writer -- cat /data/canary)"
# The victim node is the one the attached volume currently lives on.
VICTIM="$(kubectl -n "$NS" get pod writer -o jsonpath='{.spec.nodeName}')"
[ -n "$VICTIM" ] || { echo "FAIL: could not determine writer's node" >&2; exit 1; }
echo "victim node (currently hosts the attached volume): $VICTIM"
# Best-effort precondition log: if the Longhorn Volume CR is readable, show the
# volume is genuinely 3-replica/healthy before we take a node. Non-fatal (the
# runner is least-privilege and may not have longhorn.io read rights) — the
# data-availability assertion below is the authoritative proof, not this.
PVNAME="$(kubectl -n "$NS" get pvc survivor -o jsonpath='{.spec.volumeName}' 2>/dev/null || true)"
if [ -n "$PVNAME" ]; then
kubectl -n longhorn-system get volumes.longhorn.io "$PVNAME" \
-o jsonpath='pre-fault robustness={.status.robustness} replicas={.spec.numberOfReplicas}{"\n"}' \
2>/dev/null || echo "(Longhorn Volume CR not readable — continuing; data read is the proof)"
fi
# -- WHEN: FAULT INJECTION — remove the victim node FROM SERVICE. --
# Label it with THIS test's unique namespace so `finally` can always find and
# uncordon exactly this node; then cordon it; then evict ONLY our own writer
# pod, forcing the volume to detach from the victim. (No `drain` — unrelated
# pods on the node are never touched.)
kubectl label node "$VICTIM" "conformance.home.k3s/cordoned-by=$NS" --overwrite
kubectl cordon "$VICTIM"
echo "cordoned + labelled victim node: $VICTIM"
kubectl -n "$NS" delete pod writer --wait=true --timeout=240s
echo "evicted writer off the victim node; volume detached from $VICTIM"
# -- THEN: a fresh workload re-binds the SAME volume. The victim is unschedulable,
# so the scheduler MUST place the reader on a SURVIVING node; Longhorn re-attaches
# the volume there from its replicas on the other nodes. --
apply_pod reader
kubectl -n "$NS" wait --for=condition=Ready pod/reader --timeout=360s
READER_NODE="$(kubectl -n "$NS" get pod reader -o jsonpath='{.spec.nodeName}')"
echo "reader scheduled on surviving node: $READER_NODE (victim was $VICTIM)"
if [ "$READER_NODE" = "$VICTIM" ]; then
echo "FAIL: reader landed back on the cordoned victim node — node was not removed from service" >&2
exit 1
fi
GOT="$(kubectl -n "$NS" exec reader -- cat /data/canary)"
echo "recovered='$GOT' expected='$CANARY'"
[ "$GOT" = "$CANARY" ] || { echo "FAIL: data not available after node loss" >&2; exit 1; }
echo "volume available + data intact from a surviving node"
# Best-effort reconvergence log: if the Longhorn Volume CR is readable, wait
# (bounded) for robustness to settle back to available (healthy|degraded — both
# serve I/O; a healthy 3-replica volume rebuilds a replica once headroom exists).
# Non-fatal for the same least-privilege reason as the pre-fault log.
if [ -n "$PVNAME" ]; then
for i in $(seq 1 30); do
ROB="$(kubectl -n longhorn-system get volumes.longhorn.io "$PVNAME" \
-o jsonpath='{.status.robustness}' 2>/dev/null || true)"
[ -z "$ROB" ] && { echo "(Longhorn Volume CR not readable — skipping reconvergence log)"; break; }
echo "post-fault robustness[$i]=$ROB"
case "$ROB" in healthy|degraded) break ;; esac
sleep 10
done
fi
echo NODE_LOSS_VOLUME_AVAILABLE_OK
l.go:52: | 05:23:37 | node-loss | volume-stays-available-across-node-loss | SCRIPT | LOG |
=== STDOUT
ready nodes: 3
persistentvolumeclaim/survivor created
pod/writer created
pod/writer condition met
wrote canary: node-loss-canary-1789795346371286043
victim node (currently hosts the attached volume): k3s-prod-2
pre-fault robustness=healthy replicas=3
node/k3s-prod-2 labeled
node/k3s-prod-2 cordoned
cordoned + labelled victim node: k3s-prod-2
pod "writer" deleted from chainsaw-advanced-boxer namespace
evicted writer off the victim node; volume detached from k3s-prod-2
pod/reader created
pod/reader condition met
reader scheduled on surviving node: k3s-prod-3 (victim was k3s-prod-2)
recovered='node-loss-canary-1789795346371286043' expected='node-loss-canary-1789795346371286043'
volume available + data intact from a surviving node
post-fault robustness[1]=healthy
NODE_LOSS_VOLUME_AVAILABLE_OK
l.go:52: | 05:23:37 | node-loss | volume-stays-available-across-node-loss | SCRIPT | DONE |
l.go:52: | 05:23:37 | node-loss | volume-stays-available-across-node-loss | TRY | END |
l.go:52: | 05:23:37 | node-loss | volume-stays-available-across-node-loss | FINALLY | BEGIN |
l.go:52: | 05:23:37 | node-loss | volume-stays-available-across-node-loss | CMD | RUN |
=== COMMAND
/usr/bin/sh -c set -uo pipefail
for n in $(kubectl get nodes -l "conformance.home.k3s/cordoned-by=$NAMESPACE" -o name 2>/dev/null); do
kubectl uncordon "${n#node/}" || true
kubectl label "$n" "conformance.home.k3s/cordoned-by-" || true
echo "restored node to service: ${n#node/}"
done
l.go:52: | 05:23:37 | node-loss | volume-stays-available-across-node-loss | SCRIPT | LOG |
=== STDOUT
node/k3s-prod-2 uncordoned
node/k3s-prod-2 unlabeled
restored node to service: k3s-prod-2
l.go:52: | 05:23:37 | node-loss | volume-stays-available-across-node-loss | SCRIPT | DONE |
l.go:52: | 05:23:37 | node-loss | volume-stays-available-across-node-loss | CMD | RUN |
=== COMMAND
/usr/bin/sh -c kubectl -n "$NAMESPACE" delete pod writer reader --ignore-not-found --wait=false || true
kubectl -n "$NAMESPACE" delete pvc survivor --ignore-not-found --wait=false || true
echo "cleanup best-effort; chainsaw namespace teardown finalizes"
l.go:52: | 05:23:38 | node-loss | volume-stays-available-across-node-loss | SCRIPT | LOG |
=== STDOUT
pod "reader" deleted from chainsaw-advanced-boxer namespace
persistentvolumeclaim "survivor" deleted from chainsaw-advanced-boxer namespace
cleanup best-effort; chainsaw namespace teardown finalizes
l.go:52: | 05:23:38 | node-loss | volume-stays-available-across-node-loss | SCRIPT | DONE |
l.go:52: | 05:23:38 | node-loss | volume-stays-available-across-node-loss | FINALLY | END |
l.go:52: | 05:23:38 | node-loss | @chainsaw  | CLEANUP | BEGIN |
l.go:52: | 05:23:38 | node-loss | @chainsaw  | DELETE | OK | v1/Namespace @ chainsaw-advanced-boxer
l.go:52: | 05:24:21 | node-loss | @chainsaw  | CLEANUP | END |
--- PASS: chainsaw (0.00s)
--- PASS: chainsaw/node-loss (115.86s)
PASS
Tests Summary...
- Passed tests 1
- Failed tests 0
- Skipped tests 0
Done.
merge_junit: merged 1 report(s) -> /work/conformance/_artifacts/report.junit.xml (tests=1 failures=0 errors=0 skipped=0)
[run.sh] Done. JUnit: /work/conformance/_artifacts/report.junit.xml | Coverage: /work/conformance/_artifacts/coverage/coverage.md
+1
View File
@@ -5,3 +5,4 @@
{"chainsaw_version": "v0.2.12", "env": "prod", "git_sha": "9d0814ae61e6a0ba64d3789a71f1cd9c985efbf0", "junit_result": "passed", "kube_version": "v1.36.4+k3s1", "layer": "L3", "note": "run log OUTPUT carries every declared pass token (NS_PRIVILEGED_OK, TUNNEL_HEALTHY_OK, NO_LEAK_OK, INTERNET_UNAFFECTED_OK, TUNNEL_SELF_HEAL_OK, TUNNEL_RECOVERED_OK) and no skip token [log scoped to this testcase; tokens read from real-output sections only]", "proof": "proven", "requirements": ["R-NET-06"], "run_id": "20260913T014923Z-9d0814a", "sentinel": "TUNNEL_RECOVERED_OK", "test": "vpn-killswitch", "ts_utc": "2026-09-13T01:49:23Z"}
{"chainsaw_version": "v0.2.12", "env": "prod", "git_sha": "24b8111d0c39e9417e9f4d782f333f683540cfa7", "junit_result": "passed", "kube_version": "v1.36.4+k3s1", "layer": "L3", "note": "run log OUTPUT carries every declared pass token (OFF_CLUSTER_TARGET_OK, BACKUPS_ARE_SCHEDULED_OK, DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK) and no skip token [log scoped to this testcase; tokens read from real-output sections only]", "proof": "proven", "requirements": ["R-STATE-03"], "run_id": "20260914T021505Z-24b8111", "sentinel": "DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK", "test": "dr-restore", "ts_utc": "2026-09-14T02:15:05Z"}
{"chainsaw_version": "v0.2.12", "env": "prod", "git_sha": "bd17b65db9fa906f336528e90b429ec1e2377963", "junit_result": "passed", "kube_version": "v1.36.4+k3s1", "layer": "L0", "note": "L0 offline check exited 0; run_l1_offline.py renders its skips as JUnit <skipped/>, so a green JUnit is truthful without a sentinel", "proof": "proven", "requirements": ["R-ENV-02", "R-STATE-03"], "run_id": "20260914T021902Z-bd17b65", "sentinel": null, "test": "placeholder-guard-coupling", "ts_utc": "2026-09-14T02:19:02Z"}
{"chainsaw_version": "v0.2.12", "env": "prod", "git_sha": "9339d5a79e2301d399e4e20722b164583c53c55d", "junit_result": "passed", "kube_version": "v1.36.4+k3s1", "layer": "L3", "note": "run log OUTPUT carries every declared pass token (NODE_LOSS_VOLUME_AVAILABLE_OK) and no skip token [log scoped to this testcase; tokens read from real-output sections only]", "proof": "proven", "requirements": ["R-STATE-02"], "run_id": "20260919T052225Z-9339d5a", "sentinel": "NODE_LOSS_VOLUME_AVAILABLE_OK", "test": "node-loss", "ts_utc": "2026-09-19T05:22:25Z"}
@@ -256,7 +256,13 @@ SCRAPE_OWNERS: list[dict] = [
"file": KPS,
"require": [
r"metricLabelsAllowlist:",
r"nodes=\[homelab\.keithjackson\.dev/egress-gateway\]",
# The label must be IN the allowlist, not BE the whole allowlist. This pattern used to
# end in a literal `]`, asserting the list held exactly this one label — so adding a
# second one (`egress-uplink`, for the steering-staleness alerts) made this check FAIL
# while the property it guards stayed perfectly satisfied. A guard that breaks when you
# correctly extend the thing it guards teaches people to edit the guard, which is how it
# stops guarding. `[,\\]]` accepts either the end of the list or a further entry.
r"nodes=\[homelab\.keithjackson\.dev/egress-gateway[,\]]",
],
"forbid": [],
"why": "KSM exposes NO custom labels by default. Without the allowlist entry "
@@ -448,9 +454,14 @@ def is_golden_fixture(p: Path) -> bool:
return "conformance" in parts and "golden" in parts
# NESTED WORKTREES: skip dot-DIRECTORIES (.git, .claude) while still matching dot-FILES such
# as .sops.yaml. .claude/worktrees/agent-*/ holds full repo copies for isolated agent runs, so
# a bare rglob from ROOT audited up to 18 duplicates of every file — which both invents FAILs
# and, worse, can satisfy an "something declares X" check from a copy rather than real source.
# Same fix as egress-paths-selected-by-rules. .claude/ is gitignored (.gitignore:43).
rule_files = sorted(
p for p in ROOT.rglob("*.yaml")
if ".git" not in p.parts and not is_golden_fixture(p) and "kind: PrometheusRule" in read(p)
if not any(part.startswith(".") for part in p.relative_to(ROOT).parent.parts) and not is_golden_fixture(p) and "kind: PrometheusRule" in read(p)
)
check(len(rule_files) > 0,
"no file declaring `kind: PrometheusRule` found — the parser or the repo layout changed; "
@@ -619,7 +630,7 @@ def yaml_docs(path: Path) -> list[dict]:
# job name -> (relpath, monitor endpoint ports, ports the co-authored Services define)
same_file_monitors: dict[str, tuple[str, set[str], set[str]]] = {}
for p in sorted(ROOT.rglob("*.yaml")):
if ".git" in p.parts:
if any(part.startswith(".") for part in p.relative_to(ROOT).parent.parts):
continue
txt = read(p)
if "kind: ServiceMonitor" not in txt or "kind: Service" not in txt:
@@ -179,6 +179,10 @@ UNTESTED_FILES = {
# and NodeIOPressureCritical quiet on the same measured state — the whole point of that fixture is
# that the two PSI detectors and the eight cause rules must agree about what "healthy" is, so the
# detectors are no longer unasserted.
# It SHRANK BY ONE on 2026-09-19: platform/monitoring/tests/vpn-egress.test.yaml pins the two new
# egress-steering rules, and asserts VPNTunnelDown QUIET in all four fixtures — that silence IS the
# claim those rules exist for (tun0 up and passing traffic while vpn-class egress is dropped), so it
# had to become an assertion rather than a sentence in a comment.
UNTESTED_ALERTS = {
("platform/cnpg/alerts.yaml", "CNPGWALArchiveBacklog"),
("platform/cnpg/alerts.yaml", "CNPGNoRecoverabilityPoint"),
@@ -195,7 +199,6 @@ UNTESTED_ALERTS = {
("platform/monitoring/alerts.yaml", "CertificateExpiringSoon"),
("platform/monitoring/alerts.yaml", "CertificateExpiryImminent"),
("platform/monitoring/alerts.yaml", "CertificateNotReady"),
("platform/monitoring/alerts.yaml", "VPNTunnelDown"),
("platform/monitoring/alerts.yaml", "HubbleDeniedFlowSpike"),
("platform/monitoring/alerts.yaml", "KeycloakDown"),
("platform/monitoring/alerts.yaml", "KeycloakDatabaseDown"),
@@ -280,11 +283,16 @@ FETCH_RE = re.compile(
# versions.env. What is forbidden is a RULE-UNIT RUN acquiring its own engine: then the
# version, the network and the machine are decided silently at test time, and a missing
# toolchain becomes a green run instead of an exit 3.
# NESTED WORKTREES: skip dot-DIRECTORIES (.git, .claude) while still matching dot-FILES such
# as .sops.yaml. .claude/worktrees/agent-*/ holds full repo copies for isolated agent runs, so
# a bare rglob from ROOT audited up to 18 duplicates of every file — which both invents FAILs
# and, worse, can satisfy an "something declares X" check from a copy rather than real source.
# Same fix as egress-paths-selected-by-rules. .claude/ is gitignored (.gitignore:43).
SELF_DIR = "conformance/tests/l0-static/alert-rules-unit-tested/"
INSTALLERS = (".gitea/workflows/scripts/install-tools.sh", "infra/devcontainer/")
for p in sorted(ROOT.rglob("*.sh")):
rel = str(p.relative_to(ROOT))
if rel.startswith((".git/", "conformance/runs/", SELF_DIR)):
if rel.startswith((".git/", ".claude/", "conformance/runs/", SELF_DIR)):
continue
try:
text = p.read_text()
@@ -319,7 +327,7 @@ def tracked_yaml() -> list[Path]:
return [ROOT / p for p in out.stdout.split("\0") if p]
# No usable index (the gate container mounts a worktree whose .git points outside the
# mount). Fall back to a filesystem walk rather than auditing nothing.
return sorted(p for p in ROOT.rglob("*.y*ml") if ".git/" not in str(p))
return sorted(p for p in ROOT.rglob("*.y*ml") if not any(part.startswith(".") for part in p.relative_to(ROOT).parent.parts))
KIND_RE = re.compile(r"^\s*kind:\s*PrometheusRule\s*$", re.M)
@@ -473,7 +481,34 @@ for rel in sorted(alert_files):
f"{rel} is listed in UNTESTED_FILES but is now covered by {tests} — the register is "
f"shrink-only: delete the line so the next missing test is caught")
text = "".join((ROOT / t).read_text() for t in tests if (ROOT / t).is_file())
covered = [n for n in names if re.search(rf"\b{re.escape(n)}\b", text)]
# "COVERED" MUST MEAN ASSERTED, NOT MENTIONED (tightened 2026-09-19).
# This used to be a bare word match over the whole test file, so an alert named anywhere —
# including in a PROSE COMMENT — counted as tested. Hit for real while adding
# platform/monitoring/tests/vpn-egress.test.yaml: its header comment explains that VPNTunnelDown
# stays quiet during an egress-steering failure, and that sentence alone was enough to make this
# check declare VPNTunnelDown "asserted on" and demand its removal from UNTESTED_ALERTS. Deleting
# the register entry would then have recorded coverage that did not exist — the exact "looks fixed"
# outcome this check exists to prevent, produced BY the check.
# Now an alert counts only when a test file names it in an `alertname:` position, with comment
# lines stripped first so a commented-out assertion does not count either.
# MEASURED BLAST RADIUS BEFORE TIGHTENING: of 61 alerts across the PrometheusRule manifests, 32 are
# asserted in one of the two idioms below, 29 are not mentioned at all (already declared in the
# registers), and ZERO are prose-only — so this change failed nothing on the day it landed. That
# mattered because the registers are shrink-only: had any alert been prose-only, tightening would
# have needed a register entry it is not allowed to add. (The first measurement said 31/30/0 because
# it scanned only tests/*.test.yaml and missed the <manifest>.promtool-test.yaml companions.)
text_no_comments = "\n".join(
l for l in text.split("\n") if not l.lstrip().startswith("#")
)
# BOTH promtool idioms count, because both are real assertions:
# alert_rule_test: - alertname: CNPGPrimariesCoLocated
# promql_expr_test: - expr: 'ALERTS{alertname="CNPGPrimariesCoLocated"}'
# Matching only the first produced a FALSE NEGATIVE on CNPGPrimariesCoLocated, which
# platform/cnpg/alerts.promtool-test.yaml pins across FOUR scenarios entirely through the second
# form. Had that been believed, the "fix" would have been a redundant duplicate test for an alert
# already better covered than most.
covered = [n for n in names
if re.search(rf'(?:alertname:\s*|alertname="){re.escape(n)}\b', text_no_comments)]
covered_total += len(covered)
for n in names:
if n in covered:
@@ -126,9 +126,14 @@ def read(p: Path) -> str:
SCOPE_DECL_RE = r"^\s*conformance\.home\.k3s/scope:\s*[\"\']?whole-repo"
RUNBOOK_URL_RE = re.compile(r"""^\s*runbook_url:\s*["']?([^"'\s#]+)["']?\s*$""")
# NESTED WORKTREES: skip dot-DIRECTORIES (.git, .claude) while still matching dot-FILES such
# as .sops.yaml. .claude/worktrees/agent-*/ holds full repo copies for isolated agent runs, so
# a bare rglob from ROOT audited up to 18 duplicates of every file — which both invents FAILs
# and, worse, can satisfy an "something declares X" check from a copy rather than real source.
# Same fix as egress-paths-selected-by-rules. .claude/ is gitignored (.gitignore:43).
rule_files = sorted(
p for p in ROOT.rglob("*.yaml")
if ".git" not in p.parts and "kind: PrometheusRule" in read(p)
if not any(part.startswith(".") for part in p.relative_to(ROOT).parent.parts) and "kind: PrometheusRule" in read(p)
)
check(len(rule_files) > 0,
"no file declaring `kind: PrometheusRule` found — the parser or the repo layout changed; "
@@ -216,6 +216,20 @@ def is_literal_base(host: str) -> bool:
DELIVERY_ROOTS = ("apps", "platform", "platform-config", "platform-config-cdi",
"runbooks", "clusters", "bootstrap", "catalog")
# NARROW EXEMPTION to (0), for reconciled trees that deliberately serve NOTHING.
# `conformance/incluster` (the L2/L3 runner + CI-trigger identities, R-SEC-02) is reconciled
# by clusters/base/conformance.yaml but contains only a Namespace, ServiceAccounts, RBAC and
# an admission-bounded Job template. Adding "conformance" to DELIVERY_ROOTS instead would be
# wrong for the reason stated above: it would sweep in this harness's synthetic fixture
# routes and make (8) demand the operator's dashboard link them.
#
# THE EXEMPTION POLICES ITSELF. An unchecked allowlist is how (0) would rot into a fail-open —
# add a tree here, later put an HTTPRoute in it, and the forward direction goes quietly green
# on a hidden service. So each exempted tree is ASSERTED below to declare no route and no
# hostname-publishing Service. If that ever stops being true the check fails and the tree must
# become a real delivery root.
NON_SERVING_RECONCILED = ("conformance/incluster",)
def tracked_yaml() -> list[Path]:
out = subprocess.run(["git", "ls-files", "-z", "--", "*.yaml", "*.yml"],
@@ -262,12 +276,31 @@ flux_ks = [(p, d) for p, d in docs_all()
# a Kustomization declared elsewhere); they name no source and reconcile nothing, so requiring
# a path of them would fail on a file that is correct.
reconcilers = [(p, d) for p, d in flux_ks if (d.get("spec", {}) or {}).get("sourceRef")]
def _reconciled_path(d):
return (d.get("spec", {}) or {}).get("path", "").lstrip("./").rstrip("/")
unscanned = sorted({
f"{p.relative_to(ROOT)}:{(d.get('spec', {}) or {}).get('path', '') or '(no path)'}"
for p, d in reconcilers
if (d.get("spec", {}) or {}).get("path", "").lstrip("./").split("/")[0]
not in DELIVERY_ROOTS
if _reconciled_path(d).split("/")[0] not in DELIVERY_ROOTS
and _reconciled_path(d) not in NON_SERVING_RECONCILED
})
# The exemption's own guard: an exempted tree must actually serve nothing.
exempt_serving = sorted({
f"{p.relative_to(ROOT)} ({d.get('kind')})"
for p, d in docs_all()
if any(str(p.relative_to(ROOT)).startswith(t + "/") for t in NON_SERVING_RECONCILED)
and (d.get("kind") == "HTTPRoute"
or (d.get("kind") == "Service"
and any("hostname" in str(k).lower()
for k in ((d.get("metadata", {}) or {}).get("annotations", {}) or {}))))
})
check(not exempt_serving,
f"(0b) every NON_SERVING_RECONCILED tree really serves nothing — an exempted tree that "
f"gains a route would hide a service from this check entirely "
f"{exempt_serving if exempt_serving else ''}")
check(len(reconcilers) >= 10 and not unscanned,
f"(0) all {len(reconcilers)} Flux-reconciled path(s) lie inside a scanned root — a "
f"reconciled tree this check does not read is a service it cannot require a link for "
@@ -0,0 +1,136 @@
# =============================================================================
# L0 STATIC — no service declared `internal` can produce a PUBLIC DNS record
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
#
# THE DEFECT THIS CLOSES. docs/09 R-DNS-03 says `internal` services are unreachable from the
# public internet, that external names resolve to the PUBLIC IP, and that Pi-hole provides the
# internal split-horizon answers. schemas/renderer-contract.md §exposure says an `internal`
# service renders "no public DNS record". NOTHING ENFORCED EITHER SENTENCE. ExternalDNS
# (platform/externaldns/helmrelease.yaml, `sources: [gateway-httproute, service]`,
# `policy: sync`, provider cloudflare) considered EVERY HTTPRoute on the shared Gateway, so six
# `exposure: internal` services — jackett, pihole, qbittorrent, radarr, semaphore, sonarr — each
# published `<name>.home.keithjackson.dev` into the PUBLIC authoritative Cloudflare zone,
# answering with the Gateway VIP 192.168.2.20. Verified at the authority on 2026-09-14; the
# pre-state is committed at .standup-dns/dns-20260914-pre-externaldns-scope.txt.
#
# Three things were wrong at once, and none of them was visible from inside the cluster:
# * the internal subnet, the Gateway VIP and a complete service inventory were publicly
# enumerable by anyone who could guess or brute-force a label;
# * a public name answering with an RFC1918 address IS a DNS-rebind primitive, which is why the
# SOHO router at 192.168.2.1 strips those answers and LAN clients need per-client dnsmasq
# overrides to reach their own services;
# * the guarantee was only ever prose. `exposure` was the operator's one declaration of intent
# and it had NO effect on DNS whatsoever, so "internal" meant nothing an attacker had to
# respect.
#
# WHY THE FIX NEEDS A GUARD AND NOT JUST A COMMIT. The mechanism is an annotation on a rendered
# HTTPRoute. An annotation is exactly the kind of thing that survives review by looking harmless
# and then disappears in a refactor of `_route_meta`, a hand-edit of apps/, or a new service added
# by copying a public one. Nothing about its ABSENCE is loud: the service keeps working on the LAN,
# the Kustomization stays Ready, and the only symptom is a public record nobody is looking at. The
# 2026-09-13 ServiceMonitor incident in this same tree is the precedent — twelve monitors pointed
# at a port that did not exist for weeks, silently, because the only thing asserting the wiring was
# prose. This test makes the DNS guarantee a gate condition instead.
#
# GIVEN every ServiceDefinition in catalog/ and every HTTPRoute and external-dns-annotated
# Service this repo authors,
# WHEN each catalog service's rendered routes are resolved in the applied tree (apps/),
# THEN (1) every route of an `exposure: internal` service carries ExternalDNS's own controller
# annotation set to something OTHER than `dns-controller`, which external-dns v0.21.0
# uses to skip the source object outright (source/gateway.go ->
# annotations.IsControllerMismatch, checked before any hostname is resolved), so the
# route cannot become an endpoint for ANY name it declares;
# (2) NO route of an `exposure: public` service carries it — asserted explicitly, because a
# renderer that stamped every route would satisfy (1) perfectly while silently
# withdrawing the five public services' records, and `policy: sync` would delete them;
# (3) the same holds for the `service` source: an internal service's rendered Service may
# not carry an external-dns hostname annotation without the disown annotation beside it.
# Two sources are configured, so closing only the route half is half a guarantee;
# (4) the annotation is DERIVED FROM `exposure` in catalog/render.py and from nothing else —
# the emitting function must read `exposure`, must not name any catalog service, and
# must not be able to emit external-dns's own `dns-controller` value (the one value that
# CLAIMS a route rather than releasing it, inverting the guarantee while looking
# plausible). A per-service list is how twelve ServiceMonitors acquired one identical
# wrong port name in a single edit;
# (5) THE RENDERER IS PROVEN END-TO-END on synthetic ServiceDefinitions, by invoking the
# SHIPPED catalog/render.py as a subprocess: an `internal` fixture must yield the
# annotation, a `public` fixture must not, and a fixture that OMITS `exposure` must also
# yield it (the contract's default posture is `internal`, and omitting the field is the
# likeliest way a new service arrives). Without this the check would still pass if the
# renderer stopped working and someone hand-maintained apps/;
# (6) every HTTPRoute or annotated Service in the tree that the catalog does NOT render is
# DECLARED in KNOWN_NON_CATALOG_PUBLISHERS with what it publishes and why it is out of
# scope. Those are hand-authored platform/apps manifests with no `exposure` field for a
# renderer to read, and they are the seven names that REMAIN publicly advertised with an
# RFC1918 answer after this change. The register is shrink-only: an entry that stops
# matching a real object FAILS as stale, so the set of unadjudicated publishers cannot
# quietly grow, and adding a new one is a review event;
# AND (7) the check is LOAD-BEARING: this Test DECLARES `conformance.home.k3s/scope: whole-repo`,
# so ci/l0.sh Stage 3b's `--include-scope whole-repo` unions it into EVERY gate run, and
# ci/gate.sh blocks the merge when the l0 stage fails. It deliberately adds NO stage to
# ci/l0.sh — tests/l0-static/whole-repo-invariants-wired fails the gate if anyone does.
#
# WHY SCOPE, NOT REQUIREMENTS: ci/touched-requirements.sh's path map keys apps/* to NOTHING, and
# apps/ is exactly where the rendered routes live. A requirement-scoped selection would therefore
# filter this check out of the change most likely to break it — a hand-edit of a rendered route.
# Same reasoning as its whole-repo siblings.
#
# STATUS: EXECUTABLE. `check.sh` is an offline assertion script (PyYAML, as catalog/render.py and
# the sibling l0-static checks use); conformance/run.sh --layer l0 and ci/l0.sh run it directly,
# and this Chainsaw Test wraps the SAME script.
#
# NOT proven (honest gaps; this is static, and it runs with no cluster and no network):
# - That the record is actually GONE from Cloudflare. That is a live read against an external
# authority and belongs in L2 beside conformance/tests/l2-behavioral/dns-exposure, which owns
# the positive direction (a public name DOES appear). The negative direction was proven
# MANUALLY on prod against the deployed v0.21.0 before this landed — a throwaway HTTPRoute
# carrying the annotation produced no record at all, and adding the annotation to a route that
# already had one produced `action=DELETE` for both its A record and its `a-` TXT registry
# entry within one ~60s reconcile — but a manual proof is not a gate.
# - That an `internal` name is unreachable from the internet. No DNS record is necessary for
# reachability; a port-forward plus a hardcoded IP still reaches the Gateway. This check owns
# the NAME axis only. R-NET-04 / the edge port-forward posture owns the other.
# - Anything about the seven non-catalog publishers' own correctness. The register records WHAT
# each publishes so the claim is reviewable, which is all a static check can offer.
# - Anything about the five `public` services' TARGET. They still resolve to the Gateway VIP
# rather than a routable address; that is an open operator decision recorded in
# platform/externaldns/helmrelease.yaml, and asserting a target here would hard-code a guess.
# - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06).
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: internal-services-publish-no-public-dns
labels:
conformance.home.k3s/R-DNS-02: "true"
conformance.home.k3s/R-DNS-03: "true"
annotations:
conformance.home.k3s/requirements: "R-DNS-02, R-DNS-03"
conformance.home.k3s/layer: "L0"
# WHOLE-REPO SCOPE — this invariant quantifies over every ServiceDefinition in catalog/, every
# HTTPRoute in apps/wave-* and platform/* and runbooks/, and every external-dns-annotated
# Service (apps/vms/unifi, platform/crafty). No single requirement path-maps to that set, and
# ci/touched-requirements.sh maps apps/* — where the rendered routes live — to no requirement
# at all, so a requirement-scoped selection would skip this check on precisely the changes
# that can break it.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "true"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
A service the operator declared `internal` cannot produce a public DNS record: the
guarantee is derived from `exposure` by catalog/render.py, present on every rendered route
in the applied tree, absent from every public one, proven end-to-end on synthetic fixtures,
and the hand-authored manifests the catalog does not render are enumerated in a shrink-only
register so the unadjudicated set cannot grow silently.
spec:
steps:
- name: assert-no-internal-service-can-publish-a-public-dns-record
description: >-
Resolve every catalog service's rendered HTTPRoutes in apps/, assert the ExternalDNS
disown annotation on every internal one and on no public one, extend the same rule to the
`service` source, assert catalog/render.py derives the annotation from `exposure` rather
than from a service list, prove the shipped renderer on internal/public/default-posture
fixtures, and ratchet the register of hand-authored publishers the catalog does not render.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,458 @@
#!/usr/bin/env bash
# =============================================================================
# L0 STATIC CHECK — no service declared `internal` can produce a PUBLIC DNS record.
# See chainsaw-test.yaml's header for the full WHY. In one paragraph:
#
# docs/09 R-DNS-03 and schemas/renderer-contract.md §exposure both say an `internal` service
# renders no public DNS record. Nothing enforced it: ExternalDNS watched every HTTPRoute on the
# shared Gateway, so six internal services published `<name>.home.keithjackson.dev` into the
# public Cloudflare zone answering with the RFC1918 Gateway VIP (verified at the authority
# 2026-09-14; pre-state committed at .standup-dns/dns-20260914-pre-externaldns-scope.txt). The fix
# derives ExternalDNS's controller annotation from `exposure` in catalog/render.py; this check is
# what keeps it derived, present, and scoped.
#
# ASSERTIONS
# (0) the inventory is non-empty and the parse contract holds (anti-vacuous-pass);
# (1) every rendered route of an `internal` service carries the disown annotation;
# (2) NO rendered route of a `public` service carries it — a renderer stamping everything would
# satisfy (1) and silently delete the five public records under policy: sync;
# (3) the `service` source half: an internal service's Service may not carry an external-dns
# hostname annotation without the disown annotation beside it;
# (4) catalog/render.py DERIVES the annotation from `exposure` — the emitting function reads that
# field, names no catalog service, and cannot emit external-dns's own `dns-controller`;
# (5) the SHIPPED renderer is proven end-to-end on synthetic ServiceDefinitions (internal,
# public, and `exposure` OMITTED — the contract's default posture is internal);
# (6) every HTTPRoute / annotated Service the catalog does NOT render is declared in
# KNOWN_NON_CATALOG_PUBLISHERS — shrink-only, stale entries FAIL;
# (7) the check is LOAD-BEARING: whole-repo scope declared in chainsaw-test.yaml, honoured by
# ci/l0.sh's --include-scope union, and ci/gate.sh blocks on an l0 failure. NO hand-written
# ci/l0.sh stage (whole-repo-invariants-wired fails the gate if one appears).
#
# WHAT THIS DOES *NOT* PROVE (honest gaps — it is STATIC, offline, cluster-free):
# - That the record is gone from Cloudflare. Live read; belongs beside
# conformance/tests/l2-behavioral/dns-exposure, which owns the positive direction. The negative
# direction was proven manually on prod against the deployed v0.21.0 (a route carrying the
# annotation produced no record; adding it to a route that had one produced action=DELETE for
# both the A and its `a-` TXT within ~60s) — but a manual proof is not a gate.
# - That an internal name is UNREACHABLE. A port-forward plus a literal IP needs no DNS record.
# This check owns the NAME axis only.
# - Anything about the five public services' TARGET. They still answer with the Gateway VIP; the
# routable-target decision is open and recorded in platform/externaldns/helmrelease.yaml.
# - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06).
#
# Exit 0 = all assertions pass. Exit 1 = a failure. Exit 3 (honest SKIP, which the runner turns
# into rc 3 = UNWIRED, never green, because this test declares the whole-repo scope) only if
# catalog/ or apps/ is absent — i.e. the subject tree has vanished, which is not a pass.
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
PYTHON="${PYTHON:-python3}"
# Honest SKIP (never green for a scope-declaring test) if the subject trees are not there at all.
if [ ! -d "${ROOT}/catalog" ] || [ ! -d "${ROOT}/apps" ] || [ ! -f "${ROOT}/catalog/render.py" ]; then
echo "check SKIPPED: catalog/, apps/ or catalog/render.py absent — nothing to audit" >&2
exit 3
fi
cd "${ROOT}"
"${PYTHON}" - "${ROOT}" <<'PY'
"""Static audit: an `internal` ServiceDefinition cannot produce a public DNS record.
PyYAML (the same dependency as catalog/render.py and the sibling l0-static checks) rather than
line-based parsing: an HTTPRoute's annotations and a ServiceDefinition's exposure are nested
structures, and a line-based reader of them is precisely the approximate parser that silently
stops matching — the failure class this check exists to prevent.
"""
from __future__ import annotations
import re
import subprocess
import sys
import tempfile
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
file=sys.stderr)
raise SystemExit(1)
ROOT = Path(sys.argv[1])
sys.dont_write_bytecode = True # keep __pycache__ out of the tracked tree
FAILURES: list[str] = []
CHECKS = 0
# external-dns's own constants (v0.21.0 source/annotations/annotations.go):
# ControllerKey = annotationPrefix + "controller" -> the key below
# ControllerValue = "dns-controller" -> the value that CLAIMS an object
# source/annotations/processors.go IsControllerMismatch skips a source object when the key is
# PRESENT and the value is NOT ControllerValue. So "disowned" == key present, value != claimed.
CONTROLLER_KEY = "external-dns.alpha.kubernetes.io/controller"
CONTROLLER_CLAIMED = "dns-controller"
HOSTNAME_KEY = "external-dns.alpha.kubernetes.io/hostname"
def check(cond, msg):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(msg)
def read(p: Path) -> str:
try:
return p.read_text()
except (OSError, UnicodeDecodeError):
return ""
def docs_of(p: Path):
"""Every YAML document in a file, tolerating the non-manifest files in the tree."""
try:
return [d for d in yaml.safe_load_all(read(p)) if isinstance(d, dict)]
except yaml.YAMLError:
return []
# =============================================================================================
# REGISTER — KNOWN_NON_CATALOG_PUBLISHERS. Shrink-only.
#
# These are HAND-AUTHORED HTTPRoutes and Services that put a name into the same public Cloudflare
# zone but that catalog/render.py does not render, so there is no `exposure` field for the renderer
# to derive anything from and this check cannot adjudicate them. That is a real limit, not a pass —
# so each one is DECLARED, with the name it publishes and why it is out of this check's scope, and
# the register can only shrink. An entry naming an object that no longer exists FAILS as stale, so
# a deleted publisher cannot leave a lie behind; a NEW publisher fails until it is declared, so the
# unadjudicated set cannot grow without a review event. Same ratchet reasoning as
# KNOWN_EXTERNAL_MONITORS in servicemonitor-ports-resolve.
#
# EVERY ENTRY HERE IS A NAME THAT STILL ANSWERS WITH AN RFC1918 ADDRESS IN A PUBLIC ZONE. That is
# the honest state after this change, and it is deliberate: converting them is an operator
# decision per name (is grafana meant to be publicly resolvable at all?), not a renderer change,
# and a cluster-wide ExternalDNS opt-in filter — the available hardening, see
# platform/externaldns/helmrelease.yaml — would DELETE all of them at once the moment it is set.
#
# Keyed by "<relpath>:<namespace>/<name>".
# =============================================================================================
KNOWN_NON_CATALOG_PUBLISHERS: dict[str, str] = {
"platform/oauth2-proxy/httproute.yaml:oauth2-proxy/oauth2-proxy":
"publishes auth.${BASE_DOMAIN} -> the Gateway VIP. The oauth2-proxy login endpoint is a "
"platform component, not a catalog service; it must be reachable by every browser that "
"any forward-auth app redirects, so whether it is PUBLICLY resolvable is coupled to "
"whether the five public services are — an operator decision, not a renderer one.",
"platform/keycloak/httproute.yaml:keycloak/keycloak":
"publishes keycloak.${BASE_DOMAIN} -> the Gateway VIP, and it is the route that actually "
"OWNS that record (the TXT registry entry reads resource=httproute/keycloak/keycloak, not "
"the catalog-rendered keycloak-8080 route, because external-dns merges duplicate "
"hostnames and this one won). Keycloak is `exposure: public` in the catalog, so the name "
"is meant to be public either way; it is listed because the PUBLISHING object is the "
"hand-authored platform route, which carries no exposure field.",
"platform/monitoring/grafana-httproute.yaml:monitoring/grafana":
"publishes grafana.${BASE_DOMAIN} -> the Gateway VIP. An admin dashboard; almost "
"certainly should not be publicly resolvable, but it is not a catalog service and "
"withdrawing it is an operator call with the same LAN-resolution prerequisite as the six "
"internal services (Pi-hole must answer first).",
"platform/headlamp/httproute.yaml:headlamp/headlamp":
"publishes headlamp.${BASE_DOMAIN} -> the Gateway VIP. A Kubernetes web UI with native "
"Keycloak OIDC; same adjudication as grafana.",
"platform/homepage/httproute.yaml:homepage/homepage":
"publishes the APEX ${BASE_DOMAIN} itself -> the Gateway VIP, via the https-apex "
"listener. The apex is not a per-service name and cannot be withdrawn by an exposure "
"declaration.",
"runbooks/serving/httproute.yaml:runbooks/runbooks":
"declares NO hostnames and inherits the https-apex listener's ${BASE_DOMAIN}, so it "
"contributes to the apex record rather than a name of its own (the apex TXT registry "
"entry names the homepage route). Deliberately un-authenticated — see the manifest's own "
"header for why gating runbooks on Keycloak is a loop with no exit.",
"platform/crafty/httproute.yaml:crafty/crafty":
"publishes crafty.${BASE_DOMAIN} -> the Gateway VIP (the Minecraft admin UI, gated by an "
"oauth2-proxy sidecar). Platform component, no exposure field.",
"platform/crafty/service-l4.yaml:crafty/crafty-l4-tcp":
"the `service` SOURCE, not a route: publishes crafty-mc.${BASE_DOMAIN} -> "
"${CRAFTY_L4_VIP} (192.168.2.22) for the raw Minecraft TCP/UDP game ports the HTTPS "
"Gateway cannot express. A game server is the one case where a public name may be "
"genuinely wanted; the address it answers with is still RFC1918.",
"apps/vms/unifi/50-service-l4.yaml:unifi/unifi-l4-tcp":
"the `service` source: publishes unifi.${BASE_DOMAIN} -> ${UNIFI_L4_VIP} "
"(192.168.2.21) for the UniFi controller VM's inform/STUN/adoption ports. Hand-authored "
"under apps/vms (a VM, not a rendered container service); the catalog's only VM entry is "
"vm-demo, which declares no ingressRoutes and publishes nothing.",
}
# =============================================================================================
# (0) INVENTORY — the catalog, and every HTTPRoute / annotated Service in the tree.
# =============================================================================================
CATALOG = ROOT / "catalog"
sds: dict[str, dict] = {}
for p in sorted(CATALOG.glob("*.yaml")):
for d in docs_of(p):
if "name" in d and "source" in d:
sds[d["name"]] = d
check(len(sds) > 0, "inventory: catalog/ yielded no ServiceDefinition — parse contract broken")
# The default posture is `internal` (renderer-contract "Default posture"), so an ABSENT field is
# internal, never "unclassified". Getting this wrong in the check would be the same bug as in the
# renderer: a new service that omits exposure would go unaudited.
exposure_of = {n: (sd.get("exposure") or "internal") for n, sd in sds.items()}
# (namespace, routename) -> service, for the routes the renderer emits: one per ingressRoute,
# named "<service>-<port>" in namespace "<service>" (catalog/render.py _route_meta).
expected_routes: dict[tuple[str, str], str] = {}
for n, sd in sds.items():
for r in sd.get("ingressRoutes") or []:
expected_routes[(n, f"{n}-{r['port']}")] = n
internal_with_routes = sorted({s for (_, _), s in expected_routes.items()
if exposure_of[s] == "internal"})
public_with_routes = sorted({s for (_, _), s in expected_routes.items()
if exposure_of[s] == "public"})
check(len(internal_with_routes) > 0,
"inventory: no `internal` catalog service declares an ingressRoute — the subject set of "
"this check is empty, which means either the catalog changed shape or the parse is wrong")
check(len(public_with_routes) > 0,
"inventory: no `public` catalog service declares an ingressRoute — assertion (2) would be "
"vacuous, so the parse is presumed wrong rather than the repo")
SCAN_DIRS = ["apps", "platform", "runbooks"]
routes: list[tuple[str, dict]] = [] # (relpath, HTTPRoute doc)
dns_services: list[tuple[str, dict]] = [] # (relpath, Service doc carrying an external-dns ann)
for sub in SCAN_DIRS:
base = ROOT / sub
if not base.is_dir():
continue
for p in sorted(base.rglob("*.yaml")):
rel = p.relative_to(ROOT).as_posix()
for d in docs_of(p):
kind = d.get("kind")
md = d.get("metadata") or {}
ann = md.get("annotations") or {}
if kind == "HTTPRoute":
routes.append((rel, d))
elif kind == "Service" and any(
a.startswith("external-dns.alpha.kubernetes.io/") for a in ann
):
dns_services.append((rel, d))
check(len(routes) > 0, "inventory: no HTTPRoute found under apps/ platform/ runbooks/ — parse "
"contract broken (the tree is known to author several)")
def ident(d: dict) -> tuple[str, str]:
md = d.get("metadata") or {}
return (md.get("namespace") or "", md.get("name") or "")
def annotations_of(d: dict) -> dict:
return (d.get("metadata") or {}).get("annotations") or {}
def is_disowned(d: dict) -> bool:
"""True iff external-dns v0.21.0 would SKIP this object: the controller annotation is present
and its value is not external-dns's own. Absent == published (the default), which is the whole
point — the annotation is the only thing standing between a route and the public zone."""
v = annotations_of(d).get(CONTROLLER_KEY)
return v is not None and v != CONTROLLER_CLAIMED
# =============================================================================================
# (1) + (2) every internal service's rendered route is disowned; no public one is.
# =============================================================================================
by_ident: dict[tuple[str, str], list[tuple[str, dict]]] = {}
for rel, d in routes:
by_ident.setdefault(ident(d), []).append((rel, d))
for (ns, rname), svc in sorted(expected_routes.items()):
found = by_ident.get((ns, rname), [])
check(len(found) >= 1,
f"({svc}) the catalog declares an ingressRoute rendering HTTPRoute {ns}/{rname}, but no "
f"such HTTPRoute exists in the applied tree — apps/ is out of sync with catalog/ "
f"(ci/render-golden.sh owns the byte-level half of this)")
for rel, d in found:
if exposure_of[svc] == "internal":
v = annotations_of(d).get(CONTROLLER_KEY)
check(
v is not None,
f"({svc}) R-DNS-03 VIOLATED: {rel} HTTPRoute {ns}/{rname} belongs to an "
f"`exposure: internal` service but carries NO `{CONTROLLER_KEY}` annotation, so "
f"external-dns publishes {ns}.<zone> into the PUBLIC Cloudflare zone answering "
f"with the RFC1918 Gateway VIP. An internal service must render no public record.",
)
check(
v != CONTROLLER_CLAIMED,
f"({svc}) {rel} HTTPRoute {ns}/{rname} sets `{CONTROLLER_KEY}: "
f"{CONTROLLER_CLAIMED}` — that is external-dns's OWN value, which CLAIMS the route "
f"rather than releasing it. It reads like the guard and is its exact inverse.",
)
else:
check(
CONTROLLER_KEY not in annotations_of(d),
f"({svc}) {rel} HTTPRoute {ns}/{rname} belongs to an `exposure: "
f"{exposure_of[svc]}` service yet carries `{CONTROLLER_KEY}`. Under "
f"`policy: sync` that DELETES its live public record — the five public services "
f"must keep publishing.",
)
# =============================================================================================
# (3) the `service` source half — an internal service's Service may not publish either.
# =============================================================================================
for rel, d in dns_services:
ns, name = ident(d)
svc = ns if ns in sds else None
if svc and exposure_of[svc] == "internal":
check(
is_disowned(d),
f"({svc}) {rel} Service {ns}/{name} carries an external-dns annotation "
f"({sorted(a for a in annotations_of(d) if a.startswith('external-dns'))}) for an "
f"`exposure: internal` service without a disowning `{CONTROLLER_KEY}`. Both "
f"`gateway-httproute` AND `service` are configured sources, so closing only the route "
f"half is half a guarantee.",
)
# =============================================================================================
# (4) the GENERATOR derives it from `exposure` — not from a list of service names.
# =============================================================================================
render_py = ROOT / "catalog" / "render.py"
src = read(render_py)
check(bool(src), "catalog/render.py unreadable")
emitter = re.search(
r"def _externaldns_route_annotations\(.*?\n(?=\n?def |\n?# =====)", src, re.S
)
check(emitter is not None,
"catalog/render.py must define `_externaldns_route_annotations` — the single place the "
"ExternalDNS scoping is derived. If it was renamed, rename it here too: this check has to "
"read the DERIVATION, not just the output, or a hardcoded per-service list would pass.")
if emitter:
body = emitter.group(0)
# The derivation must read `exposure`. A body that does not mention it cannot be deriving
# anything from the operator's declaration.
check('"exposure"' in body or "'exposure'" in body,
"`_externaldns_route_annotations` must read the `exposure` field — the guarantee is "
"that the OPERATOR'S DECLARATION decides, so `exposure` has to appear in the decision")
# and it must not name any catalog service: a per-service list is how twelve ServiceMonitors
# acquired one identical wrong port name in a single edit. Only the executable statements are
# inspected — the docstring legitimately names the affected services as evidence.
code = re.sub(r'"""(?:.|\n)*?"""', "", body, count=1)
named = sorted(n for n in sds if re.search(rf"""['"]{re.escape(n)}['"]""", code))
check(not named,
f"`_externaldns_route_annotations` names catalog service(s) {named} in its CODE. The "
f"annotation must be derived from `exposure` alone; a per-service list silently omits "
f"the next service anyone adds.")
check(CONTROLLER_CLAIMED not in code,
f"`_externaldns_route_annotations` must never be able to emit `{CONTROLLER_CLAIMED}` — "
f"external-dns's own value CLAIMS the object instead of releasing it")
# =============================================================================================
# (5) THE SHIPPED RENDERER IS PROVEN on synthetic ServiceDefinitions. Three fixtures, because
# "internal is annotated" alone would also pass if the renderer annotated everything, and
# because the contract's DEFAULT posture is internal — the case a new service hits by
# omitting the field entirely, and the case a per-service list would miss.
# =============================================================================================
FIXTURES = [
("fixture-internal", {"exposure": "internal"}, True,
"an `internal` fixture must render the disown annotation"),
("fixture-public", {"exposure": "public"}, False,
"a `public` fixture must render NO disown annotation"),
("fixture-default", {}, True,
"a fixture OMITTING `exposure` defaults to internal (renderer-contract default posture) and "
"must also render the disown annotation"),
]
with tempfile.TemporaryDirectory() as td:
for fname, extra, want, msg in FIXTURES:
sd = {
"name": fname,
"kind": "container",
"source": {"image": "example/app:1.0.0"},
"egress": "internet",
"dependencies": [],
"ingressRoutes": [{"host": f"{fname}.home.keithjackson.dev", "port": 8080}],
# `native` avoids the oauth2-proxy sidecar path and the schema's groups requirement,
# keeping the fixture about DNS scoping and nothing else. A public service may not use
# auth.mode none (schema invariant since 1.1.0), so both fixtures use the same mode.
"auth": {"mode": "native"},
"update": {"channel": "stable", "cadence": "0 4 * * 0",
"autoRollbackOnFailedHealth": True},
"observability": {"metrics": True, "slis": ["up"]},
"backup": {"policy": "reacquirable"},
}
sd.update(extra)
f = Path(td) / f"{fname}.yaml"
f.write_text(yaml.safe_dump(sd, sort_keys=False))
proc = subprocess.run(
[sys.executable, str(render_py), str(f), "--no-image-automation"],
capture_output=True, text=True, cwd=str(ROOT),
)
if proc.returncode != 0:
check(False, f"fixture {fname}: the shipped renderer failed: "
f"{proc.stderr.strip()[:400]}")
continue
rendered = [d for d in yaml.safe_load_all(proc.stdout)
if isinstance(d, dict) and d.get("kind") == "HTTPRoute"]
check(len(rendered) == 1, f"fixture {fname}: expected exactly one rendered HTTPRoute")
if rendered:
got = is_disowned(rendered[0])
check(got is want, f"fixture {fname}: {msg} (rendered annotations: "
f"{annotations_of(rendered[0])})")
# =============================================================================================
# (6) REGISTER RATCHET — every publisher the catalog does not render is declared, and every
# declaration still names a real object.
# =============================================================================================
catalog_rendered = set(expected_routes)
seen_register_keys: set[str] = set()
for rel, d in routes + dns_services:
ns, name = ident(d)
if (ns, name) in catalog_rendered:
continue
key = f"{rel}:{ns}/{name}"
seen_register_keys.add(key)
check(
key in KNOWN_NON_CATALOG_PUBLISHERS,
f"UNDECLARED publisher {key}: a {d.get('kind')} the catalog does not render, so it has no "
f"`exposure` field this check can adjudicate, and it can put a name into the public "
f"Cloudflare zone. Add it to KNOWN_NON_CATALOG_PUBLISHERS with what it publishes and why "
f"it is out of scope — or render it from a ServiceDefinition instead.",
)
stale = sorted(set(KNOWN_NON_CATALOG_PUBLISHERS) - seen_register_keys)
check(not stale,
f"STALE register entries {stale}: KNOWN_NON_CATALOG_PUBLISHERS is shrink-only, and these "
f"name no object in the tree any more. Delete them — a register that keeps entries for "
f"deleted objects stops being evidence of anything.")
# =============================================================================================
# (7) LOAD-BEARING — the scope is DECLARED here (so ci/l0.sh Stage 3b's --include-scope union
# always selects this test) and NO stage is hand-written into ci/l0.sh (the
# whole-repo-invariants-wired meta-test fails the gate if one is).
# =============================================================================================
TEST_DIR = ROOT / "conformance/tests/l0-static/internal-services-publish-no-public-dns"
test_yaml = read(TEST_DIR / "chainsaw-test.yaml")
check(bool(test_yaml), "this test's own chainsaw-test.yaml is unreadable")
check(
re.search(r'^\s*conformance\.home\.k3s/scope:\s*"?whole-repo"?\s*$', test_yaml, re.M)
is not None,
"chainsaw-test.yaml must DECLARE `conformance.home.k3s/scope: \"whole-repo\"` on a real "
"mapping line — that declaration is the only thing that unions this check into every gate "
"run (ci/l0.sh Stage 3b). A scope named in prose is documentation, not a declaration.",
)
l0_sh = read(ROOT / "ci/l0.sh")
check(
"internal-services-publish-no-public-dns" not in l0_sh,
"ci/l0.sh must NOT name this test: a whole-repo invariant is selected by its DECLARED scope, "
"and the whole-repo-invariants-wired meta-test fails the gate on a hand-written stage.",
)
# =============================================================================================
if FAILURES:
for f in FAILURES:
print(f"check FAIL: {f}", file=sys.stderr)
print(f"check FAILED: {len(FAILURES)} of {CHECKS} assertions failed", file=sys.stderr)
raise SystemExit(1)
print(f"check OK: {CHECKS} assertions passed "
f"({len(sds)} ServiceDefinitions; {len(internal_with_routes)} internal + "
f"{len(public_with_routes)} public services with routes; {len(routes)} HTTPRoutes and "
f"{len(dns_services)} external-dns-annotated Services inventoried; "
f"{len(KNOWN_NON_CATALOG_PUBLISHERS)} declared non-catalog publishers)")
PY
@@ -0,0 +1,95 @@
# =============================================================================
# L0 STATIC — an L2-announced LoadBalancer VIP must be servable from ANY announcing node
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
#
# THE BUG CLASS THIS CLOSES, AND THE OUTAGE THAT PAID FOR IT.
# Cilium puts a LoadBalancer VIP on the LAN by electing ONE node per Service to answer ARP for
# it. That election is a leader lease over the nodes the CiliumL2AnnouncementPolicy's
# nodeSelector matches, and it is NOT backend-aware — Cilium does not restrict the candidates
# to nodes running a backend, and has no field that would. Combine that with
# `externalTrafficPolicy: Local`, which lets a node deliver only to a backend on ITSELF, and
# the VIP becomes a lottery: an elected announcer holding no backend answers ARP for the
# address and then has nowhere to send the packet, so the client gets ICMP port unreachable.
# On three nodes with one replica that is a 1-in-3 chance of working, drawn independently per
# Service — and it never recovers on its own, because the lease holder keeps renewing after the
# backend moves away.
#
# MEASURED on prod 2026-09-14: unifi-l4-tcp/-udp (VM on k3s-prod-1, both leases on k3s-prod-2)
# left 192.168.2.21:8443/:8080/:11443 dead from the LAN for up to 8 days, and crafty-l4-tcp
# (pod on k3s-prod-1, lease on k3s-prod-2) left the Minecraft Java port 192.168.2.22:25565
# dead — while its twin crafty-l4-udp, same pod, happened to draw k3s-prod-1 and worked. One
# Service broken and its identical twin working is the tell. The mechanism was then confirmed
# in both directions on the announcing node with `cilium-dbg service list`: under `Local` the
# frontend is ABSENT from that node's service table; under `Cluster` it appears with an active
# backend; flipped back, it vanishes again.
#
# WHY A REVIEWER CANNOT CATCH THIS. `Local` reads as the safe, source-IP-preserving choice, and
# both shipped manifests said so in a comment that also claimed it "pins the VIP to the node
# running the pod" — the precise thing that is false here. The defect is invisible in review
# and silent at apply time; it only appears as an outage, days later, after an unrelated
# reschedule moves a pod. apps/wave-0/pihole-dns.yaml hit it first and is the reference
# implementation; this check keeps the rest of the tree on that side of the line.
#
# GIVEN the repository as git tracks it,
# WHEN every tracked type=LoadBalancer Service is paired with the CiliumL2AnnouncementPolicy
# that would announce its VIP,
# THEN (1) no announced Service sets `externalTrafficPolicy: Local`, UNLESS every announcing
# node is guaranteed to hold a backend — true only for a DaemonSet-backed Service,
# and that exemption is computed from the tree rather than hand-maintained;
# (2) the premise is still live (some policy actually announces LoadBalancer VIPs), so
# the check cannot pass vacuously if L2 announcement is ever removed;
# (3) the announcer is still backend-BLIND — asserted structurally, so that if a future
# Cilium gains a backend-aware field the exemption logic is revisited deliberately
# instead of going stale in silence;
# AND (4) the check is NON-VACUOUS: a real checkout, a non-empty LoadBalancer corpus, and a
# floor on the assertion count.
#
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (standard library +
# PyYAML); conformance/run.sh --layer l0 runs it directly and this Chainsaw Test wraps the
# SAME script.
#
# NOT proven — the runtime half, which needs a cluster: that the VIP is actually reachable,
# that a backend exists, or that an application is listening behind it. That gap is not
# academic. On 2026-09-14 all four Services above ALSO had no listening backend at all (UniFi
# OS had never been installed in the VM; no Minecraft server had ever been created in Crafty),
# so fixing this configuration defect did NOT by itself make those ports answer. A static
# check cannot see an absent application; only an L2 behavioural probe can. Nor does this
# prove `Cluster` is costless — Cilium runs in SNAT mode here, so a cross-node connection
# reaches the backend bearing the ingress node's address, and whether that is acceptable is a
# per-service judgement recorded in each manifest.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: l2-announced-vips-serve-from-any-node
labels:
conformance.home.k3s/R-DNS-01: "true"
annotations:
conformance.home.k3s/requirements: "R-DNS-01"
conformance.home.k3s/layer: "L0"
# WHOLE-REPO SCOPE, and necessarily so. ci/touched-requirements.sh's path map has NO arm for
# `apps/*` and none for `platform/crafty/*`, and nothing in it maps to R-DNS-01 at all. So the
# change most likely to break this invariant — authoring or editing an L4 LoadBalancer Service
# under apps/ or platform/, exactly where both halves of the 2026-09-14 outage lived — would
# attribute NO requirement and be gated by nothing. The invariant also spans three trees at
# once (the Services under apps/ and platform/, and the announcement policy under
# platform-config/), which no single requirement tag can select.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
No LoadBalancer Service whose VIP is put on the LAN by Cilium L2 announcement may use
externalTrafficPolicy Local, because the announcing node is elected without regard to
backend placement — so an announcer holding no backend blackholes the VIP and never
self-heals. The sole exemption is a DaemonSet-backed Service, where every announcing
node is guaranteed a local backend.
spec:
steps:
- name: assert-no-l2-announced-vip-is-etp-local
description: >-
Pair every tracked type=LoadBalancer Service with the CiliumL2AnnouncementPolicy that
would announce its VIP, and assert none combines that announcement with
externalTrafficPolicy Local unless a DaemonSet guarantees a backend on every node.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,242 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — l2-announced-vips-serve-from-any-node
# Serves: R-DNS-01 ("Services MUST be reachable at `<service>.home.keithjackson.dev`."
# — spec docs/00-vision-and-scope.md, section "DNS & ingress").
#
# THE BUG CLASS THIS CLOSES — it is not hypothetical, it cost an 8-day outage.
# -----------------------------------------------------------------------------
# A type=LoadBalancer Service on this cluster gets its VIP from Cilium LB-IPAM and is put on
# the LAN by a CiliumL2AnnouncementPolicy, which elects ONE node per Service to answer ARP for
# that VIP. The election is a plain leader lease among the nodes the policy's nodeSelector
# matches. IT IS NOT BACKEND-AWARE: Cilium does not restrict the candidate set to nodes that
# actually run a backend, and it has no field that would let it.
#
# With `externalTrafficPolicy: Local`, a node may only deliver to a backend on ITSELF. Compose
# the two and the VIP is a lottery: if the elected announcer holds no backend, it answers ARP
# for the address and then has nowhere to send the packet, so the client gets ICMP port
# unreachable. On three nodes with one replica that is a 1-in-3 chance of working, drawn
# independently PER SERVICE — and it does not self-heal, because the lease holder keeps
# renewing after the backend moves away. Deleting the Lease only redraws the lottery.
#
# MEASURED on prod 2026-09-14, which is why this check exists:
# * unifi-l4-tcp / unifi-l4-udp — VM on k3s-prod-1, both leases on k3s-prod-2. 192.168.2.21
# :8443, :8080 and :11443 dead from the LAN for up to 8 days (the leases were 8 days old).
# * crafty-l4-tcp — pod on k3s-prod-1, lease on k3s-prod-2: 192.168.2.22:25565 (Minecraft
# Java) dead. crafty-l4-udp — same pod, lease happened to land on k3s-prod-1, so Bedrock
# worked. One Service broken and its twin working, from the same pod, is the whole tell.
# * The causal mechanism was confirmed both directions on the announcing node with
# `cilium-dbg service list`: under `Local` the frontend 192.168.2.22:25565 is ABSENT from
# that node's service table entirely; flipped to `Cluster` it appears as
# `1 => 10.0.0.11:25565 (active)`. Flipped back, it vanishes again.
#
# `Cluster` is the fix: every node can serve the VIP by forwarding to wherever the backend is,
# so the announcement and the backend no longer have to coincide. apps/wave-0/pihole-dns.yaml
# is the reference implementation (and carries the cost analysis); this check keeps the whole
# tree on that side of the line, because the defect is INVISIBLE in review — `Local` reads as
# the safe, source-IP-preserving choice, and the manifests that shipped it said so explicitly.
#
# GIVEN the repository as git tracks it,
# WHEN every tracked type=LoadBalancer Service is paired with the CiliumL2AnnouncementPolicy
# that would announce its VIP,
# THEN (1) no announced Service sets `externalTrafficPolicy: Local`, UNLESS every announcing
# node is guaranteed to hold a backend — which is true only for a DaemonSet-backed
# Service, and that exemption is computed from the tree, not hand-maintained;
# (2) the premise is still live: at least one CiliumL2AnnouncementPolicy actually
# announces LoadBalancer VIPs (`loadBalancerIPs: true`). If that ever becomes false
# this check is vacuous and must be re-derived rather than silently passing;
# (3) the announcer's candidate set really is backend-blind — asserted structurally, by
# requiring that the policy carries no field pinning it to backend-holding nodes;
# AND (4) the check is NON-VACUOUS: a real checkout is present, the LoadBalancer corpus is
# non-empty, and a floor on both the corpus and the assertion count keeps a pass
# from degenerating into a no-op when a refactor moves these files.
#
# WHY THE EXEMPTION IS NARROW, AND WHY IT IS SAFE. `Local` is correct precisely when the
# announcing node always has a local backend. Under `default-l2`'s nodeSelector
# (kubernetes.io/os=linux, i.e. every node) that holds only for a DaemonSet. A Deployment with
# replicas == node count does NOT qualify: nothing pins one replica per node, a rollout or a
# cordon breaks it, and the failure mode is this same silent outage. So the exemption keys on
# DaemonSet backing only.
#
# WHAT THIS DOES NOT PROVE (honest gaps):
# - That the VIP is actually reachable. This is a static check; it proves the configuration
# cannot produce the backend-blind-announcer failure, not that a backend exists, that an
# application is listening behind it, or that the LAN can route to it. On 2026-09-14 all
# four Services above ALSO had no listening backend (UniFi OS was never installed in the
# VM; no Minecraft server had been created in Crafty), and no static check can see that.
# The end-to-end claim belongs to an L2 behavioural test against a live cluster.
# - That `Cluster` is COSTLESS. It is not: Cilium runs in SNAT mode here, so a cross-node
# connection reaches the backend bearing the ingress node's address. Whether that cost is
# acceptable is a per-service judgement recorded in each manifest, not something this
# check can decide.
# - Anything about Services created in-cluster rather than authored here (the Gateway's own
# LoadBalancer Service is generated by Cilium from the Gateway resource and is not in the
# tree, so it is outside this static scan).
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
PYTHON="${PYTHON:-python3}"
cd "${ROOT}"
"${PYTHON}" - "${ROOT}" <<'PY'
import re, subprocess, sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
file=sys.stderr)
raise SystemExit(1)
ROOT = Path(sys.argv[1])
FAILURES: list[str] = []
CHECKS = 0
def check(cond, msg):
global CHECKS
CHECKS += 1
if not cond:
FAILURES.append(msg)
def tracked() -> list[str]:
out = subprocess.run(["git", "-C", str(ROOT), "ls-files", "-z"],
capture_output=True, text=True, check=True).stdout
return [p for p in out.split("\0") if p]
def docs_of(rel: str):
try:
return [d for d in yaml.safe_load_all((ROOT / rel).read_text(errors="replace"))
if isinstance(d, dict)]
except Exception:
return []
FILES = tracked()
YAML_FILES = [f for f in FILES if re.search(r"\.(ya?ml)(\.example)?$", f)]
check(len(YAML_FILES) > 50, f"only {len(YAML_FILES)} YAML files seen — not a real checkout")
# --------------------------------------------------------------------------- the announcers
# A CiliumL2AnnouncementPolicy with loadBalancerIPs: true puts LoadBalancer VIPs on the LAN.
# `serviceSelector` absent means EVERY LoadBalancer Service is announced (Cilium's default).
announcers: list[dict] = []
for rel in YAML_FILES:
for d in docs_of(rel):
if d.get("kind") == "CiliumL2AnnouncementPolicy":
spec = d.get("spec") or {}
announcers.append({
"file": rel,
"name": (d.get("metadata") or {}).get("name"),
"lb_ips": bool(spec.get("loadBalancerIPs")),
"service_selector": spec.get("serviceSelector"),
"spec": spec,
})
check(len(announcers) > 0,
"no CiliumL2AnnouncementPolicy is tracked — this check's premise (VIPs are put on the "
"LAN by L2 announcement) no longer holds and the invariant must be re-derived (R-DNS-01)")
lb_announcers = [a for a in announcers if a["lb_ips"]]
# (2) the premise is live.
check(len(lb_announcers) > 0,
"no CiliumL2AnnouncementPolicy sets loadBalancerIPs: true — nothing announces LoadBalancer "
"VIPs, so this check would pass vacuously; re-derive it (R-DNS-01)")
# (3) the candidate set is backend-blind. Cilium has no field that restricts L2 announcement to
# nodes holding a backend; assert none has appeared, so that if a future Cilium adds one the
# exemption logic here is revisited deliberately instead of silently going stale.
BACKEND_AWARE_FIELDS = {"backendSelector", "endpointSelector", "localBackendsOnly"}
for a in lb_announcers:
present = BACKEND_AWARE_FIELDS & set(a["spec"].keys())
check(not present,
f"{a['file']}: CiliumL2AnnouncementPolicy/{a['name']} carries {sorted(present)}, which "
"may make announcement backend-aware — the `Local` exemption below must be re-derived "
"before this check can keep passing (R-DNS-01)")
# --------------------------------------------------------------------------- DaemonSet backing
# Service.spec.selector is matched against DaemonSet pod-template labels in the same namespace.
# A DaemonSet puts a backend on every node, so `Local` cannot strand an announcing node.
daemonset_labels: list[tuple[str, dict]] = [] # (namespace, template labels)
for rel in YAML_FILES:
for d in docs_of(rel):
if d.get("kind") == "DaemonSet":
md = d.get("metadata") or {}
tmpl = (((d.get("spec") or {}).get("template") or {}).get("metadata") or {})
daemonset_labels.append((md.get("namespace"), tmpl.get("labels") or {}))
def daemonset_backed(ns, selector) -> bool:
if not selector:
return False
for ds_ns, labels in daemonset_labels:
if ds_ns == ns and all(labels.get(k) == v for k, v in selector.items()):
return True
return False
# --------------------------------------------------------------------------- (1) the invariant
lb_services: list[dict] = []
for rel in YAML_FILES:
for d in docs_of(rel):
if d.get("kind") != "Service":
continue
spec = d.get("spec") or {}
if spec.get("type") != "LoadBalancer":
continue
md = d.get("metadata") or {}
lb_services.append({
"file": rel,
"name": md.get("name"),
"ns": md.get("namespace"),
"etp": spec.get("externalTrafficPolicy"),
"selector": spec.get("selector") or {},
"pinned": (md.get("annotations") or {}).get("lbipam.cilium.io/ips"),
})
# Non-vacuity floor: these Services are the reason the check exists; if the corpus empties out,
# something moved and the check must be re-pointed rather than passing on nothing.
check(len(lb_services) >= 3,
f"only {len(lb_services)} tracked type=LoadBalancer Service(s) found — the corpus this "
"check quantifies over has moved or emptied; re-point it (R-DNS-01)")
exempt: list[str] = []
for s in lb_services:
where = f"{s['file']}: Service/{s['ns']}/{s['name']}"
if s["etp"] != "Local":
CHECKS += 1 # counted: every Service is genuinely inspected
continue
if daemonset_backed(s["ns"], s["selector"]):
exempt.append(f"{s['ns']}/{s['name']}")
CHECKS += 1
continue
pin = f" pinned at {s['pinned']}" if s["pinned"] else ""
check(False,
f"{where}{pin} combines type=LoadBalancer with externalTrafficPolicy: Local while "
f"CiliumL2AnnouncementPolicy/{lb_announcers[0]['name'] if lb_announcers else '?'} "
"announces its VIP from nodes chosen WITHOUT regard to backend placement. When the "
"elected announcer holds no backend the VIP answers ICMP port unreachable and does "
"not self-heal — this is the 8-day unifi/crafty outage of 2026-09-14. Use "
"externalTrafficPolicy: Cluster (see apps/wave-0/pihole-dns.yaml for the pattern and "
"the client-IP cost it trades away), or back the Service with a DaemonSet so every "
"announcing node holds a backend (R-DNS-01)")
# --------------------------------------------------------------------------- (4) non-vacuity
check(CHECKS >= 8,
f"only {CHECKS} assertions ran — too few for this invariant to be meaningful (R-DNS-01)")
# --------------------------------------------------------------------------------- verdict
if FAILURES:
print(f"CHECK FAIL (l2-announced-vips-serve-from-any-node): {len(FAILURES)} of {CHECKS} "
"assertions failed", file=sys.stderr)
for f in FAILURES:
print(f" - {f}", file=sys.stderr)
raise SystemExit(1)
note = f", {len(exempt)} DaemonSet-backed exemption(s): {sorted(exempt)}" if exempt else ""
print(f"check OK: l2-announced-vips-serve-from-any-node — {CHECKS} assertions over "
f"{len(lb_services)} LoadBalancer Service(s) and {len(lb_announcers)} announcing "
f"policy(ies){note}")
PY
@@ -238,7 +238,7 @@ def bail(msg):
DEF_RE = re.compile(r"^([a-z0-9_]+_is_placeholder)\(\)\s*\{")
SELF_DIR = ROOT / "conformance" / "tests" / "l0-static" / "placeholder-guard-coupling"
SKIP_DIRS = {".git", "_artifacts", "node_modules", ".venv"}
SKIP_DIRS = {".git", ".claude", "_artifacts", "node_modules", ".venv"}
# ------------------------------------------------------------------ discover the guard FILE
# Hardcoding a path is what let a file that never landed disable this whole check.
@@ -154,8 +154,13 @@ services: list[tuple] = []
monitors: list[tuple] = []
unparseable: list[str] = []
# NESTED WORKTREES: skip dot-DIRECTORIES (.git, .claude) while still matching dot-FILES such
# as .sops.yaml. .claude/worktrees/agent-*/ holds full repo copies for isolated agent runs, so
# a bare rglob from ROOT audited up to 18 duplicates of every file — which both invents FAILs
# and, worse, can satisfy an "something declares X" check from a copy rather than real source.
# Same fix as egress-paths-selected-by-rules. .claude/ is gitignored (.gitignore:43).
for p in sorted(ROOT.rglob("*.yaml")):
if ".git" in p.parts or is_test_fixture(p) or ".sops." in p.name:
if any(part.startswith(".") for part in p.relative_to(ROOT).parent.parts) or is_test_fixture(p) or ".sops." in p.name:
continue
txt = read(p)
if not txt:
@@ -39,20 +39,20 @@ if [ "$FAILED" -ne 0 ]; then
fi
grep -q 'CONF_SCOPE_ANNOTATION' "$COMMON_SH" \
&& grep -q 'conf_dirs_for_scope' "$COMMON_SH"
chk $? "conformance/lib/common.sh resolves the scope annotation (conf_dirs_for_scope)" \
&& grep -q 'conf_dirs_for_scope' "$COMMON_SH" && arc=0 || arc=$?
chk $arc "conformance/lib/common.sh resolves the scope annotation (conf_dirs_for_scope)" \
"conformance/lib/common.sh no longer resolves the scope annotation — no test can be
selected BY declaring a scope, so every whole-repo invariant is back to being
selectable away by the requirement filter"
grep -q -- '--include-scope' "$RUN_SH" && grep -q 'conf_dirs_for_scopes' "$RUN_SH"
chk $? "conformance/run.sh --include-scope unions scope-declared tests into select_dirs" \
grep -q -- '--include-scope' "$RUN_SH" && grep -q 'conf_dirs_for_scopes' "$RUN_SH" && arc=0 || arc=$?
chk $arc "conformance/run.sh --include-scope unions scope-declared tests into select_dirs" \
"conformance/run.sh does not union scope-declared tests (--include-scope /
conf_dirs_for_scopes missing) — the annotation would be inert decoration"
grep -Eq -- '--include-scope[[:space:]]+"?\$\{?WHOLE_REPO_SCOPE' "$L0_SH" \
|| grep -Eq -- "--include-scope[[:space:]]+['\"]?${SCOPE}" "$L0_SH"
chk $? "ci/l0.sh passes --include-scope ${SCOPE} to the conformance runner" \
|| grep -Eq -- "--include-scope[[:space:]]+['\"]?${SCOPE}" "$L0_SH" && arc=0 || arc=$?
chk $arc "ci/l0.sh passes --include-scope ${SCOPE} to the conformance runner" \
"ci/l0.sh does not pass --include-scope ${SCOPE}: the L0 merge-gate stage is back to a
purely requirement-scoped run, and every whole-repo invariant is filtered out of
exactly the change that breaks it"
@@ -63,16 +63,16 @@ while IFS= read -r d; do [ -n "$d" ] && declared_dirs+=("$d"); done < <(
CONF_ROOT="$CONF" bash -c 'source "${CONF_ROOT}/lib/common.sh"; conf_dirs_for_scope "$1"' _ "$SCOPE"
)
MIN_DECLARED="${MIN_DECLARED:-5}"
[ "${#declared_dirs[@]}" -ge "$MIN_DECLARED" ]
chk $? "${#declared_dirs[@]} test(s) declare scope='${SCOPE}' (floor ${MIN_DECLARED})" \
[ "${#declared_dirs[@]}" -ge "$MIN_DECLARED" ] && arc=0 || arc=$?
chk $arc "${#declared_dirs[@]} test(s) declare scope='${SCOPE}' (floor ${MIN_DECLARED})" \
"only ${#declared_dirs[@]} test(s) declare scope='${SCOPE}' (floor ${MIN_DECLARED}) — either the
annotation was stripped from the whole-repo checks, or the resolver stopped matching
it. Every assertion below would then pass while checking nothing"
for d in ${declared_dirs[@]+"${declared_dirs[@]}"}; do
rel="${d#"$ROOT"/}"
[ -f "$d/check.sh" ] || [ -f "$d/render-check.sh" ]
chk $? "${rel}: declares the scope AND carries an executable offline check" \
[ -f "$d/check.sh" ] || [ -f "$d/render-check.sh" ] && arc=0 || arc=$?
chk $arc "${rel}: declares the scope AND carries an executable offline check" \
"${rel} declares scope='${SCOPE}' but carries neither check.sh nor render-check.sh — an
unconditional run of a test with nothing to run is a guaranteed SKIP, i.e. a
permanent not-green, not a guard"
@@ -91,25 +91,58 @@ BOGUS_REQ="r-nosuchreq-99"
sel_without="$(bash "$RUN_SH" --layer l0 --requirements "$BOGUS_REQ" --print-selection 2>/dev/null || true)"
sel_with="$(bash "$RUN_SH" --layer l0 --requirements "$BOGUS_REQ" --include-scope "$SCOPE" --print-selection 2>/dev/null || true)"
[ -z "$sel_without" ]
chk $? "a requirement filter matching no test resolves to an EMPTY L0 selection (control)" \
[ -z "$sel_without" ] && arc=0 || arc=$?
chk $arc "a requirement filter matching no test resolves to an EMPTY L0 selection (control)" \
"the control case is broken: --requirements ${BOGUS_REQ} still selected tests
($(printf '%s' "$sel_without" | tr '\n' ' ')), so the assertion below proves nothing
about --include-scope"
# PARTITIONED BY LAYER, because the union is layer-BOUNDED by design: `--layer l0` must select the
# scope-declaring L0 tests and NOT the L1 ones (asserted separately below). This loop compared EVERY
# scope-declaring dir against an `--layer l0` selection, which silently assumed only L0 tests would
# ever declare the scope — and ci/l1.sh was written from the opposite premise, that a whole-repo
# invariant could appear "at any layer". The moment the three L1 rendering tests declared it
# (2026-09-18) this check failed, blaming the union for correctly excluding another layer.
missing=""
for d in ${declared_dirs[@]+"${declared_dirs[@]}"}; do
case "$d" in */tests/l0-static/*) ;; *) continue ;; esac
printf '%s\n' "$sel_with" | grep -qxF "$d" || missing="${missing} ${d#"$ROOT"/}"
done
[ -z "$missing" ]
chk $? "every scope-declared L0 test is selected DESPITE a requirement filter matching none" \
[ -z "$missing" ] && arc=0 || arc=$?
chk $arc "every scope-declared L0 test is selected DESPITE a requirement filter matching none" \
"the scope union does not actually select:${missing}. This is the blind spot itself: a
change that attributes no requirement would not run these checks"
# The SAME property one layer up. This is what makes the L1 declaration load-bearing rather than
# decorative: ci/l1.sh has always passed --include-scope, but until an L1 test declared the scope
# nothing proved the union reached L1 at all. The 2026-09-04 external-dns merge is the case in
# point — it attributed no rendering requirement, selected ZERO L1 tests, gated GREEN, and left all
# three rendering goldens stale.
sel_l1="$(bash "$RUN_SH" --layer l1 --requirements "$BOGUS_REQ" --include-scope "$SCOPE" \
--print-selection 2>/dev/null || true)"
missing_l1=""
declared_l1=0
for d in ${declared_dirs[@]+"${declared_dirs[@]}"}; do
case "$d" in */tests/l1-rendering/*) ;; *) continue ;; esac
declared_l1=$((declared_l1+1))
printf '%s\n' "$sel_l1" | grep -qxF "$d" || missing_l1="${missing_l1} ${d#"$ROOT"/}"
done
[ "$declared_l1" -gt 0 ] && [ -z "$missing_l1" ] && arc=0 || arc=$?
chk $arc "every scope-declared L1 test is selected under --layer l1 DESPITE a requirement filter matching none" \
"scope-declared L1 tests: ${declared_l1}; not selected by --layer l1 --include-scope ${SCOPE}:
${missing_l1:-<none>}. If the count is 0 the rendering goldens are back to being checked only
when a touched path happens to attribute R-PLAT-02/R-NET-01/R-NET-05/06/07"
# And the L1 union must not leak the other way either.
strays_l1="$(printf '%s\n' "$sel_l1" | grep -v '/tests/l1-rendering/' || true)"
[ -z "$strays_l1" ] && arc=0 || arc=$?
chk $arc "the scope union stays inside --layer l1 (no cross-layer leakage)" \
"--layer l1 --include-scope ${SCOPE} selected non-L1 test(s): $(printf '%s' "$strays_l1" | tr '\n' ' ')"
# The union must not smuggle in another layer's tests when --layer bounds the run.
strays="$(printf '%s\n' "$sel_with" | grep -v '/tests/l0-static/' || true)"
[ -z "$strays" ]
chk $? "the scope union stays inside --layer l0 (no cross-layer leakage)" \
[ -z "$strays" ] && arc=0 || arc=$?
chk $arc "the scope union stays inside --layer l0 (no cross-layer leakage)" \
"--layer l0 --include-scope ${SCOPE} selected non-L0 test(s): $(printf '%s' "$strays" | tr '\n' ' ')"
# ---- (3) a SKIP of a scope-declared check is UNWIRED, NOT GREEN -------------------------
@@ -143,23 +176,23 @@ run_offline() { # run_offline <dir...> -> echoes rc
rc_declared="$(run_offline "${WORK}/l0-static/synth-declared")"
rc_undeclared="$(run_offline "${WORK}/l0-static/synth-undeclared")"
[ "$rc_declared" = 3 ]
chk $? "a SKIPPED check that DECLARES scope='${SCOPE}' makes the runner exit 3 (UNWIRED, not green)" \
[ "$rc_declared" = 3 ] && arc=0 || arc=$?
chk $arc "a SKIPPED check that DECLARES scope='${SCOPE}' makes the runner exit 3 (UNWIRED, not green)" \
"a SKIPPED scope-declared check made the offline runner exit ${rc_declared}, not 3. A
whole-repo invariant reporting 'nothing to audit' means the tree it audits has
VANISHED; the deleted ci/l0.sh stages called that 'not a pass' and set the gate's
rc=3. Exit 0 here turns that back into a silent green"
[ "$rc_undeclared" = 0 ]
chk $? "a SKIPPED check with NO scope declaration still exits 0 (unchanged for scaffolds)" \
[ "$rc_undeclared" = 0 ] && arc=0 || arc=$?
chk $arc "a SKIPPED check with NO scope declaration still exits 0 (unchanged for scaffolds)" \
"a SKIPPED undeclared check made the runner exit ${rc_undeclared}, not 0 — the strict
rule leaked onto ordinary scaffolds, which would make every unfinished test block"
# ...and ci/l0.sh must map that rc 3 to the GATE's rc 3, never to a pass.
l0_static_stage="$(sed -n '/Stage 3b: L0 STATIC/,/^case "\$rc" in/p' "$L0_SH")"
printf '%s\n' "$l0_static_stage" | grep -Eq '^[[:space:]]*3\)' \
&& printf '%s\n' "$l0_static_stage" | grep -Eq 'rc=3'
chk $? "ci/l0.sh maps the runner's rc 3 to the gate's own rc 3 (SKIPPED, not a pass)" \
&& printf '%s\n' "$l0_static_stage" | grep -Eq 'rc=3' && arc=0 || arc=$?
chk $arc "ci/l0.sh maps the runner's rc 3 to the gate's own rc 3 (SKIPPED, not a pass)" \
"ci/l0.sh's L0-static stage has no rc-3 arm setting the gate's rc=3, so a scope-declared
check that SKIPPED would be reported as a PASS — the exact false green the deleted
stages were written to prevent"
@@ -193,8 +226,8 @@ done
# ---- (5) the gate still BLOCKS on an l0 failure -----------------------------------------
# Carried over from the three deleted stages' own checks — asserted once, here.
grep -Eq '^for s in .*\bl0\b.*; do' "$GATE_SH" && grep -Eq 'fail\).*BLOCK=1' "$GATE_SH"
chk $? "ci/gate.sh sets the merge block when the l0 stage fails" \
grep -Eq '^for s in .*\bl0\b.*; do' "$GATE_SH" && grep -Eq 'fail\).*BLOCK=1' "$GATE_SH" && arc=0 || arc=$?
chk $arc "ci/gate.sh sets the merge block when the l0 stage fails" \
"ci/gate.sh does not set the merge block on an l0 stage failure — every assertion above
would hold while a red L0 stage merged anyway"
@@ -23,6 +23,17 @@ metadata:
conformance.home.k3s/requirements: "R-PLAT-02"
conformance.home.k3s/layer: "L1"
conformance.home.k3s/safety-critical: "false"
# WHY SCOPE, NOT REQUIREMENTS ALONE: every L1 test exercises ONE shared code path —
# catalog/render.py against schemas/servicedefinition.schema.json — so anything that changes
# rendered output breaks these goldens regardless of which requirement the touched path maps to.
# Requirement-scoped selection cannot see that coupling. Concretely (2026-09-04 external-dns
# merge): `platform/externaldns/helmrelease.yaml` attributes R-ENV-02, R-OBS-01/02/04,
# R-STATE-03 and R-TEST-01/02/03/04 via ci/touched-requirements.sh — NOT R-PLAT-02, R-NET-01 or
# R-NET-05/06/07 — yet it added `external-dns.alpha.kubernetes.io/controller: none` to the
# rendered output of ALL THREE L1 goldens. A requirement-scoped gate selected ZERO L1 tests, went
# GREEN, and left every golden stale; the staleness surfaced only on a later full run.
# ci/l1.sh has passed --include-scope since it was written, waiting for this annotation.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/intent: >-
A kind: container ServiceDefinition renders a Deployment + Service (the PaaS path).
spec:
@@ -196,6 +196,7 @@ metadata:
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: platform-admins
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
@@ -27,6 +27,17 @@ metadata:
conformance.home.k3s/requirements: "R-NET-01"
conformance.home.k3s/layer: "L1"
conformance.home.k3s/safety-critical: "false"
# WHY SCOPE, NOT REQUIREMENTS ALONE: every L1 test exercises ONE shared code path —
# catalog/render.py against schemas/servicedefinition.schema.json — so anything that changes
# rendered output breaks these goldens regardless of which requirement the touched path maps to.
# Requirement-scoped selection cannot see that coupling. Concretely (2026-09-04 external-dns
# merge): `platform/externaldns/helmrelease.yaml` attributes R-ENV-02, R-OBS-01/02/04,
# R-STATE-03 and R-TEST-01/02/03/04 via ci/touched-requirements.sh — NOT R-PLAT-02, R-NET-01 or
# R-NET-05/06/07 — yet it added `external-dns.alpha.kubernetes.io/controller: none` to the
# rendered output of ALL THREE L1 goldens. A requirement-scoped gate selected ZERO L1 tests, went
# GREEN, and left every golden stale; the staleness surfaced only on a later full run.
# ci/l1.sh has passed --include-scope since it was written, waiting for this annotation.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/intent: >-
Rendering any service's namespace yields a default-deny baseline policy.
spec:
@@ -32,6 +32,17 @@ metadata:
conformance.home.k3s/requirements: "R-NET-05, R-NET-06, R-NET-07"
conformance.home.k3s/layer: "L1"
conformance.home.k3s/safety-critical: "false"
# WHY SCOPE, NOT REQUIREMENTS ALONE: every L1 test exercises ONE shared code path —
# catalog/render.py against schemas/servicedefinition.schema.json — so anything that changes
# rendered output breaks these goldens regardless of which requirement the touched path maps to.
# Requirement-scoped selection cannot see that coupling. Concretely (2026-09-04 external-dns
# merge): `platform/externaldns/helmrelease.yaml` attributes R-ENV-02, R-OBS-01/02/04,
# R-STATE-03 and R-TEST-01/02/03/04 via ci/touched-requirements.sh — NOT R-PLAT-02, R-NET-01 or
# R-NET-05/06/07 — yet it added `external-dns.alpha.kubernetes.io/controller: none` to the
# rendered output of ALL THREE L1 goldens. A requirement-scoped gate selected ZERO L1 tests, went
# GREEN, and left every golden stale; the staleness surfaced only on a later full run.
# ci/l1.sh has passed --include-scope since it was written, waiting for this annotation.
conformance.home.k3s/scope: "whole-repo"
conformance.home.k3s/intent: >-
egress: vpn renders the egress-gateway + kill-switch path; internet does not.
spec:
@@ -223,6 +223,7 @@ metadata:
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: media-admins
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
@@ -223,6 +223,7 @@ metadata:
annotations:
homelab.keithjackson.dev/auth-mode: forward-auth
homelab.keithjackson.dev/auth-groups: media-admins
external-dns.alpha.kubernetes.io/controller: none
spec:
parentRefs:
- name: shared
+140
View File
@@ -0,0 +1,140 @@
# infra/gitea — platform settings that are NOT GitOps-managed
Gitea's own repo settings (branch protection, required status checks, approvals) live
in Gitea's database, not in a manifest Flux reconciles. They are therefore **drift-prone
and invisible to the gate** — exactly how the state below went unnoticed.
## The gap this closes
Verified against the live API on 2026-09-29, against a `DECISIONS-LOG.md` entry
(2026-08-31) asserting the opposite:
| Claimed | Measured |
|---|---|
| `main` is a protected branch, gate is a required status check | `branch_protections` → `[]`; `protected:false`, `enable_status_check:false`, `required_approvals:0`, `user_can_push:true` |
| Runner `incluster-dind` online | **zero** runners registered at repo, org and site level |
So `main` accepted direct pushes, and the gate could not run at all. `CLAUDE.md` is the
rule; this directory is how the rule gets enforced by the machine.
## `branch-protection.sh`
Blocks direct pushes to `main`. Run it, then run the push probe it prints — **verify,
do not trust the API response**, since Gitea may let site admins bypass protection.
./infra/gitea/branch-protection.sh
It authenticates with the credential `git` already has stored for this host, and never
prints the secret.
## `runner-token-rotate.sh`
Mints a fresh instance-level registration token from the Gitea API and writes it into
`apps/wave-1/gitea-actions-runner-secret.sops.yaml` via `sops set`. **The token value is
never printed.**
./infra/gitea/runner-token-rotate.sh
A fresh token is usually **not sufficient on its own**. `act_runner` registers exactly once
and persists its identity in `/data/.runner` on the `act-runner-data` PVC. If Gitea no
longer holds a matching runner row, the pod starts, sees `.runner`, **skips registration**,
and polls with an identity Gitea does not recognise — so it never appears in the runner
list. That is precisely the observed symptom: zero runners, pod running, no error anyone
looks at. Clearing the PVC is what forces a real re-registration; the script prints the
commands.
## Sequence — DONE 2026-09-29
All four steps are complete and each was verified, not assumed:
| Step | State |
|---|---|
| 1. Runner registered | ✅ `incluster-dind` online. Root cause of its absence was a stale `/data/.runner` identity; clearing the PVC forced re-registration |
| 2. Gate observed green on a real PR | ✅ `gate / gate (pull_request)` ~1m35s |
| 3. `enable_status_check: true` | ✅ context `gate / gate (pull_request)` — that one ONLY |
| 4. Collaborator + approvals | ✅ `jakman85` is owner; `required_approvals: 1` |
Direct push to `main` was **provoked and rejected** (`pre-receive hook declined`) as
`gitea-admin`, a site admin — so push protection is not merely declared, it holds for the
account agents authenticate as.
### 5. Block the admin override — the step the first pass MISSED
`enable_status_check` and `required_approvals` were set on 2026-09-29 and **still did not
bite**, because `block_admin_merge_override` was `false`. Gitea lets repo admins override
merge requirements without it, and `jakman85` holds `owner` — so the two settings that looked
like the gate were overridable by precisely the account that merges.
**The evidence was sitting in plain sight:** PRs #5 through #9 all merged with
`required_approvals: 1` in force and **zero approvals recorded on any of them**. Checking the
*branch* view (`/branches/main`) hid this — it does not expose the field. Read the rule object
(`/branch_protections/main`) and enumerate its keys.
> **Do not probe for field names you assume exist.** This Gitea (1.24.3) has no
> `apply_to_admins`; asking `jq` for it returns `null`, which reads like "set to false" but
> means "key absent". Enumerate with `to_entries` and act on what is really there.
Now `block_admin_merge_override: true`. **That is still only a setting, not evidence** — it is
proven when a green PR is REFUSED a merge until approved, and not before.
### `require-gate.sh` / `verify-then-enforce.sh`
`verify-then-enforce.sh` is the re-runnable form of the whole safe order. It merges nothing —
merging is a human's reviewed act — but it forces the Flux reconcile and then **aborts** unless
the runner pod is healthy, a runner is online, and a real workflow goes green on it, before
enabling enforcement and probing the push. `require-gate.sh` is the enforcement step alone.
Re-run `verify-then-enforce.sh` after any future change to the act-runner Deployment.
### Why these scripts exist at all, and what is still missing
Gitea's repo settings live in **its own database**, not in anything Flux reconciles or the
conformance gate can read. Nothing detects drift from what this directory declares. That is
the root cause of the entire 2026-09-29 session: branch protection was off and the runner was
gone for a month while the log recorded both as working, and **no check existed that could
have caught it.**
These scripts encode the desired state and can re-assert it, but they are not a detector. The
durable fix is a conformance check that reads the live Gitea API and fails when it diverges
from what is declared here — turning a class of silent, invisible failure into a loud one.
**That check does not exist yet and is the highest-value remaining work in this directory.**
## Historical sequence (kept for context)
### Original ordering notes
1. **Get an Actions runner registered** — `runner-token-rotate.sh`, then clear the PVC so
the runner actually re-registers, then confirm it shows `online` in the API. Do not
skip the confirmation: the whole reason this went unnoticed is that a runner was
*recorded* as online without anyone checking.
2. **Watch the gate actually report on a real PR**, and note the context name it produced.
3. **Only then set `enable_status_check: true`** with that exact context.
4. Add the human reviewer as a repo collaborator, then raise `required_approvals` to 1.
> **Do not do step 3 before step 1.** A required status check with no runner leaves every
> PR permanently pending — including the PR that would fix it. That half is measured: PR #1
> sat `pending` for 30.6 minutes across 122 polls with no runner registered, and never
> reported.
### Measured, once the runner came up (2026-09-29)
The first CI jobs this repo ever ran, on PR #1 and PR #2:
| Context | Result | Time |
|---|---|---|
| `gate / gate (pull_request)` | success | 1m58s (PR #1), 1m34s (PR #2) |
| `gate / gate (push)` | success | 1m32s (PR #2 only) |
| `gate / conformance (pull_request)` | **success — "Has been skipped"** | — |
**Require `gate / gate (pull_request)`. Do NOT require either `conformance` context —
but not for the reason an earlier draft of this file gave.** A skipped Gitea Actions job
does not hang a status check: Gitea reports it as **`success`** with the description
"Has been skipped". So requiring it would not deadlock anything — it would pass
*vacuously*, every time, forever, while nothing ran. Under HARD RULE 4 that is worse than
a deadlock: a deadlock is loud, a permanently-green check that verifies nothing is
exactly the fail-open shape this repo keeps finding.
The `(push)` contexts appear only for branches matching `gate.yml`'s push trigger
(`feature/**`, `fix/**`, `docs/**`, `chore/**`). `feat/**` is **not** in that list, which
is why PR #1's `feat/` branch produced only `pull_request` contexts. Require only the
`pull_request` context, or PRs from non-matching prefixes will never satisfy the rule.
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Stop repository admins from overriding the merge requirements on `main`.
#
# WHY. Gitea lets repo admins override branch-protection merge requirements unless
# `block_admin_merge_override` is set. It was FALSE, and jakman85 holds `owner` -- so
# required_approvals and the required status check were both overridable by the one account
# that actually merges. Evidence: PRs #5-#9 all merged with ZERO approvals recorded against
# `required_approvals: 1`.
#
# NOTE ON FIELD NAMES: this Gitea (1.24.3) has NO `apply_to_admins` field. Querying it with jq
# returns null, which reads like "set to false" but actually means "key absent". Enumerate the
# real keys (`to_entries`) rather than probing names you assume exist.
#
# Recoverable: an owner can PATCH this back to false if it gets in the way.
set -euo pipefail
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
B="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branch_protections/main"
echo "==> before: block_admin_merge_override = $(api "$B" | jq -r .block_admin_merge_override)"
api -X PATCH -H 'Content-Type: application/json' \
-d '{"block_admin_merge_override": true}' "$B" >/dev/null
AFTER=$(api "$B" | jq -r .block_admin_merge_override)
echo "==> after: block_admin_merge_override = $AFTER"
[ "$AFTER" = true ] || { echo "PATCH did not take -- do not record this as enforced." >&2; exit 1; }
api "$B" | jq -r '{enable_push,enable_status_check,status_check_contexts,required_approvals,block_admin_merge_override}'
cat <<'NOTE'
NOW PROVE IT -- the setting is not the evidence. On the next PR:
* let the gate go green, then try to merge WITHOUT approving it.
* Gitea must REFUSE. If it merges, this still is not doing its job.
Do not record approvals as enforced until you have seen a merge refused.
NOTE
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Enable branch protection on main for home-network-k3s-impl.
#
# Sets enable_push:false -> no direct pushes to main; changes land only by merging a PR.
#
# enable_status_check is deliberately FALSE. There is currently NO Gitea Actions runner
# registered (verified 2026-09-29: zero at repo, org and site level), so requiring the
# gate context would deadlock EVERY PR permanently -- including the PR that fixes it.
# Turn it on only after a runner is live and you have seen the gate actually report on
# a real PR, using the context name CI itself produced.
#
# required_approvals is 0 for the same class of reason: jakman85 is a site admin but NOT
# a repo collaborator, so requiring an approval now could leave nobody able to approve.
# Add the collaborator first, then raise this to 1.
set -euo pipefail
HOST=gitea.home.keithjackson.dev
OWNER=Home-Network
REPO=home-network-k3s-impl
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
U="$(sed -n 's/^username=//p' <<<"$creds")"
P="$(sed -n 's/^password=//p' <<<"$creds")"
[ -n "$U" ] && [ -n "$P" ] || { echo "no stored credential for $HOST" >&2; exit 1; }
echo "authenticating as '${U}' (secret not shown)"
echo "==> creating branch protection on 'main'"
jq -n '{
branch_name: "main",
enable_push: false,
enable_status_check: false,
status_check_contexts: [],
required_approvals: 0,
block_on_rejected_reviews: true,
block_on_outdated_branch: false,
dismiss_stale_approvals: true
}' | curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") \
-X POST -H 'Content-Type: application/json' --data @- \
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branch_protections" \
| jq -r 'if .branch_name then "created" else "API said: \(.message // tostring)" end'
echo "==> verifying (this is the part that matters)"
curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") \
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branches/main" \
| jq -r '{protected, user_can_push, required_approvals, enable_status_check, status_check_contexts}'
cat <<'NOTE'
Now prove it actually blocks you -- do not take the API's word for it. From a clean
checkout on main, with nothing to lose:
git commit --allow-empty -m 'protection probe'
git push origin main # EXPECT: rejected by the remote
git reset --hard HEAD~1 # tidy up either way
If that push SUCCEEDS, Gitea is letting site admins bypass protection and this is not
yet doing what you want.
NOTE
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Items 1 + 2: make the gate a REQUIRED status check and require one approval.
#
# RUN THIS LAST. Prerequisites, in order:
# a) PR #7 (dind native sidecar) is MERGED, and
# b) you have SEEN a real workflow run go green on the converted pod.
# The runner Deployment is the merge gate for the whole repo. Turning on a required check
# while the runner is broken means no PR can merge -- including the revert. (Recoverable:
# an owner can set enable_status_check back to false. But do not rely on that.)
#
# Requires exactly ONE context: `gate / gate (pull_request)`.
# * NOT `gate / conformance (*)` -- that job is `if: false`, and a skipped Gitea job reports
# SUCCESS ("Has been skipped"). Requiring it would pass VACUOUSLY forever while nothing
# ran, which under HARD RULE 4 is worse than a deadlock: a deadlock is loud.
# block_admin_merge_override: true is LOAD-BEARING and was missing on the first pass. Gitea
# lets repo admins override merge requirements without it, so required_approvals and the
# required status check are both bypassable by exactly the account that does the merging.
# Measured: PRs #5-#9 merged with ZERO approvals recorded against required_approvals: 1.
#
# FIELD NAMES: this Gitea (1.24.3) has NO `apply_to_admins`. Asking jq for it returns null,
# which reads like "false" but means "key absent" -- enumerate real keys with `to_entries`
# instead of probing names you assume exist. That mistake cost a wasted PATCH.
#
# * NOT the `(push)` variant -- after PR #6 there are no per-branch push runs at all, so a
# PR would never produce that context.
set -euo pipefail
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
echo "==> updating branch protection on main"
jq -n '{
enable_push: false,
enable_status_check: true,
status_check_contexts: ["gate / gate (pull_request)"],
required_approvals: 1,
block_on_rejected_reviews: true,
dismiss_stale_approvals: true,
block_on_outdated_branch: false,
block_admin_merge_override: true
}' | api -X PATCH -H 'Content-Type: application/json' --data @- \
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branch_protections/main" >/dev/null
echo "==> verifying"
api "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branches/main" \
| jq -r '{protected,user_can_push,enable_status_check,status_check_contexts,required_approvals}'
cat <<'NOTE'
NOTE ON required_approvals: 1 -- Gitea will not let an author approve their own PR.
* Agent-opened PRs (author gitea-admin) are approved by jakman85. Fine.
* PRs YOU open as jakman85 need gitea-admin to approve. If that is awkward, either open
agent work as gitea-admin and review as jakman85, or set required_approvals back to 0
and rely on the status check alone.
Then prove it, rather than trusting the API:
git commit --allow-empty -m probe && git push origin main # EXPECT: rejected
git reset --hard HEAD~1
NOTE
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Mint a fresh Gitea Actions registration token and write it into the SOPS secret.
#
# The token value is NEVER printed. It goes from the Gitea API into `sops set` and
# nowhere else. (It is briefly visible in this host's process list as a sops argument;
# acceptable on a single-operator workstation, and far better than echoing it.)
#
# This ALONE may not bring the runner back -- see "IMPORTANT" at the end of the output.
set -euo pipefail
HOST=gitea.home.keithjackson.dev
SECRET=apps/wave-1/gitea-actions-runner-secret.sops.yaml
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
cd "$REPO_ROOT"
[ -f "$SECRET" ] || { echo "not in the repo root, or $SECRET is missing" >&2; exit 1; }
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
U="$(sed -n 's/^username=//p' <<<"$creds")"
P="$(sed -n 's/^password=//p' <<<"$creds")"
[ -n "$U" ] && [ -n "$P" ] || { echo "no stored git credential for $HOST" >&2; exit 1; }
echo "==> minting an instance-level registration token as '${U}'"
TOKEN="$(curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") \
"https://${HOST}/api/v1/admin/runners/registration-token" | jq -r '.token // empty')"
[ -n "$TOKEN" ] || { echo "Gitea did not return a token" >&2; exit 1; }
echo "==> got a ${#TOKEN}-character token (value not shown)"
echo "==> writing it into ${SECRET} via sops"
DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run \
sops set "$SECRET" '["stringData"]["token"]' "\"${TOKEN}\""
unset TOKEN
echo "==> done. Verify the file is still encrypted (no plaintext token):"
grep -q 'ENC\[' "$SECRET" && echo " OK - stringData is still SOPS-encrypted" \
|| { echo " REFUSING TO CONTINUE: $SECRET is not encrypted" >&2; exit 1; }
cat <<'NOTE'
Next: commit this on a branch, open a PR, and merge it so Flux applies the new Secret.
IMPORTANT -- a new token is usually NOT sufficient on its own.
act_runner registers exactly ONCE and then persists its identity in /data/.runner on the
act-runner-data PVC. If Gitea no longer has a matching runner row (DB restore, the row was
deleted, the instance was rebuilt), the pod starts, sees .runner, SKIPS registration, and
polls with an identity Gitea does not recognise -- so it never appears in the runner list,
which is exactly the observed symptom (zero runners, pod running).
So after the Secret lands, force a genuine re-registration by clearing that state:
kubectl --context k3s-prod -n gitea-actions scale deploy/act-runner --replicas=0
kubectl --context k3s-prod -n gitea-actions delete pvc act-runner-data
kubectl --context k3s-prod -n gitea-actions scale deploy/act-runner --replicas=1
(The PVC holds only runner registration state -- no build artifacts, nothing durable.
Deleting it is the supported way to re-register. Flux recreates it from the manifest.)
Then confirm it actually registered -- do not assume:
curl -s -u '<user>' https://gitea.home.keithjackson.dev/api/v1/admin/actions/runners | jq
A runner must appear with status "online" and the labels ubuntu-latest / homelab.
NOTE
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# verify-then-enforce.sh — run this AFTER you have merged PRs 8, 5, 6, 7 in that order.
#
# It does NOT merge anything. Merging is your reviewed act (CLAUDE.md: a human approves and
# merges). This script does the mechanical half: prove the converted runner actually works,
# and only then turn on enforcement.
#
# WHY THIS ORDER. The act-runner Deployment IS the merge gate for this repository. Enabling a
# required status check while the runner is broken means NO PR can merge -- including the
# revert. So PR #7 lands while the gate is still advisory, is PROVEN green on a real run, and
# only then does enforcement go on.
#
# Aborts at the first checkpoint that does not pass. Safe to re-run.
set -euo pipefail
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
REPO_ROOT=/var/home/keith/source/home-network-k3s-impl
D="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$REPO_ROOT"
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
A="https://${HOST}/api/v1/repos/${OWNER}/${REPO}"
say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
die() { printf '\n\033[31mABORT: %s\033[0m\n' "$1" >&2; exit 1; }
say "PRECHECK — all four PRs must be merged first"
for n in 8 5 6 7; do
m=$(api "$A/pulls/$n" | jq -r .merged)
printf ' PR #%s merged=%s\n' "$n" "$m"
[ "$m" = true ] || die "PR #$n is not merged yet. Merge 8, 5, 6, 7 in that order, then re-run."
done
say "STEP 1 — force Flux to apply the sidecar now, not on the 10m interval"
./infra/devcontainer/dev.sh run bash -c \
'flux --context k3s-prod -n flux-system reconcile kustomization apps-wave-1 --with-source'
say "STEP 2 — CHECKPOINT: the converted pod must come up healthy"
./infra/devcontainer/dev.sh run bash -c '
kubectl --context k3s-prod -n gitea-actions rollout status deploy/act-runner --timeout=180s
echo "--- sidecar (initContainers) ---"
kubectl --context k3s-prod -n gitea-actions get deploy act-runner \
-o jsonpath="{range .spec.template.spec.initContainers[*]}{.name} restartPolicy={.restartPolicy}{\"\n\"}{end}"
kubectl --context k3s-prod -n gitea-actions get pod -l app.kubernetes.io/name=act-runner' \
|| die "converted act-runner pod is not healthy -- REVERT PR #7 before anything else"
say "STEP 3 — CHECKPOINT: a runner must be ONLINE again"
n=0
for i in $(seq 1 40); do
n=$(api "https://${HOST}/api/v1/admin/actions/runners" | jq -r '[(.runners//[])[]|select(.status=="online")]|length')
printf '\r online runners: %s (%ss) ' "$n" "$((i*5))"
[ "${n:-0}" -ge 1 ] && { echo; break; }
sleep 5
done
[ "${n:-0}" -ge 1 ] || die "no runner online after the conversion -- REVERT PR #7"
say "STEP 4 — CHECKPOINT: a REAL workflow must go green ON THE CONVERTED POD"
MAIN_SHA=$(api "$A/branches/main" | jq -r .commit.id)
echo " watching the post-merge push run on main (${MAIN_SHA:0:8})..."
st=""
for i in $(seq 1 60); do
st=$(api "$A/commits/$MAIN_SHA/status" | jq -r .state)
printf '\r main gate: %-8s (%ss) ' "$st" "$((i*10))"
case "$st" in success) echo; break;; failure|error) die "gate FAILED on the converted pod -- REVERT PR #7";; esac
sleep 10
done
[ "$st" = success ] || die "gate never completed on the converted pod -- REVERT PR #7; do NOT enable enforcement"
echo " the converted sidecar ran a real job green. Enforcement is now safe."
say "STEP 5 — require the gate + one approval"
"$D/require-gate.sh"
say "STEP 6 — prove a direct push is rejected (do not trust the API's word)"
git fetch -q origin && git checkout -q main && git reset -q --hard origin/main
git commit -q --allow-empty -m 'protection probe'
if git push origin main 2>&1 | grep -q 'protected branch'; then
echo " CONFIRMED: push to main rejected by the pre-receive hook."
else
echo " WARNING: the push was NOT rejected -- protection is not doing what it claims."
fi
git reset -q --hard origin/main
say "FINAL STATE"
api "$A/branches/main" | jq -r '{protected,user_can_push,enable_status_check,status_check_contexts,required_approvals}'
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# plex-claim-ship.sh — run this IMMEDIATELY AFTER editing the SOPS secret with a fresh
# plex.tv claim token. It drives the whole CI/CD loop end to end and prints a running
# stopwatch so you can see the budget being spent.
#
# branch -> commit -> push -> PR -> gate -> merge -> reconcile -> pod roll -> verify
#
# It never sees, prints, or handles the token: the file is already encrypted by sops.
set -euo pipefail
HOST=gitea.home.keithjackson.dev
OWNER=Home-Network
REPO=home-network-k3s-impl
SECRET=apps/wave-0/plex-claim-secret.sops.yaml
T0=$(date +%s)
el() { printf '\n[T+%02d:%02d] %s\n' $(( ($(date +%s)-T0)/60 )) $(( ($(date +%s)-T0)%60 )) "$1"; }
cd "$(git rev-parse --show-toplevel)"
# --- preflight: refuse to ship a file that is not what we think it is -------------
git diff --quiet -- "$SECRET" && { echo "ERROR: $SECRET is unchanged. Edit it first:"; \
echo " infra/devcontainer/dev.sh run sops $SECRET"; exit 1; }
grep -q 'PLEX_CLAIM: ENC\[' "$SECRET" || { echo "ERROR: $SECRET is not encrypted. REFUSING."; exit 1; }
[ -z "$(git status --porcelain | grep -v " $SECRET\$")" ] || echo "NOTE: other files are dirty; only $SECRET will be committed."
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
# Branch prefix is now cosmetic. It used to matter: gate.yml's push trigger covered
# feature/** fix/** docs/** chore/** but not feat/**, and with act_runner at capacity 1 a
# matching prefix fired a redundant run that serialised ahead of the PR run. Those
# per-branch push triggers were deleted, so no prefix produces a duplicate run any more.
BRANCH="feat/plex-claim-token-$(date +%H%M%S)"
el "committing on $BRANCH"
git checkout -q -b "$BRANCH"
git add "$SECRET"
git commit -q -m 'chore(plex): fresh plex.tv claim token
Injected inside the plex.tv claim window per runbooks/plex-claim.md.'
SHA=$(git rev-parse HEAD)
el "pushing"
git push -q -u origin "$BRANCH"
el "opening PR"
PR=$(jq -n --arg h "$BRANCH" --arg b main --arg t 'chore(plex): fresh plex.tv claim token' \
'{head:$h,base:$b,title:$t,body:"Token injection inside the claim window. Wiring already reviewed in #1."}' \
| api -X POST -H 'Content-Type: application/json' --data @- \
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls" | jq -r '.number')
[ -n "$PR" ] && [ "$PR" != "null" ] || { echo "PR creation failed"; exit 1; }
echo " PR #$PR"
el "waiting for the gate (expect ~1m40s)"
for i in $(seq 1 60); do
st=$(api "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/commits/${SHA}/status" | jq -r '.state')
printf '\r gate=%s [T+%ss] ' "$st" "$(( $(date +%s)-T0 ))"
case "$st" in success) echo; break;; failure|error) echo; echo "GATE FAILED — stopping."; exit 1;; esac
sleep 5
done
[ "$st" = "success" ] || { echo; echo "gate did not finish in time"; exit 1; }
el "merging PR #$PR"
api -X POST -H 'Content-Type: application/json' -d '{"Do":"merge"}' \
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR}/merge" >/dev/null
git checkout -q main && git pull -q --ff-only origin main
el "forcing the Flux reconcile (--with-source, or it applies the OLD revision)"
./infra/devcontainer/dev.sh run bash -c \
'flux --context k3s-prod -n flux-system reconcile kustomization apps-wave-0 --with-source'
# MANDATORY here. Only the Secret ciphertext changed, so the Deployment pod template is
# byte-identical and NOTHING restarts on its own. PLEX_CLAIM is read once, at container
# start. Skipping this is failure mode #2 in runbooks/plex-claim.md.
el "rolling the pod (mandatory — a changed Secret restarts nothing)"
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex rollout restart deployment/plex && \
kubectl --context k3s-prod -n plex rollout status deployment/plex --timeout=120s'
el "verdict"
./infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex logs deploy/plex --since=3m | grep -i -e claim -e token || true'
cat <<'NOTE'
"Attempting to obtain server token..." + "Token obtained successfully" -> CLAIMED
"Attempting..." with no success line -> token expired; retry, nothing is broken
neither line -> already claimed, or env did not reach the container
Confirm it persisted (prints the attribute NAME only, never its value):
infra/devcontainer/dev.sh run bash -c \
'kubectl --context k3s-prod -n plex exec deploy/plex -- sh -c \
"grep -o PlexOnlineToken= \"/config/Library/Application Support/Plex Media Server/Preferences.xml\" | head -1"'
NOTE
+26
View File
@@ -117,3 +117,29 @@ Gateway VIP). The sidecar→Crafty hop is pod-local (`127.0.0.1`) and not subjec
3. **Server-create + game reach:** in the Crafty UI create a Java server bound to `25565`; start it;
connect a Minecraft client to `crafty-mc.home.keithjackson.dev:25565` (or `192.168.1.55:25565`)
from the LAN and confirm the world loads. (Bedrock: a server on `19132/udp` reached at the VIP.)
## Runtime state: the servers themselves are NOT in git
As of 2026-09-14 one Java server exists — **"homelab"**, Paper 26.2, TCP 25565, 2 GiB heap, autostart
on, UUID `13c7b85d-00bb-4a58-8c86-2433ca47891c`. It was created through Crafty's v2 API, and **Mojang's
EULA (<https://aka.ms/MinecraftEULA>) was accepted on the operator's behalf** to let it start; see the
DECISIONS-LOG entry of that date.
Everything in this directory describes only the *capacity* to run Minecraft servers. The servers are
application state on the `crafty-data` Longhorn PVC, in two places, and **Flux cannot recreate either**:
- `/crafty/app/config/db/` (`config` subPath) — Crafty's SQLite row: name, port, jar, launch command,
heap sizes, `auto_start` / `crash_detection`.
- `/crafty/servers/<uuid>/` (`servers` subPath) — `paper.jar`, `eula.txt`, `server.properties`, and
**the generated world**. The jar is re-downloadable; the world is not.
So a rebuild from git alone yields a healthy but **empty** Crafty with nothing listening on 25565.
Recreating a server by hand means: create it in the UI or API, accept the EULA again, and PATCH
`auto_start` to true — see the next note. The world starts over. **Treat this PVC as backup-relevant.**
### Trap: the create API silently ignores `autostart`
`POST /api/v2/servers` accepts and validates `autostart` and `crashdetection`, but never stores them —
the persisted columns are `auto_start` and `crash_detection`. After a create that asked for `true`,
both read back `false`. Set them afterwards with `PATCH /api/v2/servers/<uuid>` and **read them back to
confirm**, or the server will not come up after a pod restart.
+12 -1
View File
@@ -79,8 +79,19 @@ spec:
resources:
requests:
cpu: 250m
memory: 1Gi
# Raised 1Gi -> 3Gi when the first Minecraft server ("homelab", Paper 26.2) was created:
# that JVM runs with -Xms2000M -Xmx2000M, so the container's steady-state floor is now the
# panel (~100Mi) PLUS a permanently-committed 2 GiB heap plus JVM overhead. Measured
# cgroup memory.current with the server idle-but-running: 2.86Gi. A 1Gi request understated
# that by ~3x, which both misleads the scheduler and makes this Burstable pod an early
# eviction candidate under node memory pressure (kubelet ranks by usage-over-request).
memory: 3Gi
# Minecraft JVMs are memory-hungry; cap generously for a starter set of small servers.
# Headroom check for the 4Gi cap: 2 GiB heap + ~0.5 GiB JVM non-heap (metaspace, code cache,
# GC structures, thread stacks, mmap'd region files) + ~0.1 GiB panel = ~2.9Gi observed,
# leaving ~1.2Gi. A 3 GiB heap would have landed near 4Gi and risked an OOMKill, which is
# why the server was sized at a 2 GiB heap rather than larger. Raise BOTH this limit and the
# server's own memory in Crafty together if a bigger heap is ever wanted.
limits:
memory: 4Gi
securityContext:
+63 -5
View File
@@ -28,9 +28,67 @@
# documentation) and to the game-port ingress CiliumNetworkPolicy (networkpolicy.yaml). K8s Services
# enumerate ports individually (no ranges), so add one `{ name, port, targetPort, protocol }` line each.
#
# externalTrafficPolicy: Local preserves the LAN client source IP and pins the VIP to the node running
# the crafty pod (also what L2 announcement does). external-dns (source: service) publishes the A
# record from the -tcp Service.
# external-dns (source: service) publishes the A record from the -tcp Service.
#
# -------------------------------------------------------------------------------------------------
# externalTrafficPolicy: Cluster — CORRECTED 2026-09-14 after an 8-DAY LAN OUTAGE.
# -------------------------------------------------------------------------------------------------
# This file used to say `Local`, justified as "preserves the LAN client source IP and pins the VIP to
# the node running the crafty pod (also what L2 announcement does)". THE SECOND HALF IS FALSE, and it
# is what took the Minecraft port off the LAN.
#
# L2 ANNOUNCEMENT DOES NOT FOLLOW THE BACKEND. Cilium's L2 announcer elects one lease holder per
# Service among the nodes matching the CiliumL2AnnouncementPolicy `default-l2` — which matches ALL
# linux nodes — and it does NOT filter that set by which nodes hold a backend. MEASURED on prod
# 2026-09-14: the crafty pod ran on k3s-prod-1, `cilium-l2announce-crafty-crafty-l4-udp` was held by
# k3s-prod-1 (so Bedrock/UDP happened to work) but `-tcp` was held by k3s-prod-2 — and with `Local` an
# announcing node holding no backend has nowhere to send the packet and answers ICMP port unreachable.
# 192.168.2.22:25565, the Java port, was dead from the LAN for up to 8 days. That the two protocols
# landed on different nodes, one working and one not, is the whole tell: the election is per-Service
# and random, not backend-aware. It also does NOT self-heal — the holder keeps RENEWING after the pod
# moves, so only deleting the Lease forces a (still random) re-election. See
# apps/wave-0/pihole-dns.yaml, which hit this first and is the reference implementation.
#
# `Cluster` makes EVERY node able to serve the VIP by forwarding to wherever the pod is, which is what
# makes this survive a reschedule and a rebuild.
#
# NO CHANGE IS NEEDED IN networkpolicy.yaml, AND THAT WAS VERIFIED RATHER THAN ASSUMED. Under
# `Cluster` the forwarded second hop is sourced from the ingress node and carries Cilium's reserved
# `remote-node` identity. pihole-dns.yaml has to name `remote-node` explicitly in its
# CiliumNetworkPolicy — but that is because its policy deliberately omits `cluster`.
# crafty-allow-ingress admits `cluster`, and Cilium's `cluster` entity SUBSUMES `remote-node` (along
# with host/init/unmanaged). Confirmed on prod 2026-09-14 by dumping the crafty endpoint's datapath
# policy map (`cilium-dbg bpf policy get`) while the CNP was in its unmodified state:
# `reserved:remote-node` was already an Allow on 25565-25567/TCP and 19132-19134/UDP. Adding
# `remote-node` to that rule would be a no-op, so it is deliberately NOT added here — do not "fix" it.
#
# THE CLIENT-IP COST, STATED PLAINLY AND ACCEPTED. Cilium runs in SNAT mode here (no
# `loadBalancer.mode: dsr` in platform/cilium/helmrelease.yaml), so unlike UniFi — a KubeVirt VM whose
# `masquerade` binding already destroyed the client address below the Service — crafty is a plain pod
# and DOES lose a real client IP it was previously receiving. Every player will appear to the server
# subprocesses as the ingress node's address. What that actually costs here:
# * PLAYER IDENTITY IS UNAFFECTED. Minecraft Java authenticates against Mojang/Microsoft and Bedrock
# against Xbox Live; a player is a UUID, not an address. Vanilla `/ban` and the whitelist are
# UUID-keyed (banned-players.json, whitelist.json) and keep working exactly as before.
# * `/ban-ip` (banned-ips.json) DEGRADES, and would degrade dangerously: with every player behind one
# apparent address, an IP ban would hit EVERY player at once. Use `/ban` (by player), not
# `/ban-ip`, on this cluster. No IP ban is configured today.
# * Per-IP rate limiting / connection throttling degrades the same way — the whole LAN counts as one
# client. Nothing in platform/crafty configures any, and Crafty's own web UI is a separate,
# Keycloak-gated path that does not ride these Services.
# This is a real cost and it is worth paying: the alternative on offer is a Java port that is dead
# outright, and the only fix that keeps BOTH the client IP and a working VIP is `loadBalancer.mode:
# dsr` on the Cilium HelmRelease — a platform-wide change with an Ansible [parity] contract that rolls
# the CNI across all three nodes, which belongs in its own change, not in an outage fix.
#
# NO loadBalancerSourceRanges HERE, DELIBERATELY — this is where this file departs from
# pihole-dns.yaml. PiHole added `loadBalancerSourceRanges: [${LAN_CIDR}]` because it was giving up a
# restriction it had and genuinely needed to be LAN-only. These Services never had a source
# restriction at all (crafty-allow-ingress admits `world` on the game ports, and the block above is
# explicit that player auth — not network position — is the control). `Cluster` therefore removes no
# protection and there is nothing to compensate for. Pinning the game ports to ${LAN_CIDR} during an
# outage fix would be a NEW restriction that would cut off any player reaching these ports from off
# the LAN, trading this outage for a subtler one.
apiVersion: v1
kind: Service
metadata:
@@ -45,7 +103,7 @@ metadata:
external-dns.alpha.kubernetes.io/hostname: "crafty-mc.${BASE_DOMAIN}"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
externalTrafficPolicy: Cluster
selector:
app.kubernetes.io/name: crafty
ports:
@@ -65,7 +123,7 @@ metadata:
lbipam.cilium.io/ips: "${CRAFTY_L4_VIP}"
spec:
type: LoadBalancer
externalTrafficPolicy: Local
externalTrafficPolicy: Cluster
selector:
app.kubernetes.io/name: crafty
ports:
+84
View File
@@ -26,10 +26,94 @@ spec:
provider:
name: cloudflare
# Watch Gateway API routes (Cilium Gateway) + Services. docs/09: records rendered from intent.
#
# BOTH SOURCES ARE UNFILTERED HERE, AND THAT IS DELIBERATE — see the WHICH RESOURCES block
# below. Scoping is done PER RESOURCE by the renderer, not cluster-wide by this list.
sources:
- gateway-httproute
- service
# ---------------------------------------------------------------------------------------------
# WHICH RESOURCES MAY PUBLISH — `internal` services no longer can (R-DNS-03, 2026-09-14)
#
# THE DEFECT. `sources` above admits EVERY HTTPRoute on the shared Gateway, and each one's
# address is the Gateway VIP. docs/09 R-DNS-03 says `internal` services are unreachable from the
# public internet and that Pi-hole serves the internal split-horizon answers; the renderer
# contract said an `internal` service renders "no public DNS record". Neither sentence was
# enforced by anything, so six `exposure: internal` services (jackett, pihole, qbittorrent,
# radarr, semaphore, sonarr) published `<name>.home.keithjackson.dev` into this PUBLIC
# authoritative zone answering 192.168.2.20. That exposed the internal subnet, the Gateway VIP
# and a complete service inventory to anyone who could guess a label, and made every one of
# those names a DNS-rebind primitive — which is exactly why the SOHO router at 192.168.2.1
# strips RFC1918 answers and LAN clients need per-client dnsmasq overrides to reach their own
# services. Pre-state captured at the AUTHORITY and committed:
# .standup-dns/dns-20260914-pre-externaldns-scope.txt.
#
# THE MECHANISM — per-resource exclusion, emitted by catalog/render.py from `exposure`.
# An `internal` service's rendered HTTPRoute carries
# external-dns.alpha.kubernetes.io/controller: none
# ExternalDNS v0.21.0 (the DEPLOYED app version; read off the live prod Deployment, image
# registry.k8s.io/external-dns/external-dns:v0.21.0) skips any source object whose `controller`
# annotation is present and != its own `dns-controller`:
# source/annotations/processors.go IsControllerMismatch()
# source/gateway.go Endpoints(): if annotations.IsControllerMismatch(meta, src.rtKind) { continue }
# source/service.go Endpoints(): the identical check
# The gateway check runs BEFORE any hostname is resolved, so such a route cannot become an
# endpoint for any name it declares. PROVEN LIVE on prod before this landed, with a throwaway
# hostname and no existing record at risk: an annotated route published nothing at all, and
# ADDING the annotation to a route that already had a record produced
# action=DELETE record=<name> type=A and action=DELETE record=a-<name> type=TXT
# one ~60s reconcile later, and moved nothing else in the zone.
#
# WHY PER-RESOURCE AND NOT A CLUSTER-WIDE OPT-IN FILTER. The chart's `labelFilter`
# (--label-filter) is the stronger default posture — publish only what is labelled — and
# v0.21.0's own --help confirms BOTH configured sources support it ("currently supported by
# source types crd, gateway-httproute, ... service and ambassador-host"). It is NOT set here
# because its blast radius is not this defect's. Nine publishing objects in this repo are
# hand-authored platform/apps manifests with no `exposure` field for the renderer to read, and
# seven of them hold live records: auth (oauth2-proxy), crafty, crafty-mc, grafana, headlamp,
# the apex (homepage), unifi. Setting an opt-in filter DELETES all seven on the next reconcile,
# and whether each should be publicly resolvable at all is an OPERATOR decision per name, not a
# renderer one. They are enumerated in the shrink-only register in
# conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh, so the set
# cannot grow silently — adjudicate them there, then this filter becomes the right hardening.
#
# DELIBERATELY UNTOUCHED: `policy: sync`, `txtOwnerId`, `domainFilters`. Nothing above widens
# what this instance can see or own; per-resource exclusion only ever REMOVES an object from
# this instance's view, so it cannot make prod capable of deleting a record it does not own.
# ---------------------------------------------------------------------------------------------
# THE PUBLIC-TARGET GAP — STILL OPEN, AND NOT FIXABLE HERE ON v0.21.0 (operator decision)
#
# The five `exposure: public` services (plex, gitea, seerr, keycloak, nextcloud) publish records
# that point at 192.168.2.20 — the Gateway VIP, an unroutable RFC1918 address — so they are not
# actually reachable from the internet either. Their records are LEFT EXACTLY AS THEY ARE by
# this change, for two independent reasons:
#
# 1. THE VALUE IS NOT ESTABLISHED. The one public IP this repo knows is 136.51.115.252
# (clusters/prod/kustomization.yaml, where the zone's `*.keithjackson.dev` wildcard also
# points). It belongs to the LEGACY non-k3s stack and currently serves a placeholder
# certificate. Whether prod's public services belong behind it, behind a new forward, or
# are not meant to be public at all is an operator decision. Breaking five services' DNS
# on a guess is worse than leaving a known-wrong-but-stable state.
# 2. THE MECHANISM CANNOT BE SCOPED TO THEM. `external-dns.alpha.kubernetes.io/target` is a
# real override, but in v0.21.0's gateway-httproute source it is read ONLY from the
# GATEWAY (source/gateway.go: `overrides: annotations.TargetsFromTargetAnnotation(
# gw.Annotations)`) and from a ListenerSet listener — NEVER from the HTTPRoute. So there is
# no per-service target override while every public service shares the `shared` Gateway.
# Annotating the Gateway would move EVERY gateway-sourced record, which after this change
# still includes auth, crafty, grafana, headlamp and the apex — i.e. it would publish a
# routable address for admin UIs. That is strictly worse than the status quo.
#
# SO THE DECISION THE OPERATOR OWNS IS TWO-PART, and no knob is added here until the first half
# is answered, because a knob that cannot take effect invites someone to set it and expect one:
# (a) WHAT address should the five public names answer with — 136.51.115.252 as-is, a new
# port-forward/WAN address, or nothing (drop `exposure: public` to `internal` instead)?
# (b) HOW should they be separated from the non-public names that share the Gateway? Options,
# roughly in ascending cost: a SECOND Gateway (or a ListenerSet) carrying the target
# annotation, with only the public services' routes attached; or publishing the five names
# from the `crd` source (DNSEndpoint objects the renderer emits for `public` services),
# which restores per-service targeting without a second Gateway.
# Only (b) is a manifest change; (a) is a fact about the home WAN that has to be checked.
# ---------------------------------------------------------------------------------------------
# CROSS-CLUSTER DNS OWNERSHIP. Read before changing either knob below.
#
# On 2026-09-04 prod's external-dns DELETED EVERY ONE of dev's public records. Both clusters ran
@@ -0,0 +1,144 @@
# Serves: R-SEC-02 (CI holds no broad cluster access)
#
# THIS POLICY IS THE SECURITY BOUNDARY. The RBAC in conformance/incluster/rbac.yaml is not.
#
# Kubernetes places NO `use` check on a Pod/Job's `serviceAccountName`. Any principal that can
# `create jobs` in namespace `conformance` can therefore create a Job that runs as
# `conformance-runner` — a privileged SA (nodes/patch, pods/exec, namespaces/patch) — with an
# arbitrary command. So the CI trigger credential, however narrow its own Role looks, IS the
# privileged identity unless something constrains the SHAPE of what it may create.
#
# That is what this does: for Jobs created by `conformance-trigger` ONLY, admit exactly the
# committed conformance run and deny everything else. The operator chose this model
# (docs/plans/l2-l3-conformance-ci-wiring.md §4, primary model) over the zero-credential
# GitOps-native alternative.
#
# ENFORCE AND FAIL-CLOSED, NOT SUBSTITUTED. Note this does NOT use
# ${KYVERNO_VALIDATION_ACTION} like the other admission policies: a security boundary that an
# environment overlay can downgrade to Audit is not a boundary. `failurePolicy: Fail` means a
# Kyverno outage blocks conformance Jobs rather than silently admitting unconstrained ones.
# Blocking CI is the correct failure direction here; admitting arbitrary privileged code is not.
#
# SCOPE: it constrains ONLY `conformance-trigger`. Jobs created by humans, by Flux, or by any
# other SA are untouched — so a broken policy cannot wedge the rest of the cluster.
#
# IMAGE PINNING — HONEST LIMIT, READ BEFORE RELYING ON THIS. The plan specifies pinning the
# exact `CONFORMANCE_RUNNER_IMAGE` digest. That image does not exist yet, so this enforces the
# enforceable half: the image must live under the conformance repository path AND be
# digest-pinned (`@sha256:`), never a mutable tag. Residual surface: anyone who can PUSH to that
# registry path could publish a different digest. TIGHTEN THIS when the image is built by
# replacing the `image` pattern below with the literal digest — a one-line change, reviewed in a
# PR, which is exactly where a change to the security boundary belongs.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: conformance-trigger-job-shape
annotations:
policies.kyverno.io/title: Constrain what the conformance CI trigger may create
policies.kyverno.io/category: Supply chain
policies.kyverno.io/severity: critical
homelab.keithjackson.dev/requirements: R-SEC-02
homelab.keithjackson.dev/scope: admission-only
spec:
validationFailureAction: Enforce
failurePolicy: Fail
background: false
rules:
- name: trigger-may-create-only-the-committed-conformance-run
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
# As two separate `all` entries it is a schema error ("match resources not specified")
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
match:
all:
- resources:
kinds:
- Job
namespaces:
- conformance
subjects:
- kind: ServiceAccount
name: conformance-trigger
namespace: conformance
validate:
message: >-
conformance-trigger may create ONLY the committed conformance run: a single container,
digest-pinned conformance-runner image, serviceAccountName conformance-runner,
command ["./run.sh"], no host namespaces, non-root with privilege escalation disabled,
and only the `workspace` and `results` volumes. See
platform/kyverno/admission/conformance-trigger-job-shape.yaml.
pattern:
spec:
template:
spec:
serviceAccountName: conformance-runner
=(hostNetwork): "false"
=(hostPID): "false"
=(hostIPC): "false"
containers:
- image: "gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*"
command:
- "./run.sh"
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
=(privileged): "false"
- name: trigger-volumes-must-be-workspace-or-results
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
# As two separate `all` entries it is a schema error ("match resources not specified")
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
match:
all:
- resources:
kinds:
- Job
namespaces:
- conformance
subjects:
- kind: ServiceAccount
name: conformance-trigger
namespace: conformance
validate:
message: >-
conformance-trigger Jobs may mount only the `workspace` and `results` volumes. An extra
volume is how a constrained pod reaches a host path, a secret, or another SA's token.
# foreach, NOT a `pattern` list: a Kyverno array pattern asserts "at least one element
# matches", which would happily admit a Job carrying `workspace` PLUS a hostPath. This
# asserts EVERY volume is one of the two allowed names.
foreach:
- list: "request.object.spec.template.spec.volumes"
deny:
conditions:
all:
- key: "{{ element.name }}"
operator: AnyNotIn
value: ["workspace", "results"]
- name: trigger-args-must-be-the-committed-flags
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
# As two separate `all` entries it is a schema error ("match resources not specified")
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
match:
all:
- resources:
kinds:
- Job
namespaces:
- conformance
subjects:
- kind: ServiceAccount
name: conformance-trigger
namespace: conformance
validate:
message: >-
conformance-trigger Job args may contain only the conformance run's own flags
(--layer, --requirements, --include-scope, --strict-coverage) and their plain values.
Anything else — a shell, a path, a redirect — is denied.
foreach:
- list: "request.object.spec.template.spec.containers[0].args"
deny:
conditions:
all:
- key: "{{ regex_match('^(--layer|--requirements|--include-scope|--strict-coverage|[A-Za-z0-9][A-Za-z0-9,._-]*)$', element) }}"
operator: Equals
value: false
@@ -20,3 +20,7 @@ resources:
# --- secrets: the admission variant REPLACES the CI-only sops-block policy ---
# (../policies/disallow-plaintext-secrets.yaml is CI-only; see secret-provenance.yaml)
- secret-provenance.yaml
# --- R-SEC-02: constrain what the conformance CI trigger may create ---
# THE security boundary for CI cluster access. Enforce + fail-closed, deliberately NOT
# subject to ${KYVERNO_VALIDATION_ACTION}. See the file header.
- conformance-trigger-job-shape.yaml
@@ -0,0 +1,57 @@
# R-SEC-02 — the conformance CI trigger cannot escalate to arbitrary privileged execution.
#
# Each DENY case below is a real escalation path, not a style violation. The one that matters
# most is `evil-command`: correct SA, correct image, arbitrary command. That is the attack the
# whole model exists to stop, and RBAC alone cannot — Kubernetes places no `use` check on
# serviceAccountName, so `create jobs` in this namespace IS the privileged identity without
# this policy.
#
# user-info.yaml supplies the requesting identity. WITHOUT it the policy's `subjects` match
# never fires and every case trivially passes — a test that proves nothing.
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
name: conformance-trigger-job-shape
policies:
- ../../admission/conformance-trigger-job-shape.yaml
resources:
- resources.yaml
userinfo: user-info.yaml
results:
# ---- the committed run is ADMITTED ----
- policy: conformance-trigger-job-shape
rule: trigger-may-create-only-the-committed-conformance-run
kind: Job
resources: [good-run]
result: pass
- policy: conformance-trigger-job-shape
rule: trigger-volumes-must-be-workspace-or-results
kind: Job
resources: [good-run]
result: pass
- policy: conformance-trigger-job-shape
rule: trigger-args-must-be-the-committed-flags
kind: Job
resources: [good-run]
result: pass
# ---- THE escalation: runner SA + arbitrary command ----
- policy: conformance-trigger-job-shape
rule: trigger-may-create-only-the-committed-conformance-run
kind: Job
resources: [evil-command, evil-image, tag-not-digest, host-namespaces]
result: fail
# ---- an extra volume is the way out of a constrained pod ----
- policy: conformance-trigger-job-shape
rule: trigger-volumes-must-be-workspace-or-results
kind: Job
resources: [extra-volume]
result: fail
# ---- args beyond the committed flags ----
- policy: conformance-trigger-job-shape
rule: trigger-args-must-be-the-committed-flags
kind: Job
resources: [injected-args]
result: fail
@@ -0,0 +1,154 @@
# The committed shape — must be ADMITTED.
apiVersion: batch/v1
kind: Job
metadata:
name: good-run
namespace: conformance
spec:
template:
spec:
serviceAccountName: conformance-runner
containers:
- name: runner
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
command: ["./run.sh"]
args: ["--layer", "l2,l3", "--requirements", "R-NET-01"]
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
volumes:
- name: workspace
emptyDir: {}
- name: results
emptyDir: {}
---
# THE ESCALATION: runner SA, but an arbitrary command. Must be DENIED.
apiVersion: batch/v1
kind: Job
metadata:
name: evil-command
namespace: conformance
spec:
template:
spec:
serviceAccountName: conformance-runner
containers:
- name: runner
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
command: ["/bin/sh", "-c", "kubectl get secrets -A"]
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
volumes:
- name: workspace
emptyDir: {}
---
# An arbitrary image running as the privileged SA. Must be DENIED.
apiVersion: batch/v1
kind: Job
metadata:
name: evil-image
namespace: conformance
spec:
template:
spec:
serviceAccountName: conformance-runner
containers:
- name: runner
image: docker.io/library/alpine:latest
command: ["./run.sh"]
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
volumes:
- name: workspace
emptyDir: {}
---
# A mutable TAG instead of a digest — republishable under the same reference. Must be DENIED.
apiVersion: batch/v1
kind: Job
metadata:
name: tag-not-digest
namespace: conformance
spec:
template:
spec:
serviceAccountName: conformance-runner
containers:
- name: runner
image: gitea.home.keithjackson.dev/home-network/conformance-runner:latest
command: ["./run.sh"]
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
volumes:
- name: workspace
emptyDir: {}
---
# Host namespaces + privilege escalation. Must be DENIED.
apiVersion: batch/v1
kind: Job
metadata:
name: host-namespaces
namespace: conformance
spec:
template:
spec:
serviceAccountName: conformance-runner
hostPID: true
containers:
- name: runner
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
command: ["./run.sh"]
securityContext:
runAsNonRoot: false
allowPrivilegeEscalation: true
volumes:
- name: workspace
emptyDir: {}
---
# An extra volume — the way out of a constrained pod. Must be DENIED.
apiVersion: batch/v1
kind: Job
metadata:
name: extra-volume
namespace: conformance
spec:
template:
spec:
serviceAccountName: conformance-runner
containers:
- name: runner
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
command: ["./run.sh"]
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
volumes:
- name: workspace
emptyDir: {}
- name: hostroot
hostPath:
path: /
---
# Injected args beyond the committed flags. Must be DENIED.
apiVersion: batch/v1
kind: Job
metadata:
name: injected-args
namespace: conformance
spec:
template:
spec:
serviceAccountName: conformance-runner
containers:
- name: runner
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
command: ["./run.sh"]
args: ["--layer", "l2", ";", "curl", "http://evil/"]
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
volumes:
- name: workspace
emptyDir: {}
@@ -0,0 +1,12 @@
# The requesting identity the policy keys on. Without this the `subjects` match never fires and
# every case below would trivially "pass" — proving nothing.
apiVersion: cli.kyverno.io/v1alpha1
kind: UserInfo
metadata:
name: conformance-trigger
userInfo:
username: system:serviceaccount:conformance:conformance-trigger
groups:
- system:serviceaccounts
- system:serviceaccounts:conformance
- system:authenticated
+126
View File
@@ -904,6 +904,132 @@ spec:
summary: "Egress VPN tunnel down"
description: "The egress-gateway VPN tunnel is down (or its metric is absent) for 5m — outbound traffic could leak or stall (leak risk, docs/07)."
runbook_url: "https://${BASE_DOMAIN}/runbooks/vpn-down"
# -----------------------------------------------------------------------------------------
# THE BLIND SPOT `VPNTunnelDown` LEAVES (added 2026-09-12) — R-OBS-04, R-NET-06
#
# `VPNTunnelDown` goes green the instant a tun0 exists. That is not the same as vpn-class pods
# having egress, because Cilium resolves a CiliumEgressGatewayPolicy's `interface: tun0`
# exactly ONCE and caches an (egress IP, egress ifindex) pair. Every OpenVPN reconnect
# re-creates tun0 with a NEW ifindex, so after the first reconnect the BPF egress map points
# at a DESTROYED interface: `bpf_redirect()` fails and the packets are dropped — 0 out tun0,
# 0 out the WAN (measured; there is no leak, there is simply no egress). tun0 is present and
# carrying bytes the whole time, so `VPNTunnelDown` stays green. The dev gateway sat in
# exactly that state for ~80 minutes on 2026-09-12 with nothing alerting.
#
# The datapath now self-heals: `roles/vpn_egress_gateway` publishes
# `homelab.keithjackson.dev/egress-uplink=<tun0 addr>-<ifindex>` on the gateway node from the
# OpenVPN up-script on every connect (plus a 2-minute convergence timer for the boot case),
# and that node write is what makes Cilium re-resolve. These two rules watch whether it
# ACTUALLY converged — if the re-steer mechanism itself fails we are back to a silent outage.
#
# WHY THIS SIGNAL AND NOT THE MAP ITSELF. The direct invariant is "the gateway node's
# `egress-uplink` label == the Egress Ifindex in Cilium's BPF egress map". The map side is NOT
# observable in Prometheus, verified on prod 2026-09-12 rather than assumed:
# * the cilium-agent DaemonSet exposes no metrics port at all (`health=9879`,
# `peer-service=4244`; only the OPERATOR has `operator-prometheus-serve-addr :9963`), and
# there is no ServiceMonitor for cilium or hubble on either cluster — Hubble/Cilium
# metrics export is still the open TODO in platform/cilium/helmrelease.yaml. Every
# `cilium_*` and `hubble_*` query returns an empty vector today (which also means
# `HubbleDeniedFlowSpike` above cannot fire yet).
# * and even with agent metrics enabled, Cilium OSS publishes NO series carrying egress-map
# CONTENTS. The map is readable only via `cilium-dbg bpf egress list` — a CLI over the BPF
# map, not an exporter. No metric name is invented here to paper over that.
# So the observable is the label: the input to the re-steer, compared against the live tun0
# identity that node-exporter does export (`node_network_iface_id{device="tun0"}` — the real
# ifindex, 129 on k3s-prod-3 right now, matching both the label `10.150.192.86-129` and
# `cilium-dbg bpf egress list`'s `Egress Ifindex 129`, checked by hand on the live cluster).
#
# WHAT THIS DOES AND DOES NOT CATCH. It catches the whole input half: the up-script not
# firing, the label write failing or never being deployed, the convergence timer being dead —
# i.e. every way the tun0 identity can move without Cilium being told. It does NOT catch
# "the label was written and Cilium still did not re-resolve"; nothing in Prometheus can,
# until either the re-steer script exports the map's ifindex through a node-exporter textfile
# collector (DECISIONS-LOG 2026-09-12, hand-off item 4) or a vpn-class black-box probe exists.
# A probe was considered and rejected for now: there is no blackbox-exporter and no probe
# CronJob in this repo, so it means a NEW always-on component whose own failures become a new
# alerting surface — worth doing, but not as the thing that closes a hole today.
#
# WHY A `changes()` COMPARISON RATHER THAN AN EQUALITY TEST. PromQL cannot compare a metric
# VALUE (the live ifindex) against a number embedded in a LABEL VALUE (`...-129`): there is no
# label-to-number conversion, so the level-triggered equality test is not expressible. What IS
# expressible is the same invariant counted over a window: the published label must change at
# least as often as tun0 is re-created. `count(count_over_time(max by (<label>) (...)[1h:1m]))`
# is the number of DISTINCT label values seen in the last hour (verified live: 1 for a label
# that held still, 2 for one that changed value inside the window); the `max by (<label>)`
# wrapper is load-bearing — it aggregates away `pod`/`instance`, so a kube-state-metrics
# restart is not mistaken for a label change. Both sides reduce to a single unlabelled series,
# which is why `and on()` joins cleanly, exactly as `VPNTunnelDown` does.
#
# Consequence, stated plainly: this is bounded by its window. A missed flap is alerted from
# +15m to +1h and then the alert RESOLVES even if egress is still dead. That is a deliberate
# trade against the repo's alert-fatigue history (an unbounded critical on the one channel
# that must stay trustworthy is worse), and it still turns an 80-minute silent outage into an
# email within 15 minutes. The textfile-collector gauge is what would make it level-triggered.
- alert: VPNEgressSteeringStale
# FIRES when the live tun0 was re-created more times in the last hour than the gateway
# node's `egress-uplink` label changed — i.e. Cilium was not told about at least one
# tunnel reconnect, so the BPF egress map is pointing at an ifindex that no longer exists
# and vpn-class pods have no egress. Convergence is NOT this: the label is rewritten within
# seconds of a connect (worst case ~5m, the up-script's 30 x 10s kube-API retry budget on a
# cold boot), so `for: 15m` cannot fire on a healthy reconnect.
expr: |
(
changes(max(node_network_iface_id{device="tun0"})[1h:1m])
>
(
count(
count_over_time(
max by (label_homelab_keithjackson_dev_egress_uplink) (
kube_node_labels{label_homelab_keithjackson_dev_egress_uplink!=""}
)[1h:1m]
)
) - 1
)
)
and on()
(count(kube_node_labels{label_homelab_keithjackson_dev_egress_gateway="true"}) > 0)
for: 15m
labels:
severity: critical
annotations:
summary: "VPN egress steering is stale — vpn-class pods have no egress"
description: >-
The gateway node's tun0 was re-created without the `egress-uplink` label following it, so
Cilium's cached egress ifindex points at a destroyed interface: egress-class=vpn pods are
being dropped, silently, while tun0 still looks healthy. Compare
`kubectl get nodes -L homelab.keithjackson.dev/egress-uplink` against
`cilium-dbg bpf egress list` on the gateway node; re-applying the CiliumEgressGatewayPolicy
(or setting any node label) forces a fresh resolution.
runbook_url: "https://${BASE_DOMAIN}/runbooks/vpn-egress-stale"
- alert: VPNEgressUplinkLabelMissing
# The watchdog for the rule above: `VPNEgressSteeringStale` needs the `egress-uplink` label
# in kube-state-metrics, and if that label is missing its comparison degrades to an empty
# vector — it fails OPEN, alerting nothing. So alert on the precondition itself. A gateway
# node with no published uplink means the re-steer mechanism is not in place at all
# (roles/vpn_egress_gateway not applied on this node, the up-script never ran, or the
# kube-state-metrics label allowlist in kube-prometheus-stack.yaml lost the entry), which is
# the configuration that produced the 80-minute dev outage. Warning, not critical: it is a
# rollout/config gap to fix in working hours, not a page. `for: 30m` so that the Helm upgrade
# that rolls kube-state-metrics — both changes ship in this same kustomization — cannot page
# on the way in, and so an Ansible run in progress does not either.
expr: |
(count(kube_node_labels{label_homelab_keithjackson_dev_egress_gateway="true"}) > 0)
and on()
absent(kube_node_labels{label_homelab_keithjackson_dev_egress_uplink!=""})
for: 30m
labels:
severity: warning
annotations:
summary: "Egress gateway node publishes no egress-uplink label"
description: >-
A node carries `homelab.keithjackson.dev/egress-gateway=true` but no
`homelab.keithjackson.dev/egress-uplink` label is visible in kube-state-metrics. Without it
nothing can tell whether Cilium's egress steering followed the last tunnel reconnect, and
`VPNEgressSteeringStale` is inert. Check the label on the node, then the
`vpn-egress-resteer` service/timer on the gateway node, then the kube-state-metrics
metricLabelsAllowlist.
runbook_url: "https://${BASE_DOMAIN}/runbooks/vpn-egress-stale"
# -----------------------------------------------------------------------------------------
# (ADR-0015) The former `EgressGatewayUnhealthy` pod-readiness alert was REMOVED with the gluetun
# retirement: there is no in-cluster egress-gateway pod anymore (the tunnel is a host systemd
# service, steering is Cilium eBPF). Tunnel + gateway-node health is covered by `VPNTunnelDown`
@@ -231,7 +231,7 @@ spec:
# 3 extra series, no cardinality concern. Label name is sanitized by KSM to
# `label_homelab_keithjackson_dev_egress_gateway` (`label_` + non-alphanumerics -> `_`).
metricLabelsAllowlist:
- "nodes=[homelab.keithjackson.dev/egress-gateway]"
- "nodes=[homelab.keithjackson.dev/egress-gateway,homelab.keithjackson.dev/egress-uplink]"
# --- VELERO SCHEDULE OBJECTS AS METRICS (added 2026-09-13) ---------------------------------
# Velero exports NOTHING that enumerates Schedule objects and nothing that exposes
# `spec.paused`. Verified on prod against velero v1.18.1: all 35 velero_* metric names that
+7 -3
View File
@@ -116,10 +116,14 @@ fi
# `.git` file points outside the mount, so git cannot read the index there — and with
# `pipefail` that failure silently emptied the discovery, i.e. a green run over ZERO tests
# (observed 2026-09-12). `find` needs no repo metadata, so discovery cannot be disabled by
# how the tree happens to be mounted. .git/ is pruned so a stale object copy is never picked up.
# how the tree happens to be mounted. .git/ is pruned so a stale object copy is never picked up,
# and .claude/ with it: .claude/worktrees/agent-*/ holds full repo copies for isolated agent
# runs, so an unpruned find reported the SAME manifest under test once per worktree (up to 18
# times), and the L0 check that compares this listing against the tree failed on paths that
# are not authored surface at all (.claude/ is gitignored, .gitignore:43).
discover() {
# Companion convention, anywhere in the tree.
find "${ROOT}" -path "${ROOT}/.git" -prune -o -name '*.promtool-test.yaml' -print 2>/dev/null
find "${ROOT}" \( -path "${ROOT}/.git" -o -path "${ROOT}/.claude" \) -prune -o -name '*.promtool-test.yaml' -print 2>/dev/null
# Suite convention, ANYWHERE in the tree — not just this directory.
#
# This used to be `find "${HERE}" -maxdepth 1`, which made the suite convention a
@@ -129,7 +133,7 @@ discover() {
# as the rules this harness exists to test. Found 2026-09-13 adding apps/wave-*/tests/. Scoped by
# NAME only; manifests_for() then refuses (loudly) any *.test.yaml that is not inside a tests/
# directory, so a misplaced file fails rather than disappearing.
find "${ROOT}" -path "${ROOT}/.git" -prune -o -name '*.test.yaml' -print 2>/dev/null
find "${ROOT}" \( -path "${ROOT}/.git" -o -path "${ROOT}/.claude" \) -prune -o -name '*.test.yaml' -print 2>/dev/null
}
TESTS=()
@@ -0,0 +1,188 @@
# promtool rule-unit tests for VPNEgressSteeringStale + VPNEgressUplinkLabelMissing
# (platform/monitoring/alerts.yaml)
#
# Run with: platform/monitoring/tests/run.sh
# (the merge gate runs the same harness via
# conformance/tests/l0-static/alert-rules-unit-tested)
#
# WHAT THESE RULES CLAIM, AND WHY IT NEEDS PINNING IN BOTH DIRECTIONS
# `egress-class=vpn` pods leave through one gateway node's tun0. Cilium resolves that interface to an
# IFINDEX and caches it; an OpenVPN reconnect destroys and re-creates tun0 with a NEW ifindex, and the
# gateway node's up-script publishes `homelab.keithjackson.dev/egress-uplink=<addr>-<ifindex>` so that
# node write forces Cilium to re-resolve. If the label does not follow the tunnel, the cache points at a
# destroyed interface and every vpn-class pod is dropped — while tun0 itself stays up, so VPNTunnelDown
# says nothing. That shape ran ~80 minutes undetected on the dev gateway.
#
# VPNEgressSteeringStale detects it by comparing HOW MANY TIMES tun0's ifindex changed in the last hour
# against HOW MANY DISTINCT `egress-uplink` label values appeared in the same hour. The subtlety worth
# testing is that a label VALUE change is not a sample change: it RETIRES one series and STARTS another,
# so "the label followed the tunnel" shows up as two overlapping series in the window, not as a
# changing value. Tests 1 and 2 are the same tunnel reconnect with and without that second series, so
# they isolate exactly that mechanism — anything that broke the series-counting half would make test 2
# fire, and anything that broke the tun0 half would make test 1 quiet.
#
# VPNEgressUplinkLabelMissing is the WATCHDOG for the rule above: with no uplink label anywhere, the
# staleness comparison has nothing to compare and is structurally INERT — present, evaluating, and
# incapable of firing. Tests 3 and 4 pin that, because an inert critical alert is the failure mode this
# whole group exists to prevent.
#
# Fixture values are prod's real shape: gateway k3s-prod-3, tun0 ifindex 139 with address
# 10.150.192.86 (live label value `10.150.192.86-139`), reconnecting to ifindex 140.
rule_files:
- generated.rules.yaml
evaluation_interval: 1m
tests:
# =============================================================================================
# 1. THE BUG — tun0 was re-created, the label did NOT follow. Steering is stale: FIRES.
# =============================================================================================
- interval: 1m
name: tunnel reconnect with no label update - VPNEgressSteeringStale fires
input_series:
# tun0 re-created at t=41m: ifindex 139 -> 140. One change inside the trailing hour.
- series: 'node_network_iface_id{job="node-exporter",instance="192.168.2.12:9100",device="tun0"}'
values: '139+0x40 140+0x40'
# tun0 is UP and passing traffic the whole time — this is what makes the failure deceptive, and
# it is asserted below: VPNTunnelDown stays QUIET while egress is actually broken.
- series: 'node_network_receive_bytes_total{job="node-exporter",instance="192.168.2.12:9100",device="tun0"}'
values: '0+120000x80'
# The node label NEVER changes, so only ONE distinct uplink value exists in the window.
- series: 'kube_node_labels{job="kube-state-metrics",node="k3s-prod-3",label_homelab_keithjackson_dev_egress_gateway="true",label_homelab_keithjackson_dev_egress_uplink="10.150.192.86-139"}'
values: '1+0x80'
alert_rule_test:
# 1 tunnel change > (1 distinct label value - 1) = 0, held for 15m.
- eval_time: 60m
alertname: VPNEgressSteeringStale
exp_alerts:
- exp_labels:
severity: critical
exp_annotations:
summary: "VPN egress steering is stale — vpn-class pods have no egress"
description: >-
The gateway node's tun0 was re-created without the `egress-uplink` label following it, so
Cilium's cached egress ifindex points at a destroyed interface: egress-class=vpn pods are
being dropped, silently, while tun0 still looks healthy. Compare `kubectl get nodes -L
homelab.keithjackson.dev/egress-uplink` against `cilium-dbg bpf egress list` on the
gateway node; re-applying the CiliumEgressGatewayPolicy (or setting any node label)
forces a fresh resolution.
runbook_url: "https://${BASE_DOMAIN}/runbooks/vpn-egress-stale"
# Not yet: at 45m the condition has been true only ~4m, under the 15m `for`.
- eval_time: 45m
alertname: VPNEgressSteeringStale
exp_alerts: []
# No gateway label AND no tun0 history, so VPNTunnelDown's `or` guard is false: a cluster with
# no VPN gateway must not be told its tunnel is down forever.
- eval_time: 45m
alertname: VPNTunnelDown
exp_alerts: []
# The watchdog must stay quiet — the label IS present here.
- eval_time: 60m
alertname: VPNEgressUplinkLabelMissing
exp_alerts: []
# THE HEADLINE CLAIM, asserted rather than asserted-in-prose: the tunnel alert says NOTHING
# while vpn-class egress is completely broken. If VPNTunnelDown covered this failure, the
# staleness rule would not need to exist.
- eval_time: 60m
alertname: VPNTunnelDown
exp_alerts: []
# =============================================================================================
# 2. THE HEALTHY RECONNECT — same tunnel change, but the label followed it. STAYS QUIET.
# This is the half that proves the rule is not simply "any reconnect alerts".
# =============================================================================================
- interval: 1m
name: tunnel reconnect WITH the label following - stays quiet
input_series:
- series: 'node_network_iface_id{job="node-exporter",instance="192.168.2.12:9100",device="tun0"}'
values: '139+0x40 140+0x40'
- series: 'node_network_receive_bytes_total{job="node-exporter",instance="192.168.2.12:9100",device="tun0"}'
values: '0+120000x80'
# A label VALUE change retires one series and starts another. Both are inside the trailing
# hour, so the window holds TWO distinct uplink values: 2 - 1 = 1, which is NOT < 1 change.
- series: 'kube_node_labels{job="kube-state-metrics",node="k3s-prod-3",label_homelab_keithjackson_dev_egress_gateway="true",label_homelab_keithjackson_dev_egress_uplink="10.150.192.86-139"}'
values: '1+0x40 _x40'
- series: 'kube_node_labels{job="kube-state-metrics",node="k3s-prod-3",label_homelab_keithjackson_dev_egress_gateway="true",label_homelab_keithjackson_dev_egress_uplink="10.150.192.86-140"}'
values: '_x41 1+0x39'
alert_rule_test:
- eval_time: 60m
alertname: VPNEgressSteeringStale
exp_alerts: []
- eval_time: 75m
alertname: VPNEgressSteeringStale
exp_alerts: []
- eval_time: 60m
alertname: VPNEgressUplinkLabelMissing
exp_alerts: []
- eval_time: 60m
alertname: VPNTunnelDown
exp_alerts: []
# =============================================================================================
# 3. THE INERT-ALERT CASE — a gateway node publishing NO uplink label at all.
# VPNEgressSteeringStale cannot fire in this state, which is exactly why this warning exists.
# =============================================================================================
- interval: 1m
name: gateway node with no egress-uplink label - VPNEgressUplinkLabelMissing fires
input_series:
- series: 'kube_node_labels{job="kube-state-metrics",node="k3s-prod-3",label_homelab_keithjackson_dev_egress_gateway="true"}'
values: '1+0x60'
# tun0 is reconnecting the whole time. The staleness rule STILL cannot fire, because it has no
# label series to compare against — that is the inertness being alerted on.
- series: 'node_network_iface_id{job="node-exporter",instance="192.168.2.12:9100",device="tun0"}'
values: '139+0x30 140+0x30'
- series: 'node_network_receive_bytes_total{job="node-exporter",instance="192.168.2.12:9100",device="tun0"}'
values: '0+120000x60'
alert_rule_test:
- eval_time: 35m
alertname: VPNEgressUplinkLabelMissing
exp_alerts:
- exp_labels:
severity: warning
exp_annotations:
summary: "Egress gateway node publishes no egress-uplink label"
description: >-
A node carries `homelab.keithjackson.dev/egress-gateway=true` but no
`homelab.keithjackson.dev/egress-uplink` label is visible in kube-state-metrics. Without
it nothing can tell whether Cilium's egress steering followed the last tunnel reconnect,
and `VPNEgressSteeringStale` is inert. Check the label on the node, then the
`vpn-egress-resteer` service/timer on the gateway node, then the kube-state-metrics
metricLabelsAllowlist.
runbook_url: "https://${BASE_DOMAIN}/runbooks/vpn-egress-stale"
# Under the 30m `for`.
- eval_time: 20m
alertname: VPNEgressUplinkLabelMissing
exp_alerts: []
# And the proof of inertness: the critical rule is silent despite a real reconnect.
- eval_time: 55m
alertname: VPNEgressSteeringStale
exp_alerts: []
- eval_time: 55m
alertname: VPNTunnelDown
exp_alerts: []
# =============================================================================================
# 4. NO GATEWAY NODE AT ALL — neither rule may fire. Both are guarded by an `and on()` on the
# egress-gateway label so they stay silent on a cluster with no VPN gateway, rather than
# alerting forever about an absent feature.
# =============================================================================================
- interval: 1m
name: no egress-gateway node - both rules silent
input_series:
- series: 'kube_node_labels{job="kube-state-metrics",node="k3s-prod-1"}'
values: '1+0x60'
- series: 'node_network_iface_id{job="node-exporter",instance="192.168.2.10:9100",device="eth0"}'
values: '2+0x60'
alert_rule_test:
- eval_time: 45m
alertname: VPNEgressUplinkLabelMissing
exp_alerts: []
- eval_time: 45m
alertname: VPNEgressSteeringStale
exp_alerts: []
# No gateway label AND no tun0 history, so VPNTunnelDown's `or` guard is false: a cluster with
# no VPN gateway must not be told its tunnel is down forever.
- eval_time: 45m
alertname: VPNTunnelDown
exp_alerts: []
+36
View File
@@ -24,6 +24,36 @@
# pod, and nothing tells them. A volume policy tests the PROPERTY that makes a volume bulk-tier —
# its source is the out-of-cluster NFS export — so it covers volumes that do not exist yet.
#
# RULE 2 — emptyDir (added 2026-09-19). Every emptyDir volume is skipped, because an emptyDir holds
# no durable state BY DEFINITION: created empty with its pod, destroyed with it. Backing one up
# cannot protect anything, and ATTEMPTING it actively breaks the backup.
#
# HOW IT BREAKS THINGS. A PodVolumeBackup exposes the volume through its pod; if that pod is replaced
# mid-flight the PVB fails with `error to expose PVB: error getting client pod <pod>: pods "<pod>"
# not found`, and ONE failed PVB makes the whole Backup PartiallyFailed. PartiallyFailed does not
# advance velero_backup_last_successful_timestamp, so VeleroNoRecentBackup fires CRITICAL over a
# backup that protected every real volume. Any Deployment rollout landing in the backup window is
# enough — on a GitOps cluster that is routine, not an edge case. An alert that cries wolf nightly is
# how a real backup failure gets ignored, and this one guards R-STATE-03.
#
# OBSERVED TWICE IN ONE HOUR in unrelated namespaces, which is what justified a general rule over a
# targeted exclusion:
# * ns velero — Velero's OWN transient kopia repository-maintenance Jobs
# (`<ns>-default-kopia-maintain-job-<ts>`), emptyDir `plugins` + `scratch`. Four
# failed PVBs had accumulated; velero-daily-20260919030041 was PartiallyFailed,
# 2 errors, 3079 of 3079 items backed up.
# * ns runbooks — the nginx Deployment's emptyDir `tmp` + `cache` (a read-only root filesystem needs
# writable scratch). Four failed PVBs when a runbook edit rolled the Deployment
# while the backup was running.
#
# PROVEN ON PROD, WITH A CONTROL (2026-09-19, velero 1.18.1). `volumeTypes` support was the open
# question, and a policies.yaml Velero cannot parse breaks EVERY backup — so it was tested against a
# THROWAWAY ConfigMap first, leaving this one untouched until it passed:
# control, ns runbooks, NO policy -> 4 PodVolumeBackups (2x `tmp`, 2x `cache`), Completed
# same namespace, WITH this policy -> 0 PodVolumeBackups, Backup Completed, 0 errors
# The control is the point: "zero PVBs" on its own is equally consistent with nothing being attempted.
#
# `nfs: {}` is the documented "match any NFS volume source" condition (server/path may be given to
# narrow it; deliberately not, so a second export is covered too). First matching rule wins.
#
@@ -44,6 +74,12 @@ data:
policies.yaml: |
version: v1
volumePolicies:
# emptyDir first: cheapest condition, and the most common match.
- conditions:
volumeTypes:
- emptyDir
action:
type: skip
- conditions:
nfs: {}
action:
+25
View File
@@ -157,6 +157,31 @@ kubectl -n kube-system exec <cilium-agent-on-the-gateway> -c cilium-agent -- \
If they disagree, the re-steer path is broken — `journalctl -u vpn-egress-resteer` on the
gateway node is the next stop.
### And now something watches it (2026-09-12)
The self-heal closed the datapath hole; it did not close the monitoring one, and a mechanism that
silently stops working is the same outage again. Two rules in `platform/monitoring/alerts.yaml`:
- **`VPNEgressSteeringStale`** (critical, `for: 15m`) — fires when the live tun0 was re-created
more times in the last hour than the `egress-uplink` label changed, i.e. Cilium was never told
about a reconnect. This is the case `VPNTunnelDown` cannot see: tun0 present, bytes flowing,
steering pointed at a destroyed ifindex.
- **`VPNEgressUplinkLabelMissing`** (warning, `for: 30m`) — fires when a node carries
`egress-gateway=true` but no `egress-uplink` label reaches kube-state-metrics, which is the state
in which the rule above would be inert. The watchdog for the watchdog.
Both are gated on a gateway node existing (the same `kube_node_labels` guard `VPNTunnelDown` uses),
so a cluster with no gateway cannot be made to fire them. `egress-uplink` had to be added to
`kube-state-metrics.metricLabelsAllowlist` in `platform/monitoring/kube-prometheus-stack.yaml` for
the label to exist as a metric at all — the two changes must ship together.
**What still is not watched:** "the label was written and Cilium still ignored it." Cilium OSS
exposes no metric carrying the egress map's contents (the map is readable only through
`cilium-dbg bpf egress list`), and the cilium-agent is not scraped on either cluster in the first
place — no `cilium_*` or `hubble_*` series exist. Closing that last gap means either exporting the
map's ifindex from the re-steer script through a node-exporter textfile collector, or a black-box
probe from a vpn-class pod. Neither exists yet.
## Resilience — there is NO gateway failover (spec correction, escalated)
**ADR-0015 §Decision says "Two egress-gateway nodes + Cilium Egress Gateway HA (gateway failover)
+14 -4
View File
@@ -6,7 +6,7 @@ Every alert rule in this platform carries an annotation of the form
runbook_url: "https://${BASE_DOMAIN}/runbooks/<name>"
46 of them, over the 35 names below. Until this directory existed, that path returned **HTTP 404** on the
46 of them, over 35 names. Until this directory existed, that path returned **HTTP 404** on the
live cluster: every alert that reached a human handed them a dead link at the exact moment they needed
help. These pages are what makes the link resolve.
@@ -33,7 +33,8 @@ negative is how a CLI quirk becomes real data loss. Write `backups.velero.io`,
`backups.postgresql.cnpg.io`, `clusters.postgresql.cnpg.io`, `settings.longhorn.io`,
`volumes.longhorn.io`, `nodes.longhorn.io`.
`git push origin main` **is** the deploy; there is no other apply step. Durable changes go through git.
Merging a PR into `main` **is** the deploy; there is no other apply step. `main` is protected, so a
direct push is rejected — durable changes go through a branch, a PR, and a green gate.
## Alerts you should not trust the silence of (measured 2026-09-13)
@@ -84,8 +85,10 @@ Each affected runbook says so on its own page and gives the by-hand equivalent.
- **`cert-expiry`** — a certificate is expiring. Almost always a late symptom of `cert-issuance`.
### Networking
- **`vpn-down`** — the egress VPN tunnel. Includes the silent egress-gateway staleness that this alert
cannot see, and what the kill-switch actually does.
- **`vpn-down`** — the egress VPN tunnel. Explains what the kill-switch actually does, and the silent
egress-gateway staleness this alert cannot see — which now has its own alerts and page below.
- **`vpn-egress-stale`** — the tunnel is UP but `egress-class=vpn` pods have no egress: Cilium's cached
ifindex outlived a reconnect. Also covers the watchdog for that rule going inert.
- **`denied-flows`** — policy-denied flow spike. The Cilium hairpin/identity regression class.
### Backups and DR
@@ -106,6 +109,13 @@ Each affected runbook says so on its own page and gives the by-hand equivalent.
- **`keycloak-client-secrets`** — the client/URL reconciler failing or silent. The 2026-09-06 lockout that
every green signal missed.
### Operator procedures (no alert links here)
- **`plex-claim`** — claiming the Plex Media Server. **Claimed 2026-09-29**; the page now only
applies after a `/config` wipe. The claim token is short-lived — read the countdown on
plex.tv/claim — so it is a timed sequence, scripted as `infra/plex/claim-ship.sh`:
token → `sops` → PR → gate → merge → **forced** `flux reconcile` → pod roll (~2m45s measured).
Includes why a changed Secret does not restart anything, and why an expired token is harmless.
### Virtualisation
- **`kubevirt-control-plane`** — VM lifecycle operations will fail; running VMs keep running.
- **`kubevirt-launch-failed`** — a VMI reached the Failed phase. The three k3s-specific launch traps.
+4 -2
View File
@@ -62,8 +62,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -61,8 +61,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -78,8 +78,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -55,8 +55,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -62,8 +62,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -63,8 +63,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -87,8 +87,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -69,8 +69,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -71,8 +71,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -68,8 +68,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -61,8 +61,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -62,8 +62,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+5 -3
View File
@@ -7,7 +7,7 @@
## What this means here
Something in the GitOps chain is stuck at Ready=False. On this cluster that is more consequential than
it sounds, because **`git push origin main` IS the deploy** — there is no other apply step. A stuck
it sounds, because **merging a PR into `main` IS the deploy** — there is no other apply step. A stuck
Kustomization means git and the cluster have diverged and nothing will close the gap.
The chain has **14 tiers**: `flux-system` → `cilium` → `platform` → `platform-config` →
@@ -61,8 +61,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -58,8 +58,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -53,8 +53,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -53,8 +53,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
+4 -2
View File
@@ -87,8 +87,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
Where this runbook gives a mutating command it is for an operator, and it says so.
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
and it says so.
## First commands
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more