Compare commits

...
Author SHA1 Message Date
jakman85andClaude Opus 5.5 0c6ee4921d fix(renovate): document and probe the five token scopes Renovate needs; lint daemon-bound actions
gate / gate (pull_request) Successful in 2m54s
gate / conformance (pull_request) Has been skipped
Run 350 cleared platform init (/user 200, /version 200, /repos 200), then stopped at
GET /api/v1/orgs/Home-Network with Gitea's own 403:
  token does not have at least one of required scope(s), required=[read:organization],
  token scope=read:package,write:repository,read:user
The workflow's header listed only those three scopes, and the operator minted exactly that.
Renovate's initRepo -> isOrg needs read:organization, and Gitea files /issues and /labels under
the issue scope, which the dashboard and PR labels need: write:issue.

- renovate.yaml: corrects the scope list (read:user, read:organization, write:repository,
  write:issue, read:package), with the call each serves. A new preflight step probes them with
  GETs, the token passed to curl on stdin, and names a missing scope instead of Renovate's bare
  "Authentication failure".
- ci/lint.sh: rejects workflow `uses:` lines that need a Docker daemon (docker://,
  renovatebot/github-action, docker/*-action), the class fixed in #84.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:55:39 -05:00
jakman85 d550a501e2 Merge pull request 'fix(renovate): run Renovate as the job container; the action needs a Docker daemon jobs do not get' (#84) from fix/renovate-first-run into main
gate / gate (push) Successful in 2m52s
gate / conformance (push) Has been skipped
Reviewed-on: #84
Reviewed-by: Keith Jackson <[email protected]>
2026-10-30 06:36:11 +00:00
jakman85andClaude Opus 5.5 365e43cdea fix(renovate): run Renovate as the job container; the action needs a Docker daemon jobs do not get
gate / gate (pull_request) Successful in 2m52s
gate / conformance (pull_request) Has been skipped
renovatebot/github-action is a wrapper around `docker run`. On this runner a job has no Docker
daemon by decision (decisions/2026-10-29-image-pipeline.md), so every run, including dispatch
run 343, died before Renovate started:

  failed to connect to the docker API at unix:///var/run/docker.sock ... no such file or directory

The pinned image ghcr.io/renovatebot/renovate:44.167.0 is now the job container, and a run step
execs renovate-entrypoint.sh. actions/checkout is dropped, since Renovate clones the repo itself.
An optional RENOVATE_GITHUB_COM_TOKEN secret feeds GITHUB_COM_TOKEN.

Local dry run (--platform=local, uid 12021, the step body verbatim): 179 deps extracted, 42
updates, and all 4 own-image digest lookups against the Gitea registry succeed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:23:36 -05:00
jakman85 dfc432c6ea Merge pull request 'fix(keycloak-passkey): passkey reconciler with review fixes — bind only on git's say-so (default false), protect real sub-flows, body-id screening, failure alert' (#82) from feat/keycloak-passkey-reconcile into main
gate / gate (push) Successful in 2m50s
gate / conformance (push) Has been skipped
Reviewed-on: #82
Reviewed-by: Keith Jackson <[email protected]>
2026-10-30 05:59:11 +00:00
jakman85 262c6e4a97 Merge branch 'main' into feat/keycloak-passkey-reconcile
gate / gate (pull_request) Successful in 2m56s
gate / conformance (pull_request) Has been skipped
2026-10-30 05:56:00 +00:00
jakman85 523cd9118f Merge pull request 'fix(buildkit): allow buildkitd egress to Gitea so the Gateway stops 403ing the registry push' (#83) from fix/gitea-admit-buildkit into main
gate / gate (push) Successful in 1m55s
gate / conformance (push) Has been skipped
Reviewed-on: #83
2026-10-30 05:50:12 +00:00
jakman85andClaude Opus 5.5 d2c4bd5194 fix(buildkit): allow buildkitd egress to Gitea's backend so the Gateway stops 403ing the push
gate / gate (pull_request) Successful in 1m56s
gate / conformance (pull_request) Has been skipped
Images run 337's push failed with "403 Forbidden: Access denied". Measured
read-only: Gitea never logged the request; an unauthenticated GET /v2/ via the
Gateway gets 401 from Gitea when sent from act-runner (egress unrestricted) but
403 from buildkitd; Hubble logs it as an L7 http-request DROPPED with
traffic_direction EGRESS. Cilium >= 1.16.8 (CVE-2025-30162) re-checks the
source pod's egress against the proxied backend identity, and buildkitd's
egress only allowed world and the ingress entity. Same fix as 68690e5 for
Keycloak. Gitea's ingress already admits reserved:ingress and is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:38:54 -05:00
jakman85 0dd57fbaf0 Merge pull request 'feat(images): build, verify and publish the three images in CI on rootless BuildKit; Renovate carries the digests' (#81) from feat/image-pipeline into main
gate / gate (push) Successful in 1m57s
images / images (push) Failing after 5s
gate / conformance (push) Has been skipped
Reviewed-on: #81
Reviewed-by: Keith Jackson <[email protected]>
2026-10-30 05:15:16 +00:00
jakman85andClaude Opus 5.5 04eb38584a fix(conformance): workload-mTLS certificates may use a private CA, if they prove they are workload-internal
gate / gate (pull_request) Successful in 1m58s
images / images (pull_request) Failing after 7s
gate / conformance (pull_request) Has been skipped
R-TLS-01/docs/08 govern externally reachable TLS and ADR-0006 puts workload mTLS out of scope, but
tls-lifecycle-automated required ACME for every Certificate, which platform/buildkit's internal CA
cannot satisfy. Clause (5b) accepts the tls-scope=workload-mtls annotation only when the
certificate proves it: no Gateway serves it, internal names only, namespaced selfSigned/ca issuer
backed by an exempt CA certificate. Also fixes yamllint comma spacing in the buildkitd mounts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:59:55 -05:00
jakman85andClaude Opus 5.5 f65c12b395 feat(images): build on rootless BuildKit over mTLS; verification moves into Containerfile verify stages
Operator decision on PR #81: no CI job may reach a privileged daemon. The act runner is untouched.

- platform/buildkit (+ clusters/base/buildkit.yaml): moby/buildkit v0.34.1-rootless pinned by
  digest, uid 1000, not privileged; caps drop ALL + SETUID/SETGID, seccomp/AppArmor Unconfined,
  read-only rootfs (each measured under podman). cert-manager CA + server/client certs, ClusterIP
  :1234, default-deny with ingress only from the act-runner pod (DinD masquerades jobs to it).
  No Kyverno exception: every applied policy matches app-namespace=true only.
- Each Containerfile names its shipped stage and ends in a `verify` stage holding every former
  `docker run` assertion (uid 65532, RUN --network=none, writable-path inventory standing in for
  the read-only rootfs). The old `&& echo` checks that could never fail are real assertions now.
- images.sh drives buildkitd with a pinned buildx (sha256-checked) via bake; PRs build verify
  targets with agent-runner FROM the in-run toolbox (target: context); main verifies, then pushes.
  Fails loudly when the builder secrets are absent or buildkitd is unreachable.
- infra/gitea/buildkit-client-secret.sh copies the client cert into Actions secrets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:59:55 -05:00
jakman85andClaude Opus 5.5 8f35234fcc fix(images): name the measured runner blocker; the job container gets no Docker daemon
PR #81's first images run failed in 6s: act_runner 0.2.11 forces docker_host '-' for a tcp://
DOCKER_HOST, and gitea/act drops every bind when valid_volumes is empty. Giving jobs the
privileged DinD daemon is node-root for any workflow author, so it is left to the operator;
options are in the handoff entry. The driver's failure now names the cause.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:59:55 -05:00
jakman85andClaude Opus 5.5 e539109830 feat(images): build, verify and publish the three images in CI; Renovate carries the digests
Operator rule 2026-10-29: image creation, hosting and lifecycle belong to CI/CD, never to a
workstation build/push. Nothing had ever been pushed to the Gitea registry.

- .gitea/workflows/images.yaml + scripts/images.sh: PRs build+verify (no credential); main
  builds, verifies and pushes toolbox, agent-runner (FROM toolbox) and conformance-runner as
  sha-<commit> + main, reading the digest back from the registry. Only changed images build.
- Each image's own build-image.sh does the build and in-image verification, now engine-neutral
  (DinD: no podman-only calls, probe over stdin not a bind mount). New toolbox build-image.sh.
- Digest references carry `# renovate-digest:` markers; renovate.json5 gains a custom.regex
  manager for them. The worker Job template is deliberately untracked (it would loop).
- renovate.yaml pins renovate-version 44.167.0: the action's default (39.264.0) rejects the
  current config's managerFilePatterns, so every run would have died at CONFIG_VALIDATION.
- Push credential: REGISTRY_PUSH_TOKEN secret; the Actions token has no package scope in
  Gitea 1.24.3 (container.go Authenticate). Retention: infra/gitea/registry-retention.sh.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:59:55 -05:00
jakman85 27da5555a4 Merge pull request 'fix(renovate): the workflow still named renovate.json, so every run since #79 died with ENOENT' (#80) from fix/renovate-config-filename into main
gate / gate (push) Successful in 1m54s
gate / conformance (push) Has been skipped
Reviewed-on: #80
Reviewed-by: Keith Jackson <[email protected]>
2026-10-30 04:48:42 +00:00
jakman85andClaude Opus 5.5 da1b36eb64 fix(renovate): pin renovate-version — the action's default (39) rejects renovate.json5
gate / gate (pull_request) Successful in 1m55s
gate / conformance (pull_request) Has been skipped
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:49:38 -05:00
jakman85andClaude Opus 5.5 6bb8af1d70 docs(decisions): handoff for the passkey reconciler review fixes
gate / gate (pull_request) Failing after 2m55s
gate / conformance (pull_request) Has been skipped
Also states in bind_browser_flow()'s docstring that it is reached only when git's
bind-browser-flow is "true".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:45:40 -05:00
jakman85andClaude Opus 5.5 7c4eb439b2 feat(monitoring): alert when the keycloak passkey reconciler's Job fails
KeycloakPasskeyReconcilerFailing (warning, for 15m) on
kube_job_status_failed{namespace="keycloak", job_name=~"keycloak-passkey-reconciler-.*"},
following the sibling client-secret reconciler's rule. promtool unit tests pin it in
both directions: quiet when succeeding, firing after 15m on a failed Job, quiet on the
SIBLING's failure, and quiet on a single transient failure. Runbook added and projected
by the runbooks ConfigMap generator.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:41:15 -05:00
jakman85andClaude Opus 5.5 17eed6e8ae fix(keycloak-passkey): bind only on git's say-so; protect real sub-flows; screen the requirement PUT by body id
Review findings on the passkey reconciler, fixed:

1. HIGH - the bind was automatic and rollback did not stick. A git-declared switch,
   `bind-browser-flow` in passkey-desired-state.yaml, now decides whether pass 3 may
   PUT browserFlow. Default "false": build + verify the candidate (or reuse a verified
   one), log WOULD BIND <alias>, never touch the binding - including after an earlier
   bind, so an operator's rollback sticks, and a hand-edited bound candidate is left
   alone rather than shadowed by an -r2 build. "true" binds as before. The renderer
   carries the one hand-edited line through and --check refuses anything but the
   quoted strings "true"/"false". --no-bind can only narrow it.

2. HIGH - the protected-subtree guard read `alias` on sub-flow rows. Keycloak 26.0.6
   AuthenticationManagementResource.recurseExecutions sets displayName =
   flowRef.getAlias() and flowId on a sub-flow row and never sets alias (alias is the
   CONFIG alias on authenticator rows), so the protected set was only the top-level
   alias. Sub-flows are now named by displayName (getFlows() lists top-level flows
   only, so flowId cannot be resolved through it); a sub-flow row without one leaves
   the subtree undetermined and the flow pass fail-closed. The selftest stub now
   returns 26.0.6-shaped rows, and a real run is asserted to refuse writes into
   browser-mfa-forms and browser-mfa-conditional-otp.

3. MEDIUM - PUT /flows/{alias}/executions was screened on the path alias only, but
   updateExecutions resolves the execution realm-wide by body id. body.id is now
   screened against the protected ids AND required to be in the tree this run is
   building (recorded from the build's own readback); the config POST is held to the
   same build scope; and the body may carry only id/requirement/priority (the same
   handler renames any flow named by flowId).

4. Stale "audit-only" docs rewritten (platform-config kustomization, chainsaw test,
   desired-state header and its renderer template, README) to describe what ships.

5. backoffLimit 0 (a retried deterministic build failure burns the candidate-alias
   budget); the stale "Renovate does not manage image: tags" comment corrected and the
   image pinned by index digest.

6. The ~160-line header duplicated in the YAML and the script is now kept once, in the
   script, with a short pointer above it.

Selftest: 259 assertions (was 216). mutations.py: 40 injected regressions (was 30),
including one per finding above, all caught.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:41:14 -05:00
jakman85andClaude Opus 5 f5931944fa feat(keycloak): reconcile the passkey realm config via GitOps, the browser flow blue/green
PR #72 declared the WebAuthn passwordless policy and a restructured browser-mfa
flow in the realm import. That declaration could not take effect: a
KeycloakRealmImport is a ONE-SHOT, and prod's reads Done=True / Started=False, so
every realm-level field and flow declared after the first import is inert -- in
git, applied by Flux, reported Ready, reaching the realm never. Merging it changed
nothing about how anyone logs in, and nothing anywhere said so. Same failure class
as the 2026-09-06 client-secret incident, third field over.

A SIBLING reconciler converges all three declared things every 15 minutes. It is a
sibling, not a second pass of client-secret-reconciler.yaml, so that reconciler's
allow-list -- and its guarantee that prod's real user and the media-users /
platform-admins / viewers groups are structurally unreachable -- stays literally
true. Its selftest still passes unchanged.

  pass 1  webAuthnPolicyPasswordless* -- strict read-modify-write of
          GET/PUT /admin/realms/homelab, readback-verified.
  pass 2  webauthn-register-passwordless -- strict read-modify-write of ONE
          literally-named alias; enabled, defaultAction false so enrolment stays
          opt-in.
  pass 3  the browser flow -- BLUE/GREEN.

PASS 3 NEVER MUTATES THE FLOW USERS ARE LOGGING IN THROUGH. Converging a
five-flow, ten-execution tree needs create-subflow, create-execution,
set-requirement and bind-config as SEPARATE untransacted calls; doing that to the
BOUND flow means a run dying between them leaves a half-built login path for every
SSO app and kubectl, and this repo has had a total SSO outage from a realm change.
So instead:

  1. BUILD under a NEW, unused alias (declared alias + a hash of the desired
     shape, applied to EVERY alias in the tree -- reusing browser-mfa-forms would
     mean writing INTO the bound tree). A failure leaves an orphaned, unbound flow
     and zero user-visible effect.
  2. VERIFY by readback -- levels, ordering, authenticators, requirements, and the
     conditional-OTP config CONTENTS -- before anything is bound, so the
     regression that once 403'd every platform-admin is caught BEFORE it bites.
  3. BIND with ONE atomic PUT setting browserFlow.
  4. ROLLBACK is one write; no DELETE exists anywhere in the allow-list, so the old
     flow survives as the rollback target.

The widened allow-list is made safe structurally. It cannot say "but not the bound
flow" -- its write entries are alias-parameterised -- so a second guard screens
every write against the live browserFlow's WHOLE subtree (sub-flow aliases and
execution ids) before a socket opens, FAIL-CLOSED until that binding has been
read. The create-flow endpoint carries no alias in its path, so it is screened on
its request BODY; the selftest caught that it otherwise escaped the guard.

Idempotence is load-bearing: a rebuild-and-reflip every 15 minutes would churn the
login path on a schedule. The bound flow is compared STRUCTURALLY and a match
produces zero writes and no new flow. Sub-flow and config ALIASES are deliberately
not compared -- a built flow carries a hash suffix, so comparing labels would make
it permanently unrecognisable as converged.

Single source of truth: the desired state is GENERATED from the realm import by
ci/render-keycloak-passkey-policy.py and gated with --check, so the RP ID -- baked
into every credential at enrolment and never re-keyable -- is declared once. The
pod keeps automountServiceAccountToken: false and reads a mounted ConfigMap, so no
rbac.yaml is added anywhere.

Read from Keycloak 26.0.6 source before being relied on: updateRealm
(DefaultExportImportManager 725-894) makes no getUsers/getClients/getGroups/
getRoles/getRequiredActions/getAuthenticationFlows call; it DOES read
getBrowserFlow(), which is what makes the atomic bind possible and why the policy
write pins it; the passwordless policy is applied all-or-nothing only when
RpEntityName is non-empty, with defaults substituted for omitted keys; and
updateExecutions compares rep.getPriority(), so a body omitting `priority` would
silently reshuffle the whole execution order.

Gate coverage: a new whole-repo L0 check extracts the script and runs its
--selftest (216 assertions, including a build killed at four different points each
asserting the old flow is still bound and the bound subtree was never written),
plus mutations.py (30 injected regressions, all caught) proving the selftest can
fail. An earlier iteration of this work only AUDITED the flow and left the
restructure manual; decisions/2026-10-18-keycloak-passkey-reconciler.md records
that design and why it was reconsidered.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-29 21:22:08 -05:00
jakman85andClaude Opus 5.5 aacf361bb8 fix(renovate): the workflow still named renovate.json, so every run since #79 died with ENOENT
gate / gate (pull_request) Failing after 1m58s
gate / conformance (pull_request) Has been skipped
#79 renamed the config to renovate.json5 and left configurationFile pointing at the old
name. Runs 315-322 (2026-10-22..29) all failed in ~1s before Renovate started.

Also add a ci/lint.sh assertion that every workflow configurationFile: resolves to a real
path — valid YAML, so yamllint could not catch it, and the gate stayed green throughout.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:58:15 -05:00
jakman85 1301af9a4c Merge pull request 'fix(renovate): the config was INVALID, so the first manual run errored out' (#79) from fix/renovate-invalid-config into main
gate / gate (push) Successful in 1m55s
gate / conformance (push) Has been skipped
renovate / renovate (push) Failing after 9s
Reviewed-on: #79
Reviewed-by: Keith Jackson <[email protected]>
2026-10-18 14:29:34 +00:00
jakman85 42ce001d71 Merge pull request 'fix(renovate): the cron fired outside Renovate's own window, so it would never open a PR' (#77) from fix/renovate-cron-window into main
gate / conformance (push) Has been cancelled
gate / gate (push) Has been cancelled
Reviewed-on: #77
Reviewed-by: Keith Jackson <[email protected]>
2026-10-18 14:28:59 +00:00
jakman85 84bb0f0acd Merge pull request 'fix(pihole): make the split-horizon answer cacheable (local-ttl=60)' (#78) from fix/pihole-local-ttl into main
gate / gate (push) Successful in 1m53s
gate / conformance (push) Has been skipped
Reviewed-on: #78
Reviewed-by: Keith Jackson <[email protected]>
2026-10-18 08:19:28 +00:00
jakman85andClaude Opus 5 0d4bd7297d fix(renovate): the cron fired outside Renovate's own window, so it would never open a PR
gate / conformance (pull_request) Has been skipped
gate / gate (pull_request) Successful in 1m55s
.gitea/workflows/renovate.yaml shipped with `cron: "15 3 * * *"`. Gitea Actions
cron is UTC, and renovate.json carries schedule ["after 3am and before 6am"]
with timezone America/New_York -- and THAT inner schedule is authoritative:
Renovate re-checks it and declines to open PRs outside it.

  03:15 UTC  ->  23:15 EDT, the PREVIOUS EVENING in New York
  window     ->  03:00-06:00 local   =>  NOT INSIDE (3h45m early)

The original comment claimed the cron sat "slightly ahead" of the window. It
sat 3h45m behind it, on the previous day. Every nightly run would have started,
logged "not within schedule", opened ZERO PRs and exited SUCCESSFULLY -- a green
tick forever, indistinguishable from "nothing needed updating", hiding the ~30
pending updates PR #73 existed to surface.

Changed to `15 8 * * *`, chosen to survive DST rather than to look tidy:
  EDT (UTC-4): local 03:00-06:00 == 07:00-10:00 UTC
  EST (UTC-5): local 03:00-06:00 == 08:00-11:00 UTC
08:15 UTC is inside BOTH (04:15 EDT / 03:15 EST), verified by computation at
both extremes, so neither switchover needs an edit. A cron cannot follow DST.

Also confirmed while here: RENOVATE_TOKEN now exists as a repo Actions secret,
so the workflow's fail-hard preflight will pass.

STILL UNRESOLVED and the likelier remaining bug: renovate.json's
`enabledManagers` lists bare `regex`, where modern Renovate names the custom
manager `custom.regex`. If the bare name is ignored, every inline `# renovate:`
marker in the tree does nothing. Deliberately NOT changed -- guessing at a key
that could fail validation and take the whole run down is worse than a question
one dry run answers. See the handoff.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-18 03:12:28 -05:00
jakman85andClaude Opus 5 713b0c3dba fix(renovate): the config was INVALID, so the first manual run errored out
gate / gate (pull_request) Successful in 1m55s
gate / conformance (pull_request) Has been skipped
The operator ran the Renovate workflow manually and it failed. The cause is not
the workflow: renovate.json was never a valid Renovate config. It carried five
pseudo-comment keys (_comment, _kubernetes_comment, _unmanageable_comment,
_ignorePaths_comment, _docker_comment) because JSON has no comments, and
Renovate REJECTS unknown top-level options. Proven with the official validator:

  ERROR: Found errors in configuration
    Invalid configuration option: _comment
    Invalid configuration option: _docker_comment
    Invalid configuration option: _ignorePaths_comment
    Invalid configuration option: _kubernetes_comment
    Invalid configuration option: _unmanageable_comment

This is the SECOND independent reason Renovate never worked, on top of PR #77's
cron firing outside renovate.json's own schedule window. Either alone was enough
to produce "nothing ever happens"; #73 shipped both.

Converted renovate.json -> renovate.json5. JSON5 permits real // comments and
Renovate discovers renovate.json5 natively; nothing in .gitea/ or ci/ pins the
old filename and no RENOVATE_CONFIG_FILE is set. Every word of the original
rationale is preserved as positioned comments (74 comment lines) rather than
flattened into a description string.

Verified the conversion dropped nothing: 18 non-comment keys before and after,
no missing/extra keys, no changed values, docker: {enabled: true} still present.
The validator now returns "Config validated successfully".

DISPROVED while here: `enabledManagers` containing bare `regex` was suspected of
needing to be `custom.regex` (which would silently disable every inline
`# renovate:` marker). The validator accepts `regex`; unfounded, unchanged.

Renovate has still never completed a run -- this fixes one of two blockers and
nothing in the gate catches an invalid config. See the handoff.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-18 03:10:25 -05:00
jakman85andClaude Opus 5 fda5c31a94 fix(pihole): make the split-horizon answer cacheable, or one blip latches the WAN IP for 5 minutes
gate / gate (pull_request) Successful in 1m56s
gate / conformance (pull_request) Has been skipped
Operator reported DNS flapping between the cluster ingress and the router's
public IP. PiHole is not at fault -- both replicas answer 192.168.2.20
consistently (6/6 via the VIP, 8/8 from a client). The bug is an asymmetry in
CACHEABILITY that we created. Measured:

  pihole  192.168.2.23 -> 192.168.2.20               TTL=0
  public  1.1.1.1      -> wan.home.keithjackson.dev  TTL=300
  router  192.168.2.1  -> wan....  TTL=153 -> 148 -> 143   (decrementing = caching)

dnsmasq answers `address=` entries with TTL 0 unless told otherwise. The router
forwards LAN queries to PiHole and caches the result, so the CORRECT answer is
uncacheable -- re-queried on every lookup, maximum exposure to any gap -- while
the WRONG answer caches for 300s. The instant the router falls back to upstream
(standard forwarder behaviour when the target does not answer) it latches the
WAN address for the whole LAN for five minutes, then expires, re-queries PiHole,
gets TTL 0, and is immediately re-exposed.

So ONE sub-second PiHole gap -- pod restart, L2 lease move, ARP flap, kured
reboot -- becomes FIVE MINUTES of the house resolving to the WAN IP. That
asymmetry, not the gap, is why it reads as "flapping".

Fix: local-ttl=60 prepended to FTLCONF_misc_dnsmasq_lines. dnsmasq applies
local-ttl to replies made from configuration, so one directive covers all five
address= names. 60s rides out a pod restart and a lease re-election while a real
VIP change still propagates inside a minute; these VIPs are pinned and static.

apps/wave-0/pihole.yaml regenerated with the canonical render command
(--all catalog -o apps --wave-layout --no-image-automation, NOT --emit-waves);
exactly two files changed and the rendered diff is the single local-ttl prefix.

UNAPPLIED. Does NOT remove the trigger: .23 still has two independently-elected
L2 leases (as do crafty .22 and unifi .21), which the operator has parked. If
the TTL is still 0 after merge, suspect FTL silently rejecting the passthrough
line -- see the handoff.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-18 02:56:49 -05:00
jakman85 c340bbfc4b Merge pull request 'fix(conformance): one regex made the merge gate take 28 minutes instead of 2' (#76) from fix/ha-check-catastrophic-regex into main
gate / gate (push) Successful in 1m56s
gate / conformance (push) Has been skipped
Reviewed-on: #76
Reviewed-by: Keith Jackson <[email protected]>
2026-10-18 07:51:51 +00:00
jakman85 a9a529750d Merge pull request 'fix(unattended-upgrades): Debian has no reboot-sentinel package, so ship the writer' (#75) from fix/unattended-upgrades-debian-sentinel into main
gate / conformance (push) Has been cancelled
gate / gate (push) Has been cancelled
Reviewed-on: #75
2026-10-18 07:40:20 +00:00
jakman85andClaude Opus 5 793c3940a8 fix(conformance): one regex made the merge gate take 28 minutes instead of 2
gate / gate (pull_request) Successful in 1m55s
gate / conformance (pull_request) Has been skipped
ha-control-plane-as-code/check.sh had:

  re.search(r"--server https://.*?\n(?:.*\n){0,10}?\s*when:...'secondary'",
            K3S_TEXT, re.S)

With re.S every `.` matches newlines, so `.*?` and the nested `(?:.*\n){0,10}?`
can each consume the whole document -- nested unbounded repetition, which
backtracks exponentially on a failed or late match. MEASURED in CI via
`docker top` on the live job container: python3 at 19:54 of CPU time, which is
essentially the entire ~28 minute gate.

Localised to the exact line by extracting the inline python and running it under
faulthandler.dump_traceback_later, which named re/__init__.py:177 in search from
this call -- not inferred from reading.

Replaced `.` with `[^\n]*` and dropped re.S, so the {0,10} is a real ten-line
window with nothing to backtrack into. On the real input (22,179 chars):
0.2 ms, match still found. Whole check: 0.199s, rc 0, 115 assertions, identical
success message.

WHY IT SURFACED NOW, AND IT WAS MINE: K3S_TEXT is every *.yml in
roles/k3s_server concatenated. The regex was latent for as long as it existed;
PR #71 added ~100 lines to that role (kubelet graceful-shutdown config), growing
the input past the point where exponential backtracking stopped being
survivable.

WHY ONLY infra/** WAS SLOW: touched-requirements.sh maps infra/* to R-ENV-01, so
the L0 run becomes --include-scope whole-repo --requirements R-ENV-01, which
selects this whole-repo check. Doc/platform changes never did -- #69 1m55s and
#72 1m53s stayed fast while #71 27m44s, the #73 merge 27m46s and #75 all ran
~28m.

The pattern is unique in the repo (grep over all l0-static check.sh files).
Not re-timed in CI post-fix; this PR's own run is the first datapoint.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-18 02:38:23 -05:00
jakman85andClaude Opus 5 ef8ef5ac29 fix(unattended-upgrades): Debian has no reboot-sentinel package, so ship the writer
gate / gate (pull_request) Successful in 27m51s
gate / conformance (pull_request) Has been skipped
The first real run failed on all three nodes:

  fatal: [k3s-prod-1]: "No package matching 'update-notifier-common' is available"

update-notifier-common is an UBUNTU package and does not exist in Debian --
`apt-cache policy` returns Candidate: (none) on trixie. The role asserted it
"owns the hook that creates /var/run/reboot-required", which is true on Ubuntu
and false here, so the role could never have worked.

Three further measurements settled the replacement: notify-reboot-required is
absent; NO linux-image-* postinst references it (invoking it is an Ubuntu patch,
so dropping a script at that path would never be called); and no hook under
apt.conf.d or /etc/kernel mentions reboot-required. Debian trixie ships no
mechanism at all.

So the role now provides it:
* /usr/local/sbin/k3s-notify-reboot-required -- touches the sentinel when the
  running kernel is not the newest installed, using `linux-version` (from
  linux-base, a dependency of every linux-image, measured present) because
  kernel version ordering is not string ordering.
* /etc/apt/apt.conf.d/99-k3s-reboot-required -- DPkg::Post-Invoke, `|| true` so
  it can never abort an apt transaction.
/run is tmpfs (measured), so the sentinel self-clears on boot; no cleanup task.

The verify assert was ALSO weaker than it looked: a `find` glob for
*update-notifier* / *unattended-upgrades* under apt.conf.d would have matched
Debian's own unattended-upgrades conffile and reported "writer present" while
none existed. It now stats the two exact paths this role owns and requires the
script to be executable.

Newly verified: the script ran on prod-1 as unprivileged k3s under sh -x --
running=newest=7.1.8+deb13-amd64, took the equal branch, exited 0, wrote
nothing. Correct. sh -n and bash -n clean.

Still unverified: the WRITING half never executes while running == newest, so
the sentinel has not been seen created and kured has not been seen react. The
honest test is the next kernel upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-18 01:43:43 -05:00
jakman85 ee196c7bcd Merge pull request 'feat(upgrades): automate node reboots (kured) and wire Renovate, which had never run' (#73) from feat/automated-upgrades into main
gate / gate (push) Successful in 27m46s
gate / conformance (push) Has been skipped
Reviewed-on: #73
Reviewed-by: Keith Jackson <[email protected]>
2026-10-18 06:28:30 +00:00
jakman85 83d3568150 Merge pull request 'feat(shutdown): configure graceful node shutdown, and never scale a Delete-policy StatefulSet' (#71) from feat/graceful-cluster-shutdown into main
gate / gate (push) Successful in 27m39s
gate / conformance (push) Has been skipped
Reviewed-on: #71
2026-10-18 05:51:20 +00:00
jakman85 24bea918ab Merge pull request 'fix(coredns): hold in-cluster DNS at 2 replicas via an HPA, because k3s owns the Deployment' (#74) from fix/coredns-ha into main
gate / conformance (push) Has been cancelled
gate / gate (push) Has been cancelled
Reviewed-on: #74
2026-10-18 05:50:46 +00:00
jakman85andClaude Opus 5 5642881c4d fix(coredns): hold in-cluster DNS at 2 replicas via an HPA, because k3s owns the Deployment
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
MEASURED on prod 2026-10-18: kube-system/coredns had replicas: 1. In-cluster
DNS was a single pod on a single node, so draining or rebooting that node was a
CLUSTER-WIDE in-cluster DNS outage until the pod rescheduled -- every Service
lookup, every CNPG replica finding its primary, every operator addressing the
API by name. It was invisible because 1/1 Ready looks healthy.

It is urgent rather than latent now: the scheduled reboot automation on
feat/automated-upgrades drains and reboots one node at a time, unattended,
overnight -- turning a single point of failure into a recurring one.

WHY AN HPA AND NOT replicas: 2 -- the whole decision. k3s OWNS the coredns
Deployment: AddOn/coredns, source /data/k3s/server/manifests/coredns.yaml,
managedFields managers deploy@k3s-prod-1 + k3s, objectset.rio.cattle.io/hash
label. k3s re-stages its bundled manifests on startup, so replicas: 2 written
there -- or a kubectl scale -- is reverted on the next restart or upgrade.

Rejected alternatives:
* coredns.yaml.skip + our own templated copy (the roles/kube_vip pattern).
  Deterministic, but takes ownership of coredns from k3s permanently so k3s
  upgrades stop updating it. Too high a price for a replica count.
* A Flux patch of the Deployment. Rejected outright: Flux and k3s's deploy
  controller would both own one object and fight -- the default-deny
  NetworkPolicy drift-loop shape (~2500 events/10min) this cluster already hit.

An HPA is a separate object k3s does not own, driving /scale rather than the
Deployment spec, so there is no shared-ownership fight; if k3s re-stages
replicas: 1 it is re-asserted within a reconcile. Disclosed cost: a brief
window at 1 replica after a k3s restart.

It is a FLOOR-HOLDER, not an autoscaler -- the CPU metric exists only so the
autoscaling/v2 object is valid. Measured: 5m/4m against a 100m request (~5%),
so the 80% target is never approached. scaleDown stabilization 600s because DNS
is the worst thing to flap; scaleUp immediate, since that restores redundancy.

The kustomization edit is ADDITIVE: platform/coredns/ already existed and holds
the coredns-custom split-horizon ConfigMap (R-DNS-01/03), which is untouched.

UNAPPLIED. The HPA has never run, and the claim that k3s reverts replicas on
restart is reasoned, not observed. After merge check `describe hpa coredns` for
ScalingActive=True -- an inert HPA looks identical to a working one because the
hand scale already left 2 replicas. See the handoff entry.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-18 00:44:09 -05:00
jakman85 4d489f2059 Merge pull request 'feat(keycloak): add passkey (WebAuthn passwordless) login, additive and optional' (#72) from feat/keycloak-passkeys into main
gate / gate (push) Successful in 1m53s
gate / conformance (push) Has been skipped
Reviewed-on: #72
2026-10-18 05:44:03 +00:00
jakman85andClaude Opus 5 a2440a3125 feat(upgrades): automated OS reboots (kured) + security-only unattended-upgrades, and give Renovate a runner
gate / gate (pull_request) Successful in 1m57s
gate / conformance (pull_request) Has been skipped
Two halves of one repeatable upgrade process. Neither works alone.

(a) OS upgrades and reboots

platform/kured/ — Kubernetes Reboot Daemon, chart 6.1.0/app 1.23.0, in its own
Flux Kustomization (dependsOn platform, wait:true). NOT in platform/kustomization.yaml:
that tier gates platform-config and every apps wave, so a wedged reboot daemon there
could stall every application. A component added to make maintenance safer must not be
able to cause an outage.

infra/ansible/roles/unattended_upgrades/ — new role (node_baseline is touched by other
open branches, and this is a different lifecycle), enabled for prod in group_vars.

The human gate is the SENTINEL, which is how this honours node_baseline's "IT DOES NOT
REBOOT" (k3s-prod-2 hung on a warm reboot 2026-09-03) and cluster-upgrade-plan.md's
rejection of automating away the gate between nodes: u-u is security-only with the
kernel blacklisted, so it essentially never writes /var/run/reboot-required. That file
appears when an operator deliberately runs node-baseline.yml --tags kernel. kured then
does only the mechanical half, including the ordering rule node_baseline has to shout.

Quorum: concurrent reboots are structurally impossible, not merely unlikely.
concurrency:1 plus kured's single lock annotation (API-server optimistic concurrency),
and lockTtl:0 — an expiring lock is exactly the 3-node etcd catastrophe, so a hung node
holds it forever and nothing cascades.

Longhorn: measured 25 volumes x 3 replicas, hard anti-affinity, 600s replenishment wait,
serialized rebuild — so every drain degrades ALL volumes to 2/3 and the inter-node gate
must be volume robustness, not pod readiness. Doubled: lockReleaseDelay 30m
(unconditional) plus kured's Prometheus alert gate (semantic).

alertFilterMatchOnly:true is load-bearing — the flag inverts the regexp's meaning, and
under the chart default kured would have been blocked on day one by the five alerts
measured firing with no end in sight, and never rebooted anything.

Kernel floor (kernel >= 6.13, EFI memattr): excluded TWICE. The running kernel comes
from o=Debian Backports, which is not an allowed origin, so u-u cannot see a kernel
upgrade; and ^linux- is blacklisted anyway so widening origins later cannot re-arm it.
Asserted against apt-config dump, not against the file just written. 6.12 kernels are
still installed and bootable on all three nodes, and nothing was held.

Closes a silent no-op: update-notifier-common was absent on all three nodes, so NOTHING
could create the sentinel. kured alone would have idled forever at 1/1 Running.

platform/kured/alerts.yaml — three rules over kured's only metric, with a companion
promtool suite pinning each both ways, plus runbooks/node-reboot-automation.md.
Window is DAILY 03:00-06:00 because a weekly one forces for:8d, longer than this
cluster's 7d Prometheus retention.

(b) Image/dependency automation

Renovate was aspirational: renovate.json present and complete, with no workflow, no
in-cluster workload, and its docker+kubernetes managers disabled — so every plain
image: tag was maintained by nothing while seven files claimed otherwise.

.gitea/workflows/renovate.yaml — scheduled daily, dry_run default true for a safe
first run, and a preflight that FAILS (not skips) without RENOVATE_TOKEN, because a
skipped Gitea job reports success and that is how this stayed inert.

Renovate now owns both halves; Flux image-automation is formally closed: it pushes
commits rather than opening PRs (bypassing CLAUDE.md §1), and it cannot work here
anyway — main is protected user_can_push:false and the flux-system GitRepository has
no secretRef. Its controllers are installed and have idled 43 days with zero CRs.

conformance/: +34/-0, one new SCRAPE_OWNERS entry claiming kured_. Weakens nothing;
tightens the check for this component (requires service.create+metrics.create, both
chart-default false, plus a companion NetworkPolicy requirement). Required because
assertion (3) fails an alert over an unclaimed metric and KNOWN_UNWIRED_METRICS is
shrink-only.

Sensitive paths touched: clusters/**, conformance/**, .gitea/** — needs a non-author
approving review; /approve-override deliberately NOT posted.

NOT verified: no OS upgrade run, no node rebooted, no lock observed, the Ansible role
never executed, and whether CNPG switches its primary on a drain-induced eviction (the
most likely way the first real reboot fails). Full list in the handoff entry.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-18 00:10:29 -05:00
jakman85andClaude Opus 5 16894b7b73 feat(keycloak): declare passkey (WebAuthn passwordless) login for the homelab realm
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
Adds the realm's WebAuthn Passwordless policy and restructures the browser-mfa
flow so a passkey is an ALTERNATIVE beside the password form. Additive and
optional by design: this IdP gates every SSO app plus kubectl, so a mandatory
passkey flow that misfires is a total lock-out. Enrolment is not forced.

Policy: RP ID is ${BASE_DOMAIN} (not the keycloak host) because a passkey's RP ID
is fixed at enrolment and must not be bound to a name that can move; resident key
required (a non-discoverable credential is a second factor, not a passkey); user
verification required (without it, possession alone is a complete login).

Flow: auth-username-password-form is split into auth-username-form +
auth-password-form, because the server must know who is logging in before it can
decide whether a passkey exists. Conditional OTP for platform-admins is relocated
intact onto the password branch only — a UV-required passkey is already two
phishing-resistant factors.

No requiredActions block is declared, deliberately: Keycloak 26.0.6 treats
requiredActions as REPLACE not merge, so declaring one would delete the default
set including CONFIGURE_TOTP and lock every platform-admin out of the OTP step
this flow still enforces. webauthn-register-passwordless is already registered
enabled-but-not-default by Keycloak's own defaults.

UNAPPLIED: the realm import is a one-shot resting at Done=True/Started=False, so
this reaches the live realm never. See the handoff entry and the README section.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-17 23:23:36 -05:00
jakman85 1a0b20857a Merge pull request 'fix(oauth2-proxy): gate startup on Keycloak discovery, or a cold boot CrashLoops the proxy' (#70) from fix/oauth2-proxy-oidc-startup-race into main
gate / gate (push) Successful in 1m52s
gate / conformance (push) Has been skipped
Reviewed-on: #70
2026-10-18 04:14:52 +00:00
jakman85andClaude Opus 5 c150327a30 fix(oauth2-proxy): gate startup on Keycloak discovery, or a cold boot CrashLoops the proxy
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
Exposed by the 2026-10-17 whole-cluster shutdown. oauth2-proxy performs OIDC
discovery ONCE at startup and exits 1 if it fails. On a cold boot Keycloak is
not serving yet, the shared Gateway answers 503, and the proxy dies:

  [provider.go:55] Performing OIDC Discovery...
  [main.go:73] ERROR: Failed to initialise OAuth2 Proxy: ... failed to discover
               OIDC configuration: unexpected status "503": no healthy upstream

It then sits in exponential CrashLoopBackOff long after Keycloak is healthy --
one replica was still down minutes later and only a manual pod delete cleared
it. There is no retry flag, a readinessProbe cannot help (the container is
already dead), and CrashLoopBackOff backoff is not tunable per-pod.

Add an extraInitContainers gate (wait-for-idp) that polls the discovery doc
until it answers. A pod in Init accrues no CrashLoopBackOff backoff and retries
promptly. BOUNDED and FAILS OPEN: after ~5m it starts the proxy anyway, so the
worst case is exactly today's behaviour and never something new.

extraInitContainers is the chart's OWN hook for this -- its commented example is
literally `- name: wait-for-idp` -- and the key was CONFIRMED against the pinned
chart (helm show values, 10.7.1) rather than assumed, because a wrong Helm values
key is silently ignored. No new base image: the chart already runs an alpine init
container for its built-in waitForRedis gate; pinned to 3.22 (verified upstream)
rather than the chart's `latest`.

Rejected: Flux postRenderers (no precedent in this tree, and the chart has a
hook); --skip-oidc-discovery (removes the fetch entirely, but needs four
hardcoded endpoints and I could not verify lazy-JWKS offline -- this component
gates SSO for seven apps, so the additive option wins).

Shell vars are written BARE with no braced form: platform declares
postBuild.substituteFrom, and envsubst would blank a braced shell variable and
silently break the loop. Same convention as the gitea/nextcloud OIDC hooks.

UNAPPLIED; the init container has never run, and the race was not re-created to
prove the fix. Check the init container's log on first apply. See the handoff.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-17 22:47:30 -05:00
jakman85andClaude Opus 5 2f2e98aa19 feat(shutdown): configure graceful node shutdown, and never scale a Delete-policy StatefulSet
gate / gate (pull_request) Successful in 27m44s
gate / conformance (pull_request) Has been skipped
All three nodes had to be powered off to move them onto a UPS, and there was
NO procedure: infra/cluster.sh stop is virsh-based and only ever applied to the
decommissioned dev cluster. The sequence was improvised live. Two defects came
out of doing it.

1. GRACEFUL NODE SHUTDOWN WAS UNCONFIGURED ON EVERY PROD NODE. No
   shutdownGracePeriod anywhere in infra/ansible/, so `shutdown -h` stopped k3s,
   containerd went with it, and every pod was SIGKILLed where it stood -- 9 CNPG
   instances mid-checkpoint, the UniFi VM, 25 attached Longhorn volumes.
   roles/k3s_server now sets kubelet-arg shutdown-grace-period=90s and
   shutdown-grace-period-critical-pods=20s.

   The half that silently defeats the other half: this is enforced by LOGIND,
   not the kubelet. The kubelet holds an inhibitor DELAY lock and systemd waits
   at most InhibitDelayMaxSec -- DEFAULT 5 SECONDS -- then proceeds regardless,
   truncating the drain with no error. The role ships a logind.conf.d drop-in
   (120) and ASSERTS cap >= grace period, because the failure is invisible.

2. SCALING A StatefulSet TO 0 DELETED ITS PVC AND VOLUME. monitoring/loki
   carries persistentVolumeClaimRetentionPolicy whenScaled: Delete, so the
   quiesce destroyed storage-loki-0 outright. Loki stores type: filesystem, so
   that PVC was the only copy; 72h retention bounded the loss, the mechanism
   does not. Symptom was the volume count going 25 -> 24, first misread as a
   miscount. Repaired: scale back to the declared 1 recreated the PVC and
   loki-0 is 2/2 Running.

New infra/maintenance/quiesce.sh (quiesce|resume|status) encodes the sequence
that worked and REFUSES to scale any whenScaled: Delete StatefulSet -- it stays
running, its volume hard-detaches, and it rebuilds on restart. A rebuild beats
a deletion. Discovery is computed from the live cluster, so a newly onboarded
service needs no edit here.

runbooks/cluster-maintenance-shutdown.md is the operator page: order in both
directions, TrueNAS-after-nodes (hard NFS mount wedges kubelet), the
un-hibernate-Postgres step Flux will NOT do for you, and a "looks broken but is
fine" section separating the 4/19 Flux cascade, the degraded-and-rebuilding
volume and the self-recovering keycloak-operator from the one thing that needs
a human (a stuck oauth2-proxy replica).

THE KUBELET CHANGE HAS NEVER BEEN APPLIED OR TESTED -- it needs a rolling k3s
restart, which is an operator action. Review config.yaml's kubelet-arg before
applying: a bad config.yaml is how k3s fails to come up. See the handoff entry.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-17 22:26:47 -05:00
jakman85 9a41a8e1e5 Merge pull request 'Expand plex volume' (#68) from plex/volume-expamsion into main
gate / gate (push) Successful in 1m53s
gate / conformance (push) Has been skipped
Reviewed-on: #68
2026-10-10 16:13:46 +00:00
jakman85 0132c680b3 Update apps/wave-0/plex.yaml
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
2026-10-10 16:09:53 +00:00
jakman85 439b35a3f0 Expand plex volume
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped
2026-10-10 13:55:15 +00:00
jakman85 e89df78210 Merge pull request 'fix(bulk-nfs): repoint at the TrueNAS (192.168.2.30); the PVs must be recreated, not reconciled' (#67) from fix/bulk-nfs-truenas into main
gate / gate (push) Successful in 1m53s
gate / conformance (push) Has been skipped
Reviewed-on: #67
Reviewed-by: Keith Jackson <[email protected]>
2026-10-07 06:26:44 +00:00
jakman85andClaude Opus 5 0747849837 fix(bulk-nfs): repoint at the TrueNAS (192.168.2.30); the PVs must be recreated, not reconciled
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
Both ZFS pools moved off the Proxmox host onto a standalone TrueNAS on
192.168.2.30 -- prod's OWN /24, so the pfSense crossing that made this knob
load-bearing is gone, along with the DNAT at 192.168.2.102 it mounted through.
Measured 2026-10-07: 192.168.2.102:2049 no longer answers; .30:2049 and :111 do.

BULK_NFS_SERVER -> 192.168.2.30 in prod, base and dev. The dev value is marked
inert (dev is decommissioned); it carries the real address only so nothing in
the tree renders a host that no longer exists.

THE OPERATIONAL POINT, and why this is not just a value change: a bound
PersistentVolume's NFS source is immutable. Server dry-run, 2026-10-07:

  spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is
  immutable after creation

so Flux's apply FAILS rather than updating, and the pods keep their old, dead
mount. The five PVs must be deleted and recreated -- safe, because Retain never
touches data and render.py emits a pre-set claimRef so each PV rebinds its own
PVC. The sequence is docs/BULK-NFS-STORAGE.md section 10 and is an OPERATOR
action; this commit does not perform it.

Pairs with the hazard already on the page: spec.nfs.server is unvalidated at
create (any string binds) and immutable at update. A wrong value is accepted
silently and then cannot be corrected in place.

docs/BULK-NFS-STORAGE.md: sections 1 and 5 rewritten for TrueNAS -- the measured
exports, the service settings that decide whether uid 1000 survives the wire,
the three stale ZFS sharenfs properties that had to be cleared, and the ACL
hazard (acltype=nfsv4 + aclinherit=passthrough means one click in the ACL editor
propagates to the whole future library). Section 7 rewritten now that the
placeholder is gone, section 9 retargeted from dev to prod, section 10 new.
Sections 2/3/4/6 are unchanged by the move.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-07 01:15:04 -05:00
jakman85 ad37acdf53 Merge pull request 'fix(vpn-ingress): content-hash the peer register, or a merged peer never reaches the tunnel' (#66) from fix/peers-configmap-content-hash into main
gate / gate (push) Successful in 1m51s
gate / conformance (push) Has been skipped
Reviewed-on: #66
2026-10-06 07:58:49 +00:00
jakman85andClaude Opus 5 2acfed460a fix(vpn-ingress): content-hash the peer register, or a merged peer never reaches the tunnel
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
The operator enrolled a phone, merged the peer, and `wg show` listed none.
Measured on prod 2026-10-06 with the peer already on main:

  live wg-peers ConfigMap, active AllowedIPs .. 0
  DaemonSet volume reference ................. configMap.name: wg-peers (FIXED)
  DaemonSet generation/observedGeneration .... 1 / 1   (never rolled)
  init container, all three pods ............. "0 peer(s)"

Part of it was just the 10m reconcile interval. The defect is the fixed name:
Flux updates a fixed-name ConfigMap IN PLACE, so the pod template is
byte-identical, so no rollout is triggered -- and the init container assembles
wg0.conf ONCE at pod start into a tmpfs, so the running tunnel keeps the old
peer list indefinitely. Adding or revoking a peer had no effect until something
unrelated restarted the pods. For a REVOCATION that is a security defect, not
an inconvenience: delete a stanza, see a green PR, device keeps working.

The runbook and README both claimed "PR -> merge -> Flux rolls the DaemonSet".
Never true. And this repo already documents the fix, in a file I had read --
runbooks/kustomization.yaml: "The generated name carries kustomize's content
hash, so editing any runbook rolls the server automatically and a stale
projection is impossible."

So: peers.yaml (inline ConfigMap) -> peers.conf (plain file) + a
configMapGenerator. The operator's enrolled peer is carried across verbatim.
Every line is WireGuard config and `#` is a legal comment there, so the
documentation now lives inside the file that is actually mounted.

`namespace: vpn-ingress` on the kustomization is REQUIRED, not tidiness: a
generated ConfigMap inherits no namespace, everything else here declares
vpn-ingress, and kustomize's nameReference will not rewrite across a namespace
boundary -- without it the build mounts the unhashed name and the pods wedge on
a missing volume. Caught only by building the output and reading the volumes.

new-peer.sh: PEERS_FILE repointed, and a missing register is now FATAL rather
than skipped -- the old `[ -r "$PEERS_FILE" ] && grep ...` guard meant that
after the rename the only symptom would have been duplicate detection quietly
never firing again. Stale peers.yaml references swept from six files including
the script's own user-facing output.

Verified: the DaemonSet now mounts wg-peers-m9kcckcb5t; the hash tracks content
(edit -> dgg7ht9c74 -> restore -> m9kcckcb5t); the enrolled peer is byte-identical
to main's; and `new-peer.sh dupe 2` is now correctly refused by the operator's
real allocation.

No sensitive paths; merges on a green gate.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 02:55:59 -05:00
jakman85 e1bebb857e Merge pull request 'Added my phone to peers' (#65) from vpn/add-keith-phone into main
gate / gate (push) Successful in 1m53s
gate / conformance (push) Has been skipped
Reviewed-on: #65
2026-10-06 07:42:04 +00:00
jakman85 23db97b958 Added my phone to peers
gate / gate (pull_request) Successful in 1m53s
gate / conformance (pull_request) Has been skipped
2026-10-06 02:39:23 -05:00
jakman85 5928d8153c Merge pull request 'fix(vpn-ingress): new-peer.sh counted its own commented-out example as an allocated address' (#64) from fix/new-peer-ignores-template-comments into main
gate / gate (push) Successful in 1m50s
gate / conformance (push) Has been skipped
Reviewed-on: #64
2026-10-06 07:31:03 +00:00
jakman85andClaude Opus 5 146dd9a906 fix(vpn-ingress): new-peer.sh counted its own commented-out example as an allocated address
gate / gate (pull_request) Successful in 1m51s
gate / conformance (pull_request) Has been skipped
The first real enrolment failed:

    $ ./infra/vpn-ingress/new-peer.sh keith-phone 2
    new-peer.sh: 10.90.0.2/32 is already allocated ... Pick another octet.

Nothing was allocated -- peers.yaml had zero peers, as the pod's init
container reported (`0 peer(s)`). The duplicate-address check grepped the
whole file, and peers.yaml line 66 is a COMMENTED-OUT example stanza I wrote
containing literally `#   AllowedIPs = 10.90.0.2/32`. So .2 -- the first
address anyone would ask for, and the one my own runbook uses as its example
-- was permanently un-allocatable, with a message asserting the opposite of
the truth.

Fixed on both sides, because either alone leaves the trap half-set:

  * the check strips comment lines before matching, which is the correct
    predicate rather than a patch: a real stanza's AllowedIPs line is never
    commented, while the template and every peer's `# <name> — <owner>` label
    always are.
  * the example now uses placeholders that cannot match anything real
    (`10.90.0.<octet>/32`, `<device-name>`), so a future grep cannot be fooled
    by it either.

The bug class is worth naming: a documentation example that is byte-identical
to real data will eventually be parsed as real data. This is its third
instance in this component -- flux-postbuild-substitution flagged a `${...}`
written inside a comment explaining that hazard, and no-plaintext-secrets
flagged the .sops.yaml.example because a kustomization named it in a comment.
Make examples structurally unmatchable, not merely commented out.

Verified by running it: .2 now renders a profile (via the real
`sops -d | wg pubkey` path, which this also proves against the operator's
actual key for the first time), and an uncommented duplicate stanza is still
correctly refused.

No sensitive paths; merges on a green gate.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 02:28:37 -05:00
jakman85 d1c07fe021 Merge pull request 'fix(vpn-ingress): stop trying to set net.ipv4.ip_forward — a hostNetwork pod cannot, and wg-quick dies on a failed PostUp' (#63) from fix/vpn-ingress-ip-forward into main
gate / gate (push) Successful in 1m51s
gate / conformance (push) Has been skipped
Reviewed-on: #63
2026-10-06 07:09:34 +00:00
jakman85andClaude Opus 5 5c43fbac65 fix(vpn-ingress): stop trying to set net.ipv4.ip_forward — a hostNetwork pod cannot, and wg-quick dies on a failed PostUp
gate / gate (pull_request) Successful in 1m51s
gate / conformance (pull_request) Has been skipped
The tunnel never came up on its first reconcile with a real key. One line of my
own manifest was the whole cause. Measured on prod 2026-10-06:

    [#] ip link add dev wg0 type wireguard        <- fine
    [#] wg addconf wg0 /dev/fd/63                 <- fine
    [#] ip -4 address add 10.90.0.1/24 dev wg0    <- fine
    [#] ip link set mtu 1420 up dev wg0           <- fine
    [#] sysctl -q -w net.ipv4.ip_forward=1
    sysctl: permission denied on key "net.ipv4.ip_forward"
    [#] ip link delete dev wg0
    **** Tunnel failed, will stop all others! ****

Not a missing capability: with hostNetwork the runtime mounts /proc/sys
READ-ONLY (confirmed in the pod's /proc/mounts), because those sysctls are the
HOST's globals. CAP_NET_ADMIN cannot lift a read-only mount, so the write can
never succeed here -- and wg-quick treats a failed PostUp as fatal, so it
deleted the interface it had just built.

The line was never needed. node_baseline already sets ip_forward=1, and reading
it from the pod returns 1. My header comment said exactly that ("so no pod
sysctl is needed") and then emitted the sysctl anyway, justified as "re-asserted
so this component does not silently depend on another role's sysctl file". That
defensive re-assertion is the entire outage. In a hostNetwork pod: VERIFY host
state, never write it.

Replaced with a read in the init container -- `grep -qx 1
/proc/sys/net/ipv4/ip_forward` -- failing closed with a message naming the
node_baseline role. Still fail-closed on purpose: a tunnel that comes up unable
to forward satisfies every probe here and silently drops all client traffic.

Also documents, in the manifest and the runbook, that the image's `chown:
Operation not permitted` run and its "we will not provide support for it"
warning are expected and harmless -- capabilities are dropped to NET_ADMIN so
its s6 init has no CAP_CHOWN, /config is a writable tmpfs, everything runs as
root, and wg-quick proceeds (it did; the failure was 30 lines later).

Three other unknowns closed by the same reconcile, now recorded in the README:
the kernel module reaches the pod (ip link add type wireguard works, no
CAP_SYS_MODULE needed); allowPrivilegeEscalation:false does NOT break the s6
init; and the image's iptables is nf_tables (xtables-nft-multi) matching Debian
13, which closes the worst documented fear -- rules landing in a table the
kernel never consults.

No sensitive paths; merges on a green gate with no override.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 01:30:08 -05:00
jakman85 cf7d5e4316 Merge pull request 'Changed secret' (#62) from feature/add-private-key-for-wireguard into main
gate / gate (push) Successful in 1m50s
gate / conformance (push) Has been skipped
Reviewed-on: #62
2026-10-06 06:18:13 +00:00
jakman85 be23cf3e26 Changed secret
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
2026-10-06 01:11:56 -05:00
jakman85 4791653f2b Merge pull request 'fix(vpn-ingress): keep the server key in source through SOPS, not as an out-of-band Secret' (#60) from fix/vpn-server-key-in-sops into main
gate / gate (push) Successful in 1m53s
gate / conformance (push) Has been skipped
Reviewed-on: #60
Reviewed-by: Keith Jackson <[email protected]>
2026-10-06 06:09:04 +00:00
jakman85 523f482957 Merge pull request 'fix(node): pin gitea to the Gateway VIP — the failing certificate was the router's, not the Gateway's' (#59) from docs/pm-agent-registry-pull-prereq into main
gate / gate (push) Successful in 1m51s
gate / conformance (push) Has been skipped
Reviewed-on: #59
2026-10-06 05:51:22 +00:00
jakman85andClaude Opus 5 8059297036 fix(devcontainer): bake wg + qrencode into the image instead of splitting the VPN procedure across host and container
gate / conformance (pull_request) Has been skipped
gate / gate (pull_request) Successful in 1m53s
Operator correction: "sop, wg, and any other tool requirement is supposed to be
baked into the devcontainer."

My previous commit discovered that `wg` existed only on the host, `sops` only in
the container, and `qrencode` on neither -- and fixed it the wrong way, by
splitting every step of the key procedure across the boundary and telling the
operator to `dnf install qrencode` on their workstation. That breaks CLAUDE.md §5
and the devcontainer README's "Don't install the toolchain on the host", produces
a procedure that cannot be reproduced in CI, and is miserable to follow.

wireguard-tools + qrencode are now distro packages in the Containerfile. Both are
in Fedora 44's repos (wireguard-tools 1.0.20260223 -- the same upstream version as
the image this component runs -- and qrencode 4.1.1), unpinned like every other
dnf package there: the pinned thing is the Fedora base, and neither is a gate tool
whose version changes what green means.

Three things got SIMPLER rather than merely relocated, which is the tell that the
rule was right:

  * Step 2 is one pipeline in one shell -- `sops set ... "$(wg genkey)"` writes the
    key straight into the already-encrypted document. No staged copy in .cache, no
    `*.key`-suffix warning, no sha256 comparison to prove the right bytes landed.
  * new-peer.sh derives the server public key from wg-server-key.sops.yaml itself
    (`sops -d --extract | wg pubkey`), so there is no local key copy to keep in
    step with the encrypted one, and no magic host path it must live at.
  * `.cache/` is gitignored at the repo root (mirroring infra/ansible/.gitignore),
    giving the container a persistent non-committable scratch dir. new-peer.sh
    writes profiles to .cache/wg-peers/ -- necessary because the repo bind-mount is
    the container's only persistent read-write mount, so a profile in /tmp there is
    gone before it can reach a desktop. Its blanket "refuse to write inside the
    checkout" guard is now a `git check-ignore` test of the real property.

Verified by execution, not reasoning: the image rebuilds; wg/qrencode/sops all
resolve in it; `sops -d | wg pubkey` runs in one shell; new-peer.sh refuses the
REPLACE-ME placeholder with an actionable message; and a full run with a throwaway
server key renders a correct profile at 0600 under a 0700 gitignored dir, prints
the QR, and refuses a non-ignored destination inside the repo.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 00:48:32 -05:00
jakman85andClaude Opus 5 90a79d29d3 docs(node): name the exact Ansible command that applies the pin, not 'run the play'
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
Node configuration in this project is Ansible's — declared in the inventory, reviewed,
version-controlled — and for prod it is the ONLY surface: infra/PROD-PROVISIONING.md
records prod as 3x bare metal with the dev OpenTofu -> Proxmox -> cloud-init path
explicitly not applying, so there is no tofu/cloud-init half to keep in parity for an
/etc/hosts pin.

The pin was already in the right place; what the docs lacked was the command. All four
places now carry the scoped, idempotent one:

    cd infra/ansible && ansible-playbook playbooks/node-baseline.yml \
      -e k3s_hosts=k3s_prod --tags hosts

--tags hosts runs ONLY the /etc/hosts task, so applying this pin cannot drift any other
baseline setting.

Also verified the mechanism is not inert, which is the failure mode this repo keeps
meeting: roles/node_baseline/tasks/main.yml renders node_baseline_hosts_pins into a marked
blockinfile with backup: true, and state: absent when the list empties — so a removed
entry really does leave the node.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 00:44:26 -05:00
jakman85andClaude Opus 5 523a463fd7 fix(node): pin gitea to the Gateway VIP — containerd was resolving it to the non-hairpinned WAN
gate / gate (pull_request) Successful in 1m52s
gate / conformance (pull_request) Has been skipped
The pm-agent image pull failed TLS verification, and the previous entry on this branch
recorded it honestly as not diagnosed. Diagnosed now, and it is not about the image.

containerd pulls as a NODE process, so it resolves through node_baseline_dns_servers (the
LAN gateway, deliberately not PiHole) and gets the public answer: a CNAME to
wan.home.keithjackson.dev -> 136.51.115.252, measured via both 1.1.1.1 and 8.8.8.8. This
router does not hairpin, so WAN:443 answers with its own placeholder certificate —
measured with the right SNI from the workstation:

    subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
    No extensions in certificate

No extensions means no subjectAltName, which is exactly 'certificate is not valid for any
names'. Meanwhile 192.168.2.20 IS the shared Gateway's VIP and its https listener serves
the real Let's Encrypt *.home.keithjackson.dev. The Gateway, the wildcard cert and the
digest were never implicated — only the node's answer.

This file already documents the identical failure for the Keycloak issuer, measured from
k3s-prod-2/-3 on 2026-10-03 (curl exit 60, ssl_verify_result=18), and already carries the
mechanism that fixes it. gitea is simply the second node process to need it; containerd is
a node process and nobody had added it. An operator must run the node_baseline play.

Also records, where an operator reads them, that the agent authenticates to Gitea with a
local account and a scoped PAT by decision — and why the federated alternative is not
merely unchosen but unavailable: Gitea implements only the Authorization Code Grant and
validates no third-party OIDC token, so a Keycloak service-account token cannot
authenticate to its API or to git-over-HTTP.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-06 00:35:18 -05:00
jakman85andClaude Opus 5 e35612dc7e fix(vpn-ingress): keep the server key in source through SOPS, not as an out-of-band Secret
gate / gate (pull_request) Failing after 1m51s
gate / conformance (pull_request) Has been skipped
Operator correction: secrets are kept in source through sops. #55 shipped the
WireGuard server private key as a Secret to `kubectl create` by hand. Two real
costs, not just a convention break:

  * R-SEC-01 / spec docs/07 -- secrets live in source, SOPS-encrypted, and
    reach the cluster only through Flux.
  * It broke DR for the one credential that cannot be re-derived. A rebuild
    restores everything in git; it does not restore a hand-made Secret, and
    losing this key means re-enrolling EVERY device, because each client
    profile pins its public half. #55 papered over that with "keep a copy in
    your password manager".

(Note secret-provenance did NOT forbid the original shape -- it scopes to
app-namespace=true and vpn-ingress is deliberately a platform namespace. The
rule broken was the written requirement, not an enforced control.)

An agent still must not mint a long-lived private key (CLAUDE.md §6), so the
file is committed ALREADY ENCRYPTED with `privatekey: REPLACE-ME`. That makes
the wiring complete and green from the first commit -- resource line plus both
overlay decryption patches -- leaving the operator exactly one act: replace the
value. A commented-out resource to uncomment later is the "in git, no generator
entry, reviewed, merged, still broken" shape this repo keeps hitting.

The init guard is now a validation rather than an emptiness check: the value is
piped through `wg pubkey`, which accepts only a real curve25519 private key, and
the pod names which case it hit -- placeholder, empty, mangled, or (the silent
one) still `ENC[` ciphertext because an overlay is missing its decryption patch,
which otherwise applies cleanly with every Flux condition green.

Second, independent finding that also fixes #55's step 2: `wg` is on the HOST
only and `sops` is in the CONTAINER only, so `sops -d | wg pubkey` cannot run in
one shell -- the command #55 documented could not have worked. Every step is now
split by where it runs, and the written key is verified by comparing sha256
digests rather than printing anything. `qrencode` is on neither, so the QR step
says so and gives the install command.

Touches clusters/** and adds a **/*.sops.yaml, so approval-policy will block
pending the operator's /approve-override. Deliberately not posted by me.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:55:29 -05:00
jakman85andClaude Opus 5 a209a618fd fix(pm-agent): the agent is IN the cluster — one git host, the cluster-local service name
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
An in-cluster consumer reaches a service by its *.svc.cluster.local name, which is by
design a different path from how a client outside the cluster reaches the same service.
pm-worker's host allowlist accepted TWO values: the cluster-local name and the external
hostname gitea.home.keithjackson.dev. The second is removed.

It was never needed — nothing sets it — it would route in-cluster traffic out through the
Gateway and back for no reason, and as an ACCEPTED value it widened the allowlist to a
host reachable over the namespace's world:443 egress rule, which is precisely the
injection shape the allowlist exists to refuse.

probe-validation.sh now pins that: 20 refusals, including the external hostname, the
cluster-local name over https, and the cluster-local name on the wrong port.

Also corrects a remark in the handoff entry: I cited cluster-vars' GATEWAY_VIP
(192.168.1.53) as suspicious next to the nodes' 192.168.2.x. That was noise — those base
cluster-vars values are dev-era leftovers for a decommissioned cluster and say nothing
about prod. None of the measured facts depended on it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:40:55 -05:00
jakman85andClaude Opus 5 6253d546f8 docs(pm-agent): the registry cannot be pulled from yet — name both prerequisites where the operator reads them
gate / gate (pull_request) Successful in 1m50s
gate / conformance (pull_request) Has been skipped
pm-agent reconciled exactly as designed after #57: Flux applied main@sha1:95597bd to
platform-config, pm-agent and apps-wave-0 (all Ready), the ClusterPolicy
pm-agent-job-shape is live with Enforce + failurePolicy Fail and all six rules, SOPS
decryption works through the new overlay patch (checked by key names and value lengths,
never values), and pm-agent-worker really is bound to nothing.

Measuring it turned up two prerequisites for the NEXT operator step that nothing in this
repository declares, and that #57's handoff could not have known — it listed the image
pull as unverified, and that is exactly where it bit:

 - the nodes cannot verify the registry's TLS certificate (containerd: 'x509: certificate
   is not valid for any names'), while the same name serves a healthy Let's Encrypt
   wildcard from the LAN. A correct digest fails identically;
 - an unauthenticated client gets nothing from this Gitea's registry (its anonymous token
   carries no access scope) and no imagePullSecret exists anywhere in the tree.

Not specific to pm-agent: conformance-runner shares the registry path and has never been
pulled either. The dispatcher is the first workload in the cluster ever to reference it.

Documentation only — no manifest, no policy, no code. Both fixes need an operator action
and one of them is a posture decision, so the docs present both rather than pick.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:22:52 -05:00
jakman85 95597bd688 Merge pull request 'feat(pm-agent): an in-cluster AI Program Manager that turns Gitea issues into pull requests' (#57) from feat/pm-agent-issue-to-pr into main
gate / gate (push) Successful in 1m49s
gate / conformance (push) Has been skipped
Reviewed-on: #57
Reviewed-by: Keith Jackson <[email protected]>
2026-10-06 04:12:31 +00:00
jakman85 06ca9eeb88 Merge pull request 'fix(runbooks): vpn-remote-access step 1 named a nonexistent script; step 2 cited the wrong gitignore rule' (#58) from fix/vpn-runbook-ansible-command into main
gate / conformance (push) Has been cancelled
gate / gate (push) Has been cancelled
Reviewed-on: #58
2026-10-06 04:12:27 +00:00
jakman85andClaude Opus 5 18f741d744 fix(runbooks): vpn-remote-access step 1 named a nonexistent script; step 2 cited the wrong gitignore rule
gate / gate (pull_request) Successful in 1m47s
gate / conformance (pull_request) Has been skipped
Both reported by the operator on first use of the runbook merged in #55.

Step 1 said `cd infra/ansible && ./site.sh --tags system --limit k3s_prod`.
There is no site.sh anywhere in this repo. It was wrong four ways at once:
this repo drives Ansible with `ansible-playbook <playbook>` and never a
wrapper (playbooks/site.yml is the full bring-up chain, not this); the module
work is in playbooks/node-baseline.yml; targeting here is `-e k3s_hosts=...`,
not `--limit`; and Ansible exists only in the pinned toolbox, with ansible.cfg
read only from infra/ansible. Replaced with the real command plus why
`-e k3s_hosts=k3s_prod` matters (the default group is decommissioned dev) and
why `--tags system` must never be dropped (an untagged run also executes the
`disks` tasks, which assert on prod's deliberate placeholder devices).

Step 2 told the operator to stage the server key at /work/.cache/server.key
"inside the repo's gitignored cache". .cache/ is NOT gitignored -- the file was
ignored only incidentally by `*.key` (.gitignore:11). So the path was safe for
a reason I had not identified, and renaming it (server.pem, wg-server) would
have left a committable WireGuard private key in the checkout, which
no-plaintext-secrets does not recognise. The step now names the real rule,
marks the suffix as the load-bearing part, and gives a check-ignore
verification to run before trusting it.

Docs only -- no manifest and no behaviour change. Touches no sensitive path.
Also records the post-merge measurements for #55, whose own entry predates the
merge: Flux applied ad3f56e by the real path, all 18 Kustomizations Ready,
hostNetwork confirmed (pod IPs are the node IPs), and the designed fail-closed
state (Init:0/1 on the missing wg-server-key Secret).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-10-05 23:08:19 -05:00
128 changed files with 16122 additions and 769 deletions

No files matched your search

+45
View File
@@ -115,14 +115,59 @@ CI. That is a real coverage gap, not a phase.
| variable | `REQUIRE_CLUSTER_TESTS` | `'true'` (Phase 1+) makes unverified cluster-needing touched reqs a hard block |
| secret | `CONFORMANCE_TRIGGER_KUBECONFIG` | narrow creds to create/await the in-cluster Chainsaw Job (not cluster-admin) |
## How images are built
This repo's three images — `toolbox` (`infra/devcontainer/`), `agent-runner` (`infra/agent/`,
built `FROM` the toolbox) and `conformance-runner` (`conformance/incluster/`) — are built,
verified and published by [`images.yaml`](images.yaml), never from a workstation
(operator rule, 2026-10-29). The logic is in [`scripts/images.sh`](scripts/images.sh).
**The builder is rootless BuildKit** ([`platform/buildkit/`](../../platform/buildkit/README.md)),
reached over mTLS with a pinned `buildx` — no job here touches a Docker daemon, privileged or
otherwise (operator decision on PR #81). The client certificate reaches jobs as the Actions
secrets `BUILDKIT_CA`, `BUILDKIT_CLIENT_CERT`, `BUILDKIT_CLIENT_KEY`.
**Verification is a build target.** Each Containerfile ends in a `verify` stage that runs every
in-image assertion at build time as the non-root uid with `RUN --network=none` (tool presence and
pinned versions, `PM_SELFTEST=1 pm-dispatch`, the pm-worker probe, the `conformance-clone`
refusals, no writable path outside the workspace). The pipeline builds `<image>-verify` before it
builds, let alone pushes, the image. Build args come from each image's own
`build-image.sh --print-build-args`, which is also the local podman path — so the recipe and the
proof exist once.
| Trigger | What happens |
|---|---|
| pull request touching an image's inputs | build + verify only. Nothing is pushed and **no registry credential is referenced** |
| push to `main` touching an image's inputs | build + verify + publish to `gitea.home.keithjackson.dev/home-network/<image>` as `sha-<commit>` (immutable) and `main` (moving); the **registry-returned** digest is in the job summary |
| `workflow_dispatch` | the same as a push when run on `main` (all images by default); verify-only elsewhere |
Only images whose inputs changed are built. The publish job **never writes to git**: Renovate
follows each image's `main` tag through a `# renovate-digest:` marker next to every digest
reference (`platform/pm-agent/`, `infra/agent/versions.env`, `conformance/versions.env`) and
opens the bump PR, which goes through the gate like anything else. A PR opened with the Actions
token would not trigger the required gate, which is why it is Renovate's job and not this one's.
Credential: the repository secret **`REGISTRY_PUSH_TOKEN`** (scope `write:package`, owned by an
account with package write on the org). The automatic Actions token cannot push in Gitea 1.24.3
— the reasoning, with source citations, is in the header of `images.yaml`. Retention:
[`infra/gitea/registry-retention.sh`](../../infra/gitea/registry-retention.sh), an operator
action, keeps every digest git pins plus the newest tags.
Cost: the job shares the single act_runner slot with the gate, but the build itself runs in the
`buildkitd` pod, not in DinD, so the runner's disk and memory are untouched. Until
`platform/buildkit` is deployed and its client certificate copied
(`infra/gitea/buildkit-client-secret.sh`), the job fails loudly at its builder preflight.
## Files
```
.gitea/workflows/
merge-gate.yaml the pipeline (lint → l0 → l1 → conformance → decision)
images.yaml build/verify/publish this repo's container images (see above)
scripts/
install-tools.sh installs the pinned tools per stage (from ci/versions.env)
decide.sh the R-TEST-03 merge decision over job results
images.sh the image pipeline's driver (what to build, base, push, digest)
README.md this file
```
+185
View File
@@ -0,0 +1,185 @@
# Container images — built, verified and published by CI, never from a workstation.
#
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# WHY THIS FILE EXISTS (operator rule, 2026-10-29)
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# "Container image creation, hosting and lifecycle must be part of CI/CD and the codebase, never a
# one-off build/push from a workstation." Before this workflow, three images existed only as
# workstation builds and NOTHING had ever been pushed to the Gitea registry: the Home-Network org's
# package list was empty, platform/pm-agent/ pinned an all-zero digest and sat in ImagePullBackOff,
# and conformance/versions.env carried an empty digest. Both build scripts said "publishing is an
# operator action" — a step with no log, no recorded inputs and no verification anyone could read.
#
# WHAT IT DOES — the logic is in scripts/images.sh; this file only wires triggers and secrets.
# pull_request -> build + VERIFY only. No push, and no registry credential is referenced.
# push to main -> build + verify + PUBLISH to gitea.home.keithjackson.dev/home-network/<image>
# dispatch -> same as a push when run on main; verify-only on any other ref.
# Images: toolbox (infra/devcontainer), agent-runner (infra/agent, FROM toolbox) and
# conformance-runner (conformance/incluster). Only images whose inputs changed are built.
#
# THE DIGEST LIFECYCLE — this workflow NEVER writes to git.
# 1. publish pushes `sha-<commit>` (immutable) and `main` (moving), and prints the digest the
# REGISTRY returns in the job summary.
# 2. Renovate (renovate.yaml) follows the `main` tag of each image through the
# `# renovate-digest:` markers next to every digest reference, and opens the bump PR.
# 3. That PR goes through the gate like any other change, and Flux deploys it.
# The bump PR is Renovate's and not this job's because a PR opened with the Actions token does not
# trigger workflows, so it could never pass the REQUIRED gate check; Renovate's token is a real
# user's.
#
# THE PUSH CREDENTIAL — `REGISTRY_PUSH_TOKEN`, a repository Actions secret, referenced BY NAME.
# The automatic Actions token CANNOT be used. Gitea 1.24.3 source, verified:
# * services/auth/basic.go (Basic.Verify) turns a task token into the pseudo-user
# `gitea-actions` (ActionsUserID -2) and sets NO token scope;
# * routers/api/packages/container/container.go (Authenticate, the /v2/token exchange every
# docker client uses) then requires read:package / write:package / all scope and answers 401;
# * services/context/package.go (determineAccessMode) has `// TODO: ActionUser permission check`
# and no branch for that user, so even past login it would get at most READ on a public org.
# So: a personal access token with scope `write:package`, minted by an account that has package
# WRITE on the Home-Network org (org owner, or a team with the Packages unit at write). A dedicated
# bot account in a packages-only team is the least-privilege choice. The username sent with it is
# not checked by Gitea (basic.go resolves the owner from the token), so `vars.REGISTRY_PUSH_USER`
# is optional and only labels the login.
#
# THE BUILDER — rootless BuildKit (operator decision on PR #81, 2026-10-29).
# The act runner's job containers get NO Docker daemon (act_runner 0.2.11 forces docker_host "-"
# for a tcp:// DinD, and gitea/act strips every bind when valid_volumes is empty — measured on
# this PR's first run), and handing them the privileged DinD daemon would be root on the node for
# anyone who can push a branch. So images are built by platform/buildkit/: a ROOTLESS buildkitd
# (not privileged; uid 1000 in a user namespace), reached over mTLS at
# buildkitd.buildkit.svc.cluster.local:1234 with a pinned `buildx` (remote driver). Its client
# certificate reaches jobs as three Actions secrets — BUILDKIT_CA, BUILDKIT_CLIENT_CERT,
# BUILDKIT_CLIENT_KEY — copied from cert-manager by infra/gitea/buildkit-client-secret.sh.
# Pull requests get the client certificate (they must build) but never a registry credential.
#
# UNTIL platform/buildkit IS DEPLOYED (Flux applies it only after merge) THIS JOB FAILS, LOUDLY,
# at its builder preflight. A skipped Gitea job reports `success`, so it is red on purpose.
#
# VERIFICATION is each Containerfile's `verify` stage, built before anything is pushed.
#
name: images
on:
pull_request:
branches: [main]
paths:
- infra/devcontainer/Containerfile
- infra/devcontainer/versions.env
- infra/devcontainer/build-image.sh
- infra/agent/**
- conformance/incluster/Containerfile
- conformance/incluster/conformance-clone
- conformance/incluster/build-image.sh
- .gitea/workflows/images.yaml
- .gitea/workflows/scripts/images.sh
- platform/buildkit/versions.env
push:
branches: [main]
paths:
- infra/devcontainer/Containerfile
- infra/devcontainer/versions.env
- infra/devcontainer/build-image.sh
- infra/agent/**
- conformance/incluster/Containerfile
- conformance/incluster/conformance-clone
- conformance/incluster/build-image.sh
- .gitea/workflows/images.yaml
- .gitea/workflows/scripts/images.sh
- platform/buildkit/versions.env
workflow_dispatch:
inputs:
images:
description: "Which images: 'all', or a space-separated subset of: toolbox agent-runner conformance-runner"
required: false
default: "all"
concurrency:
# Never cancel a publish half-way: a pushed `sha-` tag with no matching `main` move is
# recoverable (a rerun skips what is already published) but pointless to provoke.
group: images-${{ github.ref }}
cancel-in-progress: false
jobs:
images:
runs-on: ubuntu-latest
# Measured on the PR that introduced this file — see its decisions entry. Generous because a
# cold DinD store downloads the Fedora base and every pinned tool on each run.
timeout-minutes: 75
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0 # the per-image change detection diffs against the base commit
# ── pull requests: build + verify, NO credential ───────────────────────────────────────
- name: Build and verify (pull request — nothing is pushed)
if: github.event_name == 'pull_request'
env:
MODE: verify
IMAGES: auto
PR_BASE_REF: ${{ github.base_ref }}
# The BUILDER's client certificate (platform/buildkit). Not a registry credential:
# a pull request can build, never push.
BUILDKIT_CA: ${{ secrets.BUILDKIT_CA }}
BUILDKIT_CLIENT_CERT: ${{ secrets.BUILDKIT_CLIENT_CERT }}
BUILDKIT_CLIENT_KEY: ${{ secrets.BUILDKIT_CLIENT_KEY }}
run: |
set -euo pipefail
BASE_SHA="$(git merge-base "origin/${PR_BASE_REF}" HEAD)"
export BASE_SHA
./.gitea/workflows/scripts/images.sh
# ── non-main dispatch: verify only ──────────────────────────────────────────────────────
- name: Build and verify (dispatch off main — nothing is pushed)
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main'
env:
MODE: verify
IMAGES: ${{ inputs.images == 'all' && 'toolbox agent-runner conformance-runner' || inputs.images }}
BUILDKIT_CA: ${{ secrets.BUILDKIT_CA }}
BUILDKIT_CLIENT_CERT: ${{ secrets.BUILDKIT_CLIENT_CERT }}
BUILDKIT_CLIENT_KEY: ${{ secrets.BUILDKIT_CLIENT_KEY }}
run: ./.gitea/workflows/scripts/images.sh
# ── main: publish ───────────────────────────────────────────────────────────────────────
- name: Preflight - the push token must exist, and its absence must be LOUD
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env:
REGISTRY_PUSH_TOKEN: ${{ secrets.REGISTRY_PUSH_TOKEN }}
run: |
set -euo pipefail
if [ -z "${REGISTRY_PUSH_TOKEN:-}" ]; then
echo "::error::REGISTRY_PUSH_TOKEN is not set on this repository." >&2
cat >&2 <<'MSG'
Images cannot be published without a registry credential, and this step fails rather
than skipping ON PURPOSE: a skipped Gitea job reports `success`, so skipping here would
make "nothing was published" look exactly like "published". That is how this repo went
a month with images that existed only on one workstation.
WHY NOT THE BUILT-IN ACTIONS TOKEN: Gitea 1.24.3 gives it no package scope at the
registry's /v2/token exchange (routers/api/packages/container/container.go,
Authenticate) — see the header of .gitea/workflows/images.yaml.
TO FIX: as an account with package WRITE on the Home-Network org (ideally a dedicated
bot in a packages-only team), create an access token with scope `write:package` and
nothing else, then add it to this repository as the Actions secret
REGISTRY_PUSH_TOKEN. Optionally set the Actions variable REGISTRY_PUSH_USER to that
account's name. Do not commit the token anywhere. Then re-run this workflow
(workflow_dispatch on main, images: all).
MSG
exit 1
fi
echo "REGISTRY_PUSH_TOKEN is present (value not printed)."
- name: Build, verify and publish (main)
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
env:
MODE: publish
IMAGES: ${{ github.event_name == 'workflow_dispatch' && (inputs.images == 'all' && 'toolbox agent-runner conformance-runner' || inputs.images) || 'auto' }}
BASE_SHA: ${{ github.event.before }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_PUSH_TOKEN }}
REGISTRY_USER: ${{ vars.REGISTRY_PUSH_USER }}
BUILDKIT_CA: ${{ secrets.BUILDKIT_CA }}
BUILDKIT_CLIENT_CERT: ${{ secrets.BUILDKIT_CLIENT_CERT }}
BUILDKIT_CLIENT_KEY: ${{ secrets.BUILDKIT_CLIENT_KEY }}
run: ./.gitea/workflows/scripts/images.sh
+318
View File
@@ -0,0 +1,318 @@
# Renovate — the dependency/image update automation. SCHEDULED, self-hosted against this Gitea.
#
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# WHY THIS FILE EXISTS: renovate.json HAS BEEN IN THIS REPO AND HAS NEVER ONCE RUN.
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# Measured 2026-10-17: `renovate.json` is present, complete and thoughtfully written — it declares
# managers, schedules, automerge classes and grouping. Nothing executes it. There is no Renovate
# workflow (this is the first), no Renovate CronJob or Deployment in the cluster
# (`kubectl get cronjob,deploy,job -A | grep -i renovate` -> nothing), and no bot account doing it
# from outside. Meanwhile SEVEN files carry comments asserting "Renovate maintains this pin"
# (platform/homepage/deployment.yaml:40, runbooks/serving/deployment.yaml:87,
# platform/crafty/deployment.yaml:63, platform/vpn-ingress/daemonset.yaml:259,
# platform/keycloak/postgres-cluster.yaml:75, apps/wave-0/nextcloud-db.yaml:302, and
# platform/headlamp/helmrepository.yaml:2). Those comments have been false for the life of the file.
#
# The cost is measurable. With nothing bumping anything: the six per-app oauth2-proxy auth sidecars
# sit on v7.8.1 while the platform oauth2-proxy in the SAME cluster runs v7.15.5; Keycloak (the IdP)
# runs 26.0.6, a November-2024 release, 8 minors behind; Velero runs a RELEASE CANDIDATE
# (velero-plugin-for-aws v1.14.3-rc.2) with a stable available; the CNPG Postgres image is 16.4,
# missing every 16.5+ security release. A pin nothing maintains is not a pin, it is a fossil.
#
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# WHY RENOVATE AND NOT FLUX IMAGE-AUTOMATION (the decision, and it reverses ADR-0009's split)
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# ADR-0009 divided the work: Renovate owns Helm chart/dependency bumps, Flux image-automation owns
# per-service container image TAGS, deliberately non-overlapping. The second half of that split
# cannot be delivered on this repo's terms, for three measured reasons:
#
# 1. IT WOULD BYPASS THE MERGE GATE, WHICH CLAUDE.md §1 FORBIDS. ImageUpdateAutomation does not
# open pull requests; it COMMITS AND PUSHES. Its default target is the source's own branch —
# `main` — so it would write straight past the PR flow. It can be pointed at a side branch
# instead, but then nothing opens the PR and bumps silently accumulate on a branch nobody
# merges. Either way a human or a second bot has to close the loop.
# 2. ON THIS CLUSTER IT CANNOT EVEN DO THAT. Measured 2026-10-17: `main` is protected with
# `user_can_push: false`, and the `flux-system` GitRepository has NO `spec.secretRef` at all —
# the source is anonymous and read-only. Enabling write-back means minting an in-cluster git
# WRITE credential for the repository that defines the cluster, which is a materially larger
# blast radius than a CI-scoped token.
# 3. RENOVATE ALREADY OPENS PRs NATIVELY, so every change it proposes goes through the gate with
# no extra machinery.
#
# So: Renovate owns BOTH halves, and Flux image-automation stays disabled. Note this is a change of
# plan, not a change of state — image-automation has been commented out at
# platform/kustomization.yaml:52 since it was authored, and the cluster has ZERO
# ImageRepository/ImagePolicy/ImageUpdateAutomation objects (measured). Its two controllers ARE
# installed and have idled for 43 days. Nothing is being torn down here; a path that was never
# taken is being formally closed, and renovate.json's `docker`/`kubernetes` managers — disabled
# purely to avoid fighting a controller that never ran — are switched on to fill the gap.
#
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# THIS WORKFLOW MUST NOT BE ABLE TO SUCCEED SILENTLY
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# A SKIPPED Gitea job reports `success` (CLAUDE.md §2), so "green" is not evidence this ran. Two
# guards:
# * The preflight step FAILS HARD (exit 1) if RENOVATE_TOKEN is absent, rather than skipping.
# A missing credential on a dependency bot is indistinguishable from "nothing to update", and
# that is precisely how renovate.json stayed inert for months.
# * The summary step records what ran into the job summary unconditionally.
# What this still does NOT give you is an alert when the SCHEDULE stops firing. See the handoff
# entry's "What I did NOT verify" — that gap is stated, not papered over.
#
# OPERATOR PREREQUISITE — ONE SECRET, NOT COMMITTED HERE:
# `RENOVATE_TOKEN` — a Gitea access token for the bot account (`renovate-bot`) with FIVE scopes.
# Each is required by a call Renovate 44.x makes against Gitea 1.24.3, and Gitea refuses a
# token missing one with `403 token does not have at least one of required scope(s)`:
# read:user GET /user (platform init: initPlatform)
# read:organization GET /orgs/{owner}, /orgs/{o}/labels (initRepo -> isOrg, then org labels)
# write:repository clone/push branches, /pulls, /statuses
# write:issue /issues, /labels, /issues/{n}/labels (dependency dashboard, PR labels;
# Gitea files all of these under the ISSUE scope, not repository)
# read:package the Gitea container registry (RENOVATE_HOST_RULES below)
# CORRECTED 2026-10-30. This list used to name only write:repository + read:user (+ read:package),
# and the operator minted exactly that. Run 350 then failed at
# `GET /api/v1/orgs/Home-Network 403 ... required=[read:organization], token scope=read:package,
# write:repository,read:user`; write:issue would have been the next wall. The preflight below now
# PROBES these scopes, so a short token fails in seconds with the missing scope named.
# Set it as an Actions secret on this repository. Nothing in this repo mints,
# stores or references its value; it is consumed by name only.
# Until it exists, this workflow FAILS LOUDLY on every scheduled run. That is intended: a
# visible red is the correct state for "automation configured but not credentialed".
#
# THE FIRST RUN WILL WANT TO OPEN A LOT OF PRs. ~30 dependencies are behind. renovate.json caps the
# blast radius (`prConcurrentLimit: 5`, `prHourlyLimit: 2`), but run it manually with
# `dry_run: true` FIRST and read the log before letting the schedule loose. That is what
# workflow_dispatch's default is for.
name: renovate
on:
schedule:
# 08:15 UTC daily. renovate.json ALSO carries `"schedule": ["after 3am and before 6am"]` with
# `"timezone": "America/New_York"`, and THAT inner schedule is authoritative — Renovate re-checks
# it and DECLINES TO OPEN PRs outside it. So this cron must fire INSIDE the local window, not
# merely near it.
#
# WAS `15 3 * * *`, WHICH NEVER WOULD HAVE WORKED (fixed 2026-10-18, before the first scheduled
# run). Gitea Actions cron is UTC, so 03:15 UTC is 23:15 the PREVIOUS EVENING in New York --
# 3h45m BEFORE the window opens, not "slightly ahead" of it as the original comment claimed.
# Every nightly run would have started, logged "not within schedule", opened zero PRs and
# exited SUCCESSFULLY. A green tick forever, and no updates ever: the exact "failure mode is a
# green tick" shape this repo keeps getting burned by, and indistinguishable from "nothing
# needed updating".
#
# THE WINDOW IN UTC MOVES WITH US DST, and a cron cannot follow it:
# EDT (UTC-4, Mar-Nov): local 03:00-06:00 == 07:00-10:00 UTC
# EST (UTC-5, Nov-Mar): local 03:00-06:00 == 08:00-11:00 UTC
# 08:15 UTC is the only quarter-hour comfortably inside BOTH (04:15 EDT / 03:15 EST), so the
# schedule keeps working across both switchovers without an edit. Do not "tidy" this back to an
# early-morning-UTC value without redoing this arithmetic.
- cron: "15 8 * * *"
workflow_dispatch:
inputs:
dry_run:
description: "Dry run (no PRs, no branches). Leave true for the first run."
required: false
default: "true"
log_level:
description: "Renovate LOG_LEVEL (info | debug)"
required: false
default: "info"
concurrency:
# Never two Renovate runs at once: concurrent runs race on the same branches and can open
# duplicate PRs. Not cancel-in-progress — a half-finished Renovate run can leave a branch pushed
# with no PR, so let the first one finish.
group: renovate
cancel-in-progress: false
jobs:
renovate:
runs-on: ubuntu-latest
# RENOVATE RUNS AS THE JOB CONTAINER, NOT THROUGH renovatebot/github-action. FIXED 2026-10-30.
# That action does exactly one thing: `docker run ghcr.io/renovatebot/renovate:<version>`. On
# this runner a job has NO Docker daemon, by decision (decisions/2026-10-29-image-pipeline.md:
# act_runner sets container.docker_host to `-` when DOCKER_HOST is tcp://, and the DinD daemon
# is privileged, so a job that could reach it would be root on the node). So every run of the
# action died before Renovate started. Dispatch run 343 (2026-10-30), verbatim:
# [command]/usr/bin/docker run --env LOG_LEVEL ... --env RENOVATE_TOKEN=*** ...
# failed to connect to the docker API at unix:///var/run/docker.sock; ... no such file or directory
# ::error::Error: The process '/usr/bin/docker' failed with exit code 1
# No auth, scope, config or network question was ever reached. Making the Renovate image the
# job container needs no daemon: act starts it through the runner's own DinD exactly as it
# starts the default ubuntu image, then `exec`s each step inside it.
#
# PINNED here (was the action's `renovate-version: 44.167.0`). renovate.json5 is validated
# against this exact tag — keep them in step. Renovate's github-actions manager reads
# `.gitea/workflows` and extracts `container.image`, so this pin is still self-maintained.
#
# The image runs as uid 12021 (`ubuntu`), and that is fine under act: the step command files
# ($GITHUB_STEP_SUMMARY etc.) are created mode 0666 (gitea/act v0.261.2 pkg/runner/step.go),
# HOME comes from the image env, and no step writes the workspace. There is deliberately NO
# actions/checkout: Renovate clones the repo itself from RENOVATE_ENDPOINT, and as uid 12021
# checkout could not `git init` the root-owned workspace volume anyway.
container:
image: ghcr.io/renovatebot/renovate:44.167.0
# Renovate scans every manager across the whole repo; 40m is generous for this repo's size and
# well under a runner's patience. Without a timeout a wedged run holds the `renovate`
# concurrency group and silently blocks every subsequent scheduled run.
timeout-minutes: 40
steps:
- name: Preflight - the token must exist, and its absence must be LOUD
shell: bash
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
run: |
set -euo pipefail
if [ -z "${RENOVATE_TOKEN:-}" ]; then
echo "::error::RENOVATE_TOKEN is not set on this repository." >&2
cat >&2 <<'MSG'
Renovate cannot run without a Gitea access token, and this step fails rather than
skipping ON PURPOSE: a skipped Gitea job reports `success`, so skipping here would make
"dependency automation is not running" look exactly like "everything is up to date".
That is the failure mode that let renovate.json sit inert in this repo for months while
seven files claimed "Renovate maintains this pin".
TO FIX: create a Gitea access token for the bot account (renovate-bot) with scopes
read:user, read:organization, write:repository, write:issue and read:package (the
header of .gitea/workflows/renovate.yaml says which call needs which), then add it to
this repository as the Actions secret RENOVATE_TOKEN. Do not commit it anywhere.
MSG
exit 1
fi
echo "RENOVATE_TOKEN is present (value not printed)."
# Proves the step is running INSIDE the pinned Renovate image, not the default job image.
echo "renovate $(renovate --version)"
- name: Preflight - the token has every scope Renovate needs
shell: bash
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
# ADDED 2026-10-30. Renovate reports EVERY platform-init failure as the bare
# "Authentication failure" (lib/modules/platform/gitea/index.ts initPlatform swallows the
# HTTP error), and a later scope gap surfaces as "Repository has unknown error". Two
# dispatches were spent learning one missing scope at a time. These probes are GETs only,
# and they name the missing scope. The token never reaches argv: curl reads it from stdin
# (`--config -`), so it is not in `ps` output or in the log.
run: |
set -uo pipefail
GITEA=https://gitea.home.keithjackson.dev
REPO=Home-Network/home-network-k3s-impl
fail=0
body=$(mktemp)
probe() { # <scope it proves> <path> <auth: bearer|basic>
local code
if [ "$3" = basic ]; then
code=$(printf 'user = "renovate:%s"\n' "$RENOVATE_TOKEN" \
| curl -sS --config - -o "$body" -w '%{http_code}' "$GITEA$2") || code=000
else
code=$(printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \
| curl -sS --config - -o "$body" -w '%{http_code}' "$GITEA$2") || code=000
fi
if [ "$code" = 200 ]; then
echo "ok $1 (GET ${2%%\?*} -> 200)"
return
fi
fail=1
echo "::error::scope $1: GET ${2%%\?*} -> $code"
case "$code" in
401) echo " 401 = Gitea rejected the token VALUE (wrong, expired or revoked)." ;;
403) if grep -q 'Access denied' "$body"; then
echo " 403 'Access denied' = a Cilium/Envoy policy denial, not Gitea."
else
echo " Gitea: $(jq -r '.message // empty' "$body" 2>/dev/null | head -c 300)"
fi ;;
*) echo " body: $(head -c 200 "$body")" ;;
esac
}
probe read:user "/api/v1/user" bearer
probe read:organization "/api/v1/orgs/${REPO%%/*}" bearer
probe read:repository "/api/v1/repos/$REPO" bearer
probe read:issue "/api/v1/repos/$REPO/labels?limit=1" bearer
probe read:package "/v2/token?service=container_registry&scope=repository:home-network/toolbox:pull" basic
# WRITE scopes cannot be proven by a GET. Gitea's scope-refusal message, however, lists
# the token's scopes (names only, never the value): ask for an admin route this bot
# cannot have, and read `token scope=` from the 403. Advisory if the format changes.
printf 'header = "Authorization: Bearer %s"\n' "$RENOVATE_TOKEN" \
| curl -sS --config - -o "$body" "$GITEA/api/v1/admin/orgs?limit=1" || true
scopes=$(jq -r '.message // empty' "$body" 2>/dev/null | sed -n 's/.*token scope=//p')
if [ -n "$scopes" ]; then
echo "token scopes (from Gitea): $scopes"
for want in write:repository write:issue; do
case ",$scopes," in
*",$want,"*|*",all,"*) echo "ok $want" ;;
*) echo "::error::scope $want is missing (token scope=$scopes)"; fail=1 ;;
esac
done
else
echo "::warning::could not read the token's scope list; write:repository and write:issue are UNVERIFIED"
fi
rm -f "$body"
if [ "$fail" -ne 0 ]; then
echo "::error::RENOVATE_TOKEN failed the checks above (a 401 is the value, a 403 is a scope). Re-mint it for renovate-bot with read:user, read:organization, write:repository, write:issue, read:package and overwrite the secret."
exit 1
fi
- name: Run Renovate
shell: bash
# The repo config is discovered by Renovate from the cloned repo (renovate.json5,
# RENOVATE_REQUIRE_CONFIG=required). The action used to ALSO load it as the global config
# via RENOVATE_CONFIG_FILE; that double-loading is gone. ci/lint.sh's `configurationFile:`
# check therefore has nothing to resolve in this file, and passes.
env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
# Self-hosted Gitea. `platform` + `endpoint` were the two values renovate.json's own
# header said were "set at runtime by the operator's renovate runner" — this is that
# runner, so they are set here rather than in the repo config, which keeps renovate.json
# portable and free of this instance's URL.
RENOVATE_PLATFORM: gitea
RENOVATE_ENDPOINT: https://gitea.home.keithjackson.dev/api/v1
RENOVATE_REPOSITORIES: Home-Network/home-network-k3s-impl
# Renovate must never push to a protected branch; it works on its own branches and opens
# PRs against this base. Stated explicitly rather than inherited, because this is the
# property that keeps the whole mechanism inside CLAUDE.md §1's branch -> PR -> gate path.
RENOVATE_BASE_BRANCHES: main
RENOVATE_ONBOARDING: "false"
RENOVATE_REQUIRE_CONFIG: "required"
RENOVATE_DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == 'true' && 'full' || '' }}
LOG_LEVEL: ${{ github.event_name == 'workflow_dispatch' && inputs.log_level || 'info' }}
# ADDED 2026-10-29: registry read access for this repo's OWN images (toolbox,
# agent-runner, conformance-runner — published by .gitea/workflows/images.yaml and
# followed through `# renovate-digest:` markers). The Gitea registry answers 401 to
# anonymous reads, and Renovate scopes the platform token to hostType `gitea`, which a
# docker lookup never matches — so without this rule every digest lookup fails. Same
# token; it therefore needs scope `read:package` in addition to the ones listed in the
# preflight above. Gitea ignores the username for token auth (services/auth/basic.go).
RENOVATE_HOST_RULES: '[{"hostType":"docker","matchHost":"gitea.home.keithjackson.dev","username":"renovate","password":"${{ secrets.RENOVATE_TOKEN }}"}]'
# OPTIONAL, ADDED 2026-10-30. A github.com token (a fine-grained PAT with NO permissions,
# public-repo read only) for lookups of GitHub-hosted dependencies. Without it Renovate
# SKIPS them with `github-token-required` — measured in a local dry run: 17 deps, i.e.
# every `uses: actions/*` here plus kubevirt/kubevirt, kubevirt/containerized-data-importer
# and intel/intel-device-plugins-for-kubernetes. Unset is safe: the step below drops the
# empty variable, and those deps simply stay unmaintained, with a WARN in the log.
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
run: |
set -euo pipefail
# An expression that evaluates to '' still EXPORTS the variable, as an empty string.
# Drop those so an unset value means "Renovate's default", never "set to empty".
for v in RENOVATE_DRY_RUN GITHUB_COM_TOKEN; do
[ -n "${!v:-}" ] || unset "$v"
done
# Through the image's entrypoint, as `docker run` would have gone: it sources the
# containerbase env and initialises the bundled tools before exec'ing Renovate.
exec renovate-entrypoint.sh renovate
- name: Summary
if: always()
run: |
{
echo "## Renovate"
echo
echo "- trigger: \`${{ github.event_name }}\`"
echo "- dry run: \`${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}\`"
echo "- outcome: \`${{ job.status }}\`"
echo
echo "Renovate owns BOTH Helm chart bumps and container image tags here; Flux"
echo "image-automation is deliberately disabled (platform/kustomization.yaml)."
echo "Every change it proposes arrives as a pull request and must pass the merge gate."
} >> "$GITHUB_STEP_SUMMARY"
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env bash
###############################################################################
# .gitea/workflows/scripts/images.sh — the image pipeline's driver (images.yaml calls this).
#
# Builds, verifies and (on main) PUBLISHES the three images this repo ships:
#
# toolbox infra/devcontainer/ the pinned dev/gate toolchain
# agent-runner infra/agent/ FROM toolbox; the PM dispatcher + worker
# conformance-runner conformance/incluster/ the in-cluster L2/L3 Chainsaw runner
#
# THE BUILDER (operator decision on PR #81): a ROOTLESS BuildKit daemon, platform/buildkit/,
# reached over mTLS. No CI job may reach a privileged daemon, so there is no `docker build` and no
# `docker run` here: this script drives buildkitd with a pinned `buildx` (remote driver) and
# nothing else. The client certificate arrives as Actions secrets (BUILDKIT_CA,
# BUILDKIT_CLIENT_CERT, BUILDKIT_CLIENT_KEY; infra/gitea/buildkit-client-secret.sh puts them
# there). If the daemon is unreachable this FAILS LOUDLY — it never skips.
#
# VERIFICATION IS A BUILD TARGET. Each Containerfile ends in a `verify` stage that runs every
# in-image assertion at build time (as the non-root uid, with RUN --network=none); this script
# builds `<image>-verify` and only then the image. Build args come from each image's own
# `build-image.sh --print-build-args`, the same list the local podman path uses.
#
# MODES (env MODE):
# verify build the verify targets, never push, hold no registry credential. Pull requests.
# publish verify, then build + push to ${REGISTRY}/${NAMESPACE}/<image> with tags
# `sha-<12 hex>` (immutable: never re-pushed) and `main` (moving; Renovate follows
# it). The digest recorded is the one the REGISTRY returns, read back after the push.
#
# WHAT GETS BUILT (env IMAGES): `auto` (default) diffs BASE_SHA..HEAD_SHA against each image's
# inputs; otherwise a space-separated list. A change to the pipeline itself (images.yaml, this
# file, platform/buildkit/versions.env) builds everything in verify mode, and nothing extra in
# publish mode: re-publishing unchanged inputs would only mint new digests and churn Renovate PRs.
#
# THE agent-runner BASE:
# * verify mode -> FROM the toolbox built in this run, linked with a bake `target:` context —
# a pull request holds no registry credential and that toolbox exists in no registry. If the
# toolbox's inputs did not change it is built anyway, as the base.
# * publish mode, toolbox published in this run -> FROM the digest the registry just returned.
# * publish mode otherwise -> AGENT_BASE_IMAGE from infra/agent/versions.env (the published
# toolbox digest Renovate keeps current). If that is still the all-zero placeholder the
# toolbox has never been published, so it is built and published first (the bootstrap).
#
# LOOP-FREEDOM. Renovate turns each publish into a digest-bump PR. Those bumps land in
# platform/pm-agent/ (no image input), conformance/versions.env (no image input) and
# infra/agent/versions.env (agent-runner input -> ONE more agent-runner build, whose own bump
# lands in platform/pm-agent/ and stops). The worker Job template is deliberately NOT tracked:
# it is baked INTO agent-runner, so bumping it would rebuild agent-runner, mint a new digest,
# bump it again, forever. pm-dispatch overwrites that field at runtime anyway.
###############################################################################
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "${REPO_ROOT}"
# shellcheck disable=SC1091
BK_HOST_OVERRIDE="${BUILDKIT_HOST:-}"; BK_NAME_OVERRIDE="${BUILDKIT_SERVERNAME:-}"
. ./platform/buildkit/versions.env
BUILDKIT_HOST="${BK_HOST_OVERRIDE:-${BUILDKIT_HOST}}"
BUILDKIT_SERVERNAME="${BK_NAME_OVERRIDE:-${BUILDKIT_SERVERNAME}}"
MODE="${MODE:-verify}"
IMAGES="${IMAGES:-auto}"
REGISTRY="${REGISTRY:-gitea.home.keithjackson.dev}"
NAMESPACE="${NAMESPACE:-home-network}"
HEAD_SHA="${HEAD_SHA:-$(git rev-parse HEAD)}"
BASE_SHA="${BASE_SHA:-}"
SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}"
ZERO_DIGEST="sha256:0000000000000000000000000000000000000000000000000000000000000000"
ALL_IMAGES=(toolbox agent-runner conformance-runner)
SHA_TAG="sha-${HEAD_SHA:0:12}"
MOVING_TAG="main"
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; printf -- '- **ERROR**: %s\n' "$*" >>"${SUMMARY}"; exit 1; }
yn() { if [ -n "$1" ]; then echo "$2"; else echo "$3"; fi; }
case "${MODE}" in verify|publish) ;; *) die "MODE must be verify or publish, got '${MODE}'" ;; esac
for t in git jq curl sha256sum; do command -v "$t" >/dev/null || die "the job image lacks '$t'"; done
# --- per-image facts ------------------------------------------------------------------------
# Inputs: a change under any of these rebuilds that image. conformance-runner takes its pins from
# infra/devcontainer/versions.env. conformance/versions.env is NOT an input (it only RECORDS the
# published digest), and must not become one: Renovate writes it after every publish -> a loop.
inputs() {
case "$1" in
toolbox) echo "infra/devcontainer/Containerfile infra/devcontainer/versions.env infra/devcontainer/build-image.sh" ;;
agent-runner) echo "infra/agent/" ;;
conformance-runner) echo "conformance/incluster/Containerfile conformance/incluster/conformance-clone conformance/incluster/build-image.sh infra/devcontainer/versions.env" ;;
esac
}
context_of() { case "$1" in toolbox) echo infra/devcontainer ;; agent-runner) echo infra/agent ;; conformance-runner) echo conformance/incluster ;; esac; }
PIPELINE_FILES=".gitea/workflows/images.yaml .gitea/workflows/scripts/images.sh platform/buildkit/versions.env"
declare -A WANT=() BUILT=() DIGEST=() SECS=() NOTE=()
changed_any() { # <paths...> -> 0 if BASE_SHA..HEAD_SHA touches any
[ -n "$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" -- "$@")" ]
}
select_images() {
local i
if [ "${IMAGES}" != "auto" ]; then
for i in ${IMAGES}; do
case " ${ALL_IMAGES[*]} " in *" ${i} "*) WANT[$i]=1; NOTE[$i]="requested" ;; *) die "unknown image '${i}'" ;; esac
done
return
fi
if [ -z "${BASE_SHA}" ] || [[ "${BASE_SHA}" =~ ^0+$ ]] || ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
info "no usable diff base ('${BASE_SHA}'): building everything"
for i in "${ALL_IMAGES[@]}"; do WANT[$i]=1; NOTE[$i]="no diff base"; done
return
fi
info "diff base ${BASE_SHA} .. ${HEAD_SHA}"
for i in "${ALL_IMAGES[@]}"; do
# shellcheck disable=SC2046
if changed_any $(inputs "$i"); then WANT[$i]=1; NOTE[$i]="inputs changed"; fi
done
# shellcheck disable=SC2086
if [ "${MODE}" = verify ] && changed_any ${PIPELINE_FILES}; then
for i in "${ALL_IMAGES[@]}"; do [ -n "${WANT[$i]:-}" ] || { WANT[$i]=1; NOTE[$i]="pipeline changed"; }; done
fi
}
# --- the builder: pinned buildx + mTLS to rootless buildkitd ---------------------------------
WORK="$(mktemp -d)"
trap 'rm -rf "${WORK}"' EXIT # client key + registry auth live only here, only this long
export BUILDX_CONFIG="${WORK}/buildx" DOCKER_CONFIG="${WORK}/docker"
# Single-manifest images: the admission policies, Renovate and registry-retention.sh all reason
# about one manifest per tag. (Provenance attestations would turn every push into an index.)
export BUILDX_NO_DEFAULT_ATTESTATIONS=1
mkdir -p "${BUILDX_CONFIG}" "${DOCKER_CONFIG}" "${WORK}/certs"
echo '{}' > "${DOCKER_CONFIG}/config.json"
setup_builder() {
log "Builder: rootless buildkitd at ${BUILDKIT_HOST} (mTLS)"
BUILDX="${BUILDX_BIN:-}"
if [ -z "${BUILDX}" ]; then
BUILDX="${WORK}/buildx-bin"
curl -fsSL --retry 3 -o "${BUILDX}" \
"https://github.com/docker/buildx/releases/download/v${BUILDX_VERSION}/buildx-v${BUILDX_VERSION}.linux-amd64" \
|| die "could not download buildx v${BUILDX_VERSION}"
echo "${BUILDX_SHA256} ${BUILDX}" | sha256sum -c --quiet - \
|| die "buildx v${BUILDX_VERSION} does not match the sha256 pinned in platform/buildkit/versions.env — refusing to run it"
chmod +x "${BUILDX}"
fi
info "client: $("${BUILDX}" version)"
local missing=()
[ -n "${BUILDKIT_CA:-}" ] || missing+=(BUILDKIT_CA)
[ -n "${BUILDKIT_CLIENT_CERT:-}" ] || missing+=(BUILDKIT_CLIENT_CERT)
[ -n "${BUILDKIT_CLIENT_KEY:-}" ] || missing+=(BUILDKIT_CLIENT_KEY)
[ "${#missing[@]}" -eq 0 ] || die "the builder's client certificate is missing: ${missing[*]} not set.
These Actions secrets are copied from the cert-manager Secret buildkit/buildkit-client-ci by
infra/gitea/buildkit-client-secret.sh (an operator action, after platform/buildkit is
deployed). Without them nothing can be built — this fails rather than skipping ON PURPOSE."
umask 077
printf '%s\n' "${BUILDKIT_CA}" > "${WORK}/certs/ca.pem"
printf '%s\n' "${BUILDKIT_CLIENT_CERT}" > "${WORK}/certs/cert.pem"
printf '%s\n' "${BUILDKIT_CLIENT_KEY}" > "${WORK}/certs/key.pem"
umask 022
"${BUILDX}" create --name ci-remote --driver remote \
--driver-opt "cacert=${WORK}/certs/ca.pem,cert=${WORK}/certs/cert.pem,key=${WORK}/certs/key.pem,servername=${BUILDKIT_SERVERNAME}" \
"${BUILDKIT_HOST}" >/dev/null
if ! out="$(timeout 90 "${BUILDX}" inspect --bootstrap --builder ci-remote 2>&1)"; then
printf '%s\n' "${out}" >&2
die "buildkitd is UNREACHABLE at ${BUILDKIT_HOST} — nothing can be built, and this job fails
rather than skipping. In order of likelihood:
1. platform/buildkit is not deployed yet. Flux applies it only after it merges to main
(Kustomization 'buildkit'); a pull request that ADDS it cannot reach it.
2. the BUILDKIT_* secrets are stale (the client cert was renewed) or from another CA:
re-run infra/gitea/buildkit-client-secret.sh.
3. the NetworkPolicy (platform/buildkit/networkpolicy.yaml) does not admit this runner."
fi
printf '%s\n' "${out}" | grep -E '^(Name|Driver|Status|BuildKit version|Platforms):' | sed 's/^/ /' || true
}
# KEY=VALUE lines from an image's build-image.sh --print-build-args -> a JSON object.
build_args_json() {
local ctx; ctx="$(context_of "$1")"
"./${ctx}/build-image.sh" --print-build-args | jq -Rn '[inputs | select(length>0) | capture("^(?<k>[^=]+)=(?<v>.*)$")] | map({(.k): .v}) | add'
}
# bake_run <bakefile> <targets...>
bake_run() {
local f="$1"; shift
"${BUILDX}" bake --builder ci-remote --progress plain -f "$f" \
--metadata-file "${WORK}/meta.json" "$@"
}
# Write a bake file with a verify target and a ship target for one image.
# $1 image, $2 agent base mode: "" | "link" | <image ref>
write_bake() {
local img="$1" base="${2:-}" ctx args f
ctx="$(context_of "$img")"
args="$(build_args_json "$img")"
f="${WORK}/bake-${img}.json"
local contexts='{}'
if [ "${img}" = agent-runner ]; then
if [ "${base}" = link ]; then
# FROM ${AGENT_BASE_IMAGE} resolves to the named context below, i.e. the toolbox target
# of this same bake file — built here, never pushed anywhere.
args="$(jq -c '. + {AGENT_BASE_IMAGE: "toolbox-in-this-run"}' <<<"${args}")"
contexts='{"toolbox-in-this-run": "target:toolbox"}'
else
args="$(jq -c --arg b "${base}" '. + {AGENT_BASE_IMAGE: $b}' <<<"${args}")"
fi
fi
jq -n --arg img "$img" --arg ctx "$ctx" --argjson args "$args" --argjson contexts "$contexts" \
--arg tbargs "$( [ "${base}" = link ] && build_args_json toolbox || echo '{}')" \
--arg rev "${HEAD_SHA}" --arg repo "${REGISTRY}/${NAMESPACE}/${img}" \
--arg sha "${SHA_TAG}" --arg moving "${MOVING_TAG}" --arg mode "${MODE}" '
{target: (
{
($img + "-verify"): {context: $ctx, dockerfile: "Containerfile", target: "verify",
args: $args, contexts: $contexts, output: ["type=cacheonly"]},
($img): ({context: $ctx, dockerfile: "Containerfile", target: $img, args: $args,
contexts: $contexts,
labels: {"org.opencontainers.image.revision": $rev}}
+ (if $mode == "publish"
then {tags: [($repo + ":" + $sha), ($repo + ":" + $moving)], output: ["type=image,push=true"]}
else {output: ["type=cacheonly"]} end))
}
+ (if ($contexts | has("toolbox-in-this-run"))
then {toolbox: {context: "infra/devcontainer", dockerfile: "Containerfile", target: "toolbox",
args: ($tbargs | fromjson), output: ["type=cacheonly"]}}
else {} end)
)}' > "$f"
echo "$f"
}
# --- registry helpers (publish mode only) ----------------------------------------------------
REG_TOKEN=""
registry_token() {
[ -n "${REG_TOKEN}" ] && return 0
REG_TOKEN="$(curl -fsS --config <(printf 'user = "%s:%s"\n' "${REGISTRY_USER}" "${REGISTRY_TOKEN}") \
"https://${REGISTRY}/v2/token?service=container_registry&scope=*" | jq -r '.token // empty')" \
|| die "could not obtain a registry bearer token from https://${REGISTRY}/v2/token"
[ -n "${REG_TOKEN}" ] || die "registry returned no bearer token"
}
# Prints the manifest digest the REGISTRY serves for <image>:<tag>; nothing if it is absent (404).
# Any other answer is fatal: "cannot tell" must never be read as "not published", or the
# immutable-tag guard would be bypassed by an auth hiccup.
registry_digest() {
registry_token
local hdrs code digest
hdrs="$(curl -sS -I --config <(printf 'header = "Authorization: Bearer %s"\n' "${REG_TOKEN}") \
-H 'Accept: application/vnd.oci.image.manifest.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
"https://${REGISTRY}/v2/${NAMESPACE}/$1/manifests/$2" | tr -d '\r')" \
|| die "registry unreachable while reading $1:$2"
code="$(awk 'NR==1{print $2}' <<<"${hdrs}")"
digest="$(awk -F': ' 'tolower($1)=="docker-content-digest"{print $2}' <<<"${hdrs}" | tail -1)"
case "${code}" in
200) [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]] || die "registry answered 200 for $1:$2 with no usable digest"
echo "${digest}" ;;
404) ;;
*) die "registry answered HTTP ${code} for $1:$2 — cannot tell whether it exists" ;;
esac
}
# build_one <image> [agent base] — verify, then (publish) build + push + read back.
build_one() {
local img="$1" base="${2:-}" t0 f pushed got existing
t0=$(date +%s)
f="$(write_bake "$img" "$base")"
log "VERIFY ${img} (Containerfile target: verify, on rootless BuildKit)"
bake_run "$f" "${img}-verify"
BUILT[$img]=1
if [ "${MODE}" = publish ]; then
existing="$(registry_digest "${img}" "${SHA_TAG}")"
# Immutable tag: never overwrite. A rebuild is not byte-reproducible, so re-pushing would
# silently change what `sha-<commit>` means after something may already have pinned it.
[ -z "${existing}" ] || die "${REGISTRY}/${NAMESPACE}/${img}:${SHA_TAG} already exists (${existing}); tags are immutable"
log "BUILD + PUSH ${img} -> ${REGISTRY}/${NAMESPACE}/${img}:{${SHA_TAG},${MOVING_TAG}}"
bake_run "$f" "${img}"
pushed="$(jq -r --arg t "$img" '.[$t]["containerimage.digest"] // empty' "${WORK}/meta.json")"
# The AUTHORITATIVE digest is what the registry serves, read back — not what the client
# reported. They should agree; if they do not, nothing downstream may trust either.
got="$(registry_digest "${img}" "${SHA_TAG}")"
[ -n "${got}" ] || die "pushed ${img}:${SHA_TAG} but the registry does not serve it"
[ -z "${pushed}" ] || [ "${pushed}" = "${got}" ] || die "${img}: client reported ${pushed} but the registry serves ${got}"
[ "$(registry_digest "${img}" "${MOVING_TAG}")" = "${got}" ] \
|| die "${img}: ${MOVING_TAG} does not point at the digest just pushed (${got})"
DIGEST[$img]="${got}"
info "${img}: registry digest ${got}"
fi
SECS[$img]=$(( $(date +%s) - t0 ))
info "${img}: done in ${SECS[$img]}s"
}
# --- main ---------------------------------------------------------------------------------
log "image pipeline: MODE=${MODE} IMAGES=${IMAGES} HEAD=${HEAD_SHA}"
select_images
if [ "${MODE}" = publish ]; then
: "${REGISTRY_TOKEN:?REGISTRY_TOKEN is required in publish mode}"
REGISTRY_USER="${REGISTRY_USER:-ci-image-publisher}"
# buildx (and through it buildkitd, which does the push) reads registry auth from this file.
jq -n --arg r "${REGISTRY}" --arg a "$(printf '%s:%s' "${REGISTRY_USER}" "${REGISTRY_TOKEN}" | base64 -w0)" \
'{auths: {($r): {auth: $a}}}' > "${DOCKER_CONFIG}/config.json"
chmod 600 "${DOCKER_CONFIG}/config.json"
registry_token
# Publish idempotence: an image already published at THIS commit is not rebuilt. That is what
# makes a re-run after a half-finished publish safe — the immutable tag is never pushed twice.
for i in "${ALL_IMAGES[@]}"; do
[ -n "${WANT[$i]:-}" ] || continue
d="$(registry_digest "${i}" "${SHA_TAG}")"
if [ -n "${d}" ]; then
info "${i}: already published at ${SHA_TAG} (${d}); not rebuilding"
unset "WANT[$i]"; DIGEST[$i]="${d}"; NOTE[$i]="already published at this commit"
fi
done
fi
# Decide the agent-runner base (see header).
AGENT_BASE=""
if [ -n "${WANT[agent-runner]:-}" ]; then
PINNED_BASE="$(. ./infra/agent/versions.env; echo "${AGENT_BASE_IMAGE}")"
if [ "${MODE}" = verify ]; then
AGENT_BASE=link
[ -n "${WANT[toolbox]:-}" ] || { WANT[toolbox]=1; NOTE[toolbox]="built as agent-runner's base (verify only)"; }
elif [ -z "${WANT[toolbox]:-}" ] && [ -z "${DIGEST[toolbox]:-}" ]; then
case "${PINNED_BASE}" in
*@"${ZERO_DIGEST}") WANT[toolbox]=1; NOTE[toolbox]="never published; required as agent-runner's base" ;;
*@sha256:*) AGENT_BASE="${PINNED_BASE}" ;;
*) die "infra/agent/versions.env AGENT_BASE_IMAGE='${PINNED_BASE}' is not digest-pinned; CI will not build on a floating base" ;;
esac
fi
fi
for i in "${ALL_IMAGES[@]}"; do info "plan: ${i}: $(yn "${WANT[$i]:-}" BUILD skip) (${NOTE[$i]:-inputs unchanged})"; done
if [ -z "${WANT[toolbox]:-}${WANT[agent-runner]:-}${WANT[conformance-runner]:-}" ]; then
info "nothing to build"
else
setup_builder
fi
if [ -n "${WANT[toolbox]:-}" ]; then build_one toolbox; fi
if [ -n "${WANT[agent-runner]:-}" ]; then
if [ -z "${AGENT_BASE}" ]; then
AGENT_BASE="${REGISTRY}/${NAMESPACE}/toolbox@${DIGEST[toolbox]:?no toolbox digest to build agent-runner from}"
fi
info "agent-runner base: $( [ "${AGENT_BASE}" = link ] && echo "the toolbox built in this run (bake target: context)" || echo "${AGENT_BASE}")"
build_one agent-runner "${AGENT_BASE}"
fi
if [ -n "${WANT[conformance-runner]:-}" ]; then build_one conformance-runner; fi
# --- summary ------------------------------------------------------------------------------
{
echo "## Images (${MODE})"
echo
echo "commit \`${HEAD_SHA}\` — builder: rootless BuildKit \`${BUILDKIT_HOST}\` (mTLS)"
echo
echo "| image | verify target built | seconds | published digest (registry-returned) | note |"
echo "|---|---|---|---|---|"
for i in "${ALL_IMAGES[@]}"; do
d="${DIGEST[$i]:-}"
echo "| \`${i}\` | $(yn "${BUILT[$i]:-}" yes no) | ${SECS[$i]:--} | $(yn "${d}" "\`${REGISTRY}/${NAMESPACE}/${i}@${d}\`" -) | ${NOTE[$i]:-inputs unchanged} |"
done
echo
if [ "${MODE}" = publish ]; then
echo "Tags pushed: \`${SHA_TAG}\` (immutable) and \`${MOVING_TAG}\` (moving)."
echo "Nothing here writes to git. Renovate opens the digest-bump PR on its next run."
else
echo "Verify mode: nothing was pushed and no registry credential was present."
fi
} >>"${SUMMARY}"
log "done"
+8
View File
@@ -37,6 +37,14 @@ coverage/
infra/tofu/backend.hcl
infra/tofu/.r2.env
# Operator scratch, writable from inside the dev container (the repo is its only persistent
# read-write mount). Holds things that must never be committed: a staged copy of the SOPS age or
# WireGuard key, rendered VPN client profiles (infra/vpn-ingress/new-peer.sh writes here by
# default). `infra/ansible/.gitignore` already does this for its own .cache/; this is the same
# convention at the root. Do NOT rely on `*.key` alone to protect a staged key — that only covers
# one filename shape, which is why this rule exists.
.cache/
# Claude Code local scratch: agent worktrees + session state. Nothing here is tracked;
# ignoring it keeps `git add -A` from staging embedded worktree repos and keeps the lint
# stage (which scans untracked files) from tripping on .claude/worktrees/.
+1 -1
View File
@@ -73,7 +73,7 @@ spec:
- name: FTLCONF_dns_rateLimit_count
value: '0'
- name: FTLCONF_misc_dnsmasq_lines
value: address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}
value: local-ttl=60;address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}
- name: FTLCONF_webserver_api_password
valueFrom:
secretKeyRef:
+1 -1
View File
@@ -77,7 +77,7 @@ spec:
storageClassName: longhorn
resources:
requests:
storage: 8Gi
storage: 16Gi
---
# PVC plex-media (R-STATE)
apiVersion: v1
+32 -1
View File
@@ -143,8 +143,39 @@ env:
# resolved to the Gateway VIP and a connect to :32400 there returned http=000. That is a silent
# failure: the Plex web UI on plex.${BASE_DOMAIN} keeps working through the Gateway on 443 while
# every client that takes the ADVERTISED connection cannot reach the server at all.
#
# ── local-ttl=60 — THE SPLIT-HORIZON ANSWER MUST BE CACHEABLE ────────────────────────────────
# MEASURED 2026-10-18, diagnosing "DNS flapping between the cluster ingress and the public IP":
#
# dig home.keithjackson.dev @192.168.2.23 (pihole) -> 192.168.2.20 TTL=0
# dig home.keithjackson.dev @1.1.1.1 (public) -> wan.home.keithjackson.dev TTL=300
# dig home.keithjackson.dev @192.168.2.1 (router) -> wan.... TTL=153 -> 148 -> 143
#
# dnsmasq answers `address=` entries with **TTL 0** unless told otherwise, and that is the whole
# bug. The SOHO router forwards LAN queries to PiHole and CACHES the result, so:
# * the CORRECT answer is UNCACHEABLE (TTL 0) -- the router must re-query PiHole on every
# single lookup, which is maximum exposure to any momentary gap; while
# * the WRONG answer is CACHEABLE FOR 5 MINUTES -- the instant the router falls back to its
# upstream (standard forwarder behaviour when the forward target does not answer) it latches
# the public WAN address and serves it to EVERY LAN CLIENT for the public record's 300s TTL.
# The decrementing TTL on the router's reply above is the proof it caches rather than forwarding
# live. So ONE sub-second PiHole gap -- a pod restart, an L2 announcement lease moving, an ARP
# flap, a kured node reboot -- becomes FIVE MINUTES of the whole house resolving to the WAN IP.
# That asymmetry, not the gap, is why this is visible as "flapping" rather than a blip.
#
# 60s is a deliberate floor, not a tuning knob: long enough to ride out a pod restart and a lease
# re-election (both seconds), short enough that a genuine VIP change still propagates inside a
# minute. These VIPs are pinned by name (`lbipam.cilium.io/ips`) and effectively static, so the
# propagation cost is theoretical while the flap it prevents is measured. If flaps persist across
# LONGER outages, raising this toward the public record's 300 would cover them -- at the cost of
# the same delay on a real change. It does NOT help when PiHole is down longer than the TTL: the
# cache expires, the router falls back, and the public answer latches again. That is a real
# outage, and the fix for it is PiHole availability, not DNS caching.
#
# Applies to every `address=` reply below (dnsmasq: local-ttl governs replies made from
# configuration, /etc/hosts and DHCP leases), so one directive covers all five names.
- name: FTLCONF_misc_dnsmasq_lines
value: "address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}"
value: "local-ttl=60;address=/${BASE_DOMAIN}/${GATEWAY_VIP};address=/unifi.${BASE_DOMAIN}/${UNIFI_L4_VIP};address=/crafty-mc.${BASE_DOMAIN}/${CRAFTY_L4_VIP};address=/pihole-dns.${BASE_DOMAIN}/${PIHOLE_DNS_VIP};address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}"
# THE ADMIN PASSWORD, previously the ONE operator-set value declared nowhere. Sourced from
# apps/wave-0/pihole-bootstrap-secret.sops.yaml (SOPS/age, same wave => same reconcile), which
# carries the full rationale and the measurement that found it.
+1 -1
View File
@@ -351,7 +351,7 @@ exposure: public # edge port-forward + public DNS; clients + Seerr
egress: internet # normal WAN egress (docs/03; R-NET-05)
storage:
- name: config
size: 8Gi
size: 16Gi
class: longhorn # Plex DB/metadata: replicated app state on the 3-replica tier (docs/04; R-STATE-01/02)
mountPath: /config # plexinc/pms-docker writes its identity/claim + "Library" DB under /config, NOT the
# renderer default /data/config. Without this the PVC sat unused and Plex wrote to the
+2 -2
View File
@@ -359,7 +359,7 @@ storage:
# The live /config contents were copied onto each PVC before this landed,
# so API keys and credentials other apps reference were preserved.
# THE DOWNLOADS AREA — ONE mount of the SAME export sonarr/radarr/plex already consume
# (192.168.2.102:/mnt/Storage/plex), narrowed by subPath to the `downloads` subtree and presented at
# (192.168.2.30:/mnt/Storage/plex), narrowed by subPath to the `downloads` subtree and presented at
# /downloads, which is precisely where this app is ALREADY configured to write.
#
# WHY subPath: downloads AND NOT THE EXPORT ROOT AT /media (the shape sonarr/radarr use):
@@ -434,7 +434,7 @@ dependencies: [] # default-deny: reaches only DNS; arr reach it n
# for weeks and neither has any CNP naming the NFS server or port 2049
# (grep apps/wave-1/sonarr.yaml: none). The SAME fact is what keeps the VPN
# egress gateway off this traffic (its selector is a podSelector), and
# 192.168.2.102 is inside the 192.168.0.0/16 excludedCIDR besides — see
# 192.168.2.30 is inside the 192.168.0.0/16 excludedCIDR besides — see
# platform/vpn-egress/egress-steering.yaml, "BULK NFS".
ingressRoutes:
- host: qbittorrent.${BASE_DOMAIN}
+31
View File
@@ -27,3 +27,34 @@ fi
yamllint -c "${CI_DIR}/yamllint.yaml" -f "${YAMLLINT_FORMAT:-standard}" "${files[@]}"
ok "yaml/lint passed (${#files[@]} files)"
# A workflow's `configurationFile:` must name a file that exists. Renovate's did not after #79
# renamed renovate.json -> renovate.json5, and every scheduled run failed with ENOENT for 11 days
# while the gate stayed green. Valid YAML, so yamllint cannot see it.
missing=0
while IFS=: read -r wf _ value; do
value="${value#*configurationFile:}"
value="$(echo "$value" | sed -E 's/#.*//; s/^[[:space:]"'\'']+//; s/[[:space:]"'\'']+$//')"
if [ -n "$value" ] && [ ! -e "$value" ]; then
err "${wf}: configurationFile '${value}' does not exist in the repo"
missing=1
fi
done < <(grep -nE '^[[:space:]]*configurationFile:' .gitea/workflows/*.y*ml 2>/dev/null || true)
[ "$missing" -eq 0 ] || exit 1
ok "workflow configurationFile references resolve"
# A workflow must not use an action that needs a Docker daemon: jobs on this runner get NONE, by
# decision (decisions/2026-10-29-image-pipeline.md; act_runner maps docker_host to `-`). Such an
# action is valid YAML and passes every other stage, then dies at runtime with
# "failed to connect to the docker API at unix:///var/run/docker.sock". renovatebot/github-action
# did exactly that on every Renovate run until 2026-10-30 (run 343). Run the tool's image as the
# job `container:` instead (see .gitea/workflows/renovate.yaml). Container (`docker://`) actions
# and Docker's own daemon-bound actions are rejected for the same reason. Comments are ignored.
daemon_uses='uses:[[:space:]]*["'\'']?(docker://|renovatebot/github-action|docker/(build-push|setup-buildx|setup-qemu|login)-action)'
bad=0
while IFS= read -r hit; do
err "${hit%%:*}: needs a Docker daemon, which jobs here do not get: $(echo "${hit#*:*:}" | sed -E 's/^[[:space:]]+//')"
bad=1
done < <(grep -HnE "^[^#]*${daemon_uses}" .gitea/workflows/*.y*ml 2>/dev/null || true)
[ "$bad" -eq 0 ] || exit 1
ok "no workflow uses a Docker-daemon-bound action"
+490
View File
@@ -0,0 +1,490 @@
#!/usr/bin/env python3
# =============================================================================
# ci/render-keycloak-passkey-policy.py
#
# Render platform/keycloak/passkey-desired-state.yaml FROM
# platform/keycloak/keycloak-realm-import.yaml.
#
# WHY THIS EXISTS
# ---------------
# The Keycloak realm import is a ONE-SHOT that fails OPEN: once it rests at
# Done=True / Started=False it never re-runs. Measured on prod 2026-10-18, hours
# after the passkey declaration merged:
#
# kubectl -n keycloak get keycloakrealmimport homelab -o jsonpath=...status
# Done=True Started=False HasErrors=False
#
# So the WebAuthn passwordless policy and the restructured `browser-mfa` flow that
# keycloak-realm-import.yaml declares are INERT. They are in git, Flux applied
# them, and they reach the realm never. platform/keycloak/passkey-reconciler.yaml
# is the level-triggered loop that closes that; this renderer supplies the DESIRED
# STATE it converges towards.
#
# THE ONE VALUE THAT IS NOT RENDERED: `bind-browser-flow`, git's switch for whether the
# reconciler may BIND its verified candidate browser flow (one atomic PUT of the
# realm's browserFlow). It is an operator DECISION -- "I have tested the candidate
# flow in a browser; make it the login path" -- not a fact derivable from the realm
# import, so it cannot be generated. It is instead CARRIED THROUGH from the committed
# file: --write preserves it, --check accepts any value of it that is exactly the
# string "true" or "false" and rejects everything else (an unquoted YAML true is a
# bool, which a ConfigMap rejects at apply time -- failing the whole platform-config
# Kustomization -- so it is refused HERE, at merge time). An absent file or key
# renders as "false". Going live is therefore a one-line PR flipping that line.
#
# THE CONSTRAINT THAT SHAPES IT: the reconciler pod runs with
# automountServiceAccountToken: false, exactly like its client-secret sibling. It
# has NO Kubernetes API access at all, by design (R-SEC-02) — so it cannot read the
# KeycloakRealmImport CR to learn what the realm is supposed to look like. Its input
# must be MOUNTED. Hence a ConfigMap.
#
# THAT IS ALSO WHY THIS IS A RENDERER AND NOT A HAND-WRITTEN ConfigMap. A
# hand-authored copy of the policy would be a SECOND SOURCE OF TRUTH for the RP ID,
# the user-verification requirement and the whole flow tree — free to disagree with
# the realm import. "Two declarations that can disagree" is the same shape as the
# bug this whole effort is closing. So the ConfigMap is GENERATED from the realm
# import and the generation is GATED:
# conformance/tests/l0-static/keycloak-passkey-reconciler/check.sh runs this script
# with --check on every merge, and the merge fails if the committed file is not what
# this renderer produces. Same render==golden idiom as
# ci/render-keycloak-client-urls.py, catalog/render.py and ci/render-golden.sh.
#
# STRINGS ARE COPIED VERBATIM, NEVER RE-DERIVED. webAuthnPolicyPasswordlessRpId is
# written in the realm import as the Flux substitution token ${BASE_DOMAIN}, and it
# is carried through into the ConfigMap CHARACTER FOR CHARACTER. Both files are
# rendered by the SAME Flux Kustomization (./platform-config, whose
# postBuild.substituteFrom reads cluster-vars), so both are substituted in the same
# pass with the same value and cannot disagree about what the base domain is. This
# renderer never parses, splits or reconstructs a hostname and never reads
# cluster-vars — there is no second place the domain is decided.
#
# THE ONE THING THIS RENDERER DERIVES RATHER THAN COPIES
# -----------------------------------------------------
# The enrolment required action, `webauthn-register-passwordless`. It is
# deliberately NOT declared in the realm import, and the import's own header
# explains at length why declaring it there would be actively DANGEROUS: Keycloak
# 26.0.6 treats `requiredActions` in a realm representation as REPLACE, not merge,
# so declaring one action imports exactly that action plus delete_account and skips
# DefaultRequiredActions.addActions entirely — deleting CONFIGURE_TOTP,
# UPDATE_PASSWORD, VERIFY_EMAIL and seven more. Losing CONFIGURE_TOTP is a hard
# lock-out of every platform-admin, because auth-otp-form in the browser flow still
# demands a TOTP they could then never enrol.
#
# So the required action cannot be copied from the import, because it is correctly
# absent from it. It is instead DERIVED, as a consequence of what the import DOES
# declare:
#
# IF the declared browser flow contains a `webauthn-authenticator-passwordless`
# execution, THEN passkey logins are offered, THEREFORE the enrolment action must
# be registered and enabled — and NOT a defaultAction, because the import's whole
# stated posture is "additive and optional" and a defaultAction forces enrolment
# on every user at next login.
#
# This is a derivation, not a second declaration: there is no value here that a
# human could edit into disagreement with the import. If the import stops declaring
# the passkey execution, this renderer stops emitting the required action (and
# fails, because then the whole desired state is empty of passkey intent).
#
# WHAT IS DELIBERATELY NOT EXPORTED
# * the TWO-FACTOR webAuthnPolicy* family. Nothing in this realm uses the
# `webauthn-authenticator` two-factor authenticator; the passwordless
# authenticator reads getWebAuthnPolicyPasswordless() only, so a policy written
# into the two-factor family is a silent no-op. Exporting it would hand the
# reconciler a write surface with no purpose.
# * `clients`, `users`, `groups`, `roles`. Client secrets and client URLs are the
# SIBLING reconciler's surface (platform/keycloak/client-secret-reconciler.yaml),
# and users/groups/roles are nobody's — prod's real user and the media-users /
# platform-admins / viewers groups must survive every run of everything.
# * execution `priority`. It is consumed here to ORDER the exported executions
# (Keycloak returns them in priority order), but the numbers themselves are not
# exported: the reconciler compares the resulting SEQUENCE, which is what
# actually determines behaviour. Exporting the integers would make a
# priority renumbering that changes no ordering read as drift.
#
# USAGE
# python3 ci/render-keycloak-passkey-policy.py # print the rendered file
# python3 ci/render-keycloak-passkey-policy.py --write # write it (regenerate)
# python3 ci/render-keycloak-passkey-policy.py --check # exit 1 if it has drifted
# =============================================================================
from __future__ import annotations
import argparse
import difflib
import json
import sys
from pathlib import Path
try:
import yaml
except ImportError: # pragma: no cover
print("FATAL: PyYAML is required (same dependency as catalog/render.py)",
file=sys.stderr)
raise SystemExit(1)
ROOT = Path(__file__).resolve().parent.parent
IMPORT_FILE = "platform/keycloak/keycloak-realm-import.yaml"
OUT_FILE = "platform/keycloak/passkey-desired-state.yaml"
DATA_KEY = "passkey-desired-state.json"
CONFIGMAP_NAME = "keycloak-passkey-desired-state"
NAMESPACE = "keycloak"
# The realm keys this renderer exports, and therefore the ONLY realm keys the
# reconciler may write. Every one is a webAuthnPolicyPasswordless* field. Kept in
# lockstep with POLICY_FIELDS in the reconciler script; the L0 check asserts the two
# tuples are identical, so they cannot drift apart silently.
POLICY_STR_FIELDS = (
"webAuthnPolicyPasswordlessRpId",
"webAuthnPolicyPasswordlessRpEntityName",
"webAuthnPolicyPasswordlessRequireResidentKey",
"webAuthnPolicyPasswordlessUserVerificationRequirement",
"webAuthnPolicyPasswordlessAuthenticatorAttachment",
"webAuthnPolicyPasswordlessAttestationConveyancePreference",
)
POLICY_LIST_FIELDS = (
"webAuthnPolicyPasswordlessSignatureAlgorithms",
"webAuthnPolicyPasswordlessAcceptableAaguids",
)
POLICY_INT_FIELDS = ("webAuthnPolicyPasswordlessCreateTimeout",)
POLICY_BOOL_FIELDS = ("webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister",)
POLICY_FIELDS = (POLICY_STR_FIELDS + POLICY_LIST_FIELDS + POLICY_INT_FIELDS
+ POLICY_BOOL_FIELDS)
# The field Keycloak 26.0.6 gates the WHOLE passwordless policy on
# (DefaultExportImportManager.updateRealm: the policy is applied only when
# RpEntityName is non-null and non-empty). A desired state without it would produce
# an accepted PUT that changes nothing, so refuse to render one.
RP_ENTITY_NAME = "webAuthnPolicyPasswordlessRpEntityName"
# The execution whose presence is what makes passkey enrolment necessary.
PASSKEY_AUTHENTICATOR = "webauthn-authenticator-passwordless"
ENROL_ACTION = "webauthn-register-passwordless"
BIND_KEY = "bind-browser-flow"
BIND_VALUES = ("true", "false")
def committed_bind(root: Path) -> str:
"""The operator's bind-browser-flow value from the COMMITTED file, or "false".
This is the only input read from the output file, and it is validated, not trusted:
anything other than the exact strings "true" / "false" is a hard failure."""
target = root / OUT_FILE
if not target.is_file():
return "false"
docs = [d for d in yaml.safe_load_all(target.read_text(encoding="utf-8")) if d]
cm = next((d for d in docs if d.get("kind") == "ConfigMap"), None) or {}
data = cm.get("data") or {}
if BIND_KEY not in data:
return "false"
v = data[BIND_KEY]
if not isinstance(v, str) or v not in BIND_VALUES:
raise SystemExit(
"FATAL: %s data.%s is %r. It must be exactly the QUOTED string \"true\" or "
"\"false\": an unquoted true/false is a YAML boolean, which a ConfigMap rejects "
"at apply time and which would fail the whole platform-config Kustomization"
% (OUT_FILE, BIND_KEY, v))
return v
def render(root: Path) -> str:
"""Return the full text of passkey-desired-state.yaml as it should be committed."""
docs = list(yaml.safe_load_all((root / IMPORT_FILE).read_text()))
imp = next((d for d in docs if d and d.get("kind") == "KeycloakRealmImport"), None)
if imp is None:
raise SystemExit("FATAL: no KeycloakRealmImport document in %s" % IMPORT_FILE)
realm = (imp.get("spec") or {}).get("realm") or {}
realm_name = realm.get("realm")
if not isinstance(realm_name, str) or not realm_name:
raise SystemExit("FATAL: %s declares no spec.realm.realm name" % IMPORT_FILE)
# ---- the passwordless policy, copied VERBATIM ----------------------------------
policy: dict[str, object] = {}
for f in POLICY_STR_FIELDS:
if f not in realm:
continue # not declared => NOT MANAGED
v = realm[f]
# A bare ${BASE_DOMAIN} parses as a plain string; an int or bool here would be
# a YAML quoting mistake that must not be silently stringified.
if not isinstance(v, str):
raise SystemExit("FATAL: %s realm field %s is not a string (got %r) — "
"quote it in the realm import" % (IMPORT_FILE, f, v))
policy[f] = v
for f in POLICY_LIST_FIELDS:
if f not in realm:
continue
v = realm[f]
if not isinstance(v, list) or not all(isinstance(x, str) for x in v):
raise SystemExit("FATAL: %s realm field %s is not a list of strings"
% (IMPORT_FILE, f))
policy[f] = list(v) # verbatim, order as authored
for f in POLICY_INT_FIELDS:
if f not in realm:
continue
v = realm[f]
if not isinstance(v, int) or isinstance(v, bool):
raise SystemExit("FATAL: %s realm field %s is not an integer"
% (IMPORT_FILE, f))
policy[f] = v
for f in POLICY_BOOL_FIELDS:
if f not in realm:
continue
v = realm[f]
if not isinstance(v, bool):
raise SystemExit("FATAL: %s realm field %s is not a boolean"
% (IMPORT_FILE, f))
policy[f] = v
if not policy:
# Fail closed. An empty policy would make the reconciler believe every field is
# unmanaged and silently stop reconciling — the realm import's own fail-open.
raise SystemExit("FATAL: %s declares no webAuthnPolicyPasswordless* field — "
"refusing to render an empty desired state" % IMPORT_FILE)
if not policy.get(RP_ENTITY_NAME):
raise SystemExit(
"FATAL: %s does not declare a non-empty %s. Keycloak 26.0.6 applies the "
"passwordless policy ONLY when that field is set, so every realm PUT would "
"be accepted and change nothing" % (IMPORT_FILE, RP_ENTITY_NAME))
# ---- the flow tree, flattened per flow and ORDERED BY PRIORITY -----------------
browser_flow = realm.get("browserFlow")
if not isinstance(browser_flow, str) or not browser_flow:
raise SystemExit("FATAL: %s declares no spec.realm.browserFlow — the flow could "
"be perfect and still not be the one anyone logs in through"
% IMPORT_FILE)
raw_flows = realm.get("authenticationFlows") or []
if not isinstance(raw_flows, list) or not raw_flows:
raise SystemExit("FATAL: %s declares no authenticationFlows" % IMPORT_FILE)
flows: dict[str, object] = {}
for fl in raw_flows:
alias = fl.get("alias")
if not isinstance(alias, str) or not alias:
raise SystemExit("FATAL: %s has an authentication flow with no alias"
% IMPORT_FILE)
if alias in flows:
raise SystemExit("FATAL: %s declares the flow %r twice"
% (IMPORT_FILE, alias))
execs = fl.get("authenticationExecutions") or []
if not isinstance(execs, list) or not execs:
raise SystemExit("FATAL: %s flow %r declares no authenticationExecutions"
% (IMPORT_FILE, alias))
ordered = []
for ex in sorted(execs, key=lambda e: (e.get("priority") or 0)):
is_flow = bool(ex.get("authenticatorFlow"))
name = ex.get("flowAlias") if is_flow else ex.get("authenticator")
if not isinstance(name, str) or not name:
raise SystemExit(
"FATAL: %s flow %r has an execution with neither an authenticator "
"nor a flowAlias" % (IMPORT_FILE, alias))
requirement = ex.get("requirement")
if not isinstance(requirement, str) or not requirement:
raise SystemExit("FATAL: %s flow %r execution %r declares no requirement"
% (IMPORT_FILE, alias, name))
entry: dict[str, object] = {
"kind": "flow" if is_flow else "authenticator",
"name": name,
"requirement": requirement,
}
cfg = ex.get("authenticatorConfig")
if cfg is not None:
if not isinstance(cfg, str) or not cfg:
raise SystemExit("FATAL: %s flow %r execution %r has a non-string "
"authenticatorConfig" % (IMPORT_FILE, alias, name))
entry["configAlias"] = cfg
ordered.append(entry)
flows[alias] = {"topLevel": bool(fl.get("topLevel")), "executions": ordered}
if browser_flow not in flows:
raise SystemExit("FATAL: %s binds browserFlow to %r but declares no such flow"
% (IMPORT_FILE, browser_flow))
# Every referenced subflow must be declared, or the reconciler's flattening walks
# into a hole and the audit silently covers less than it claims.
for alias, spec in flows.items():
for ex in spec["executions"]:
if ex["kind"] == "flow" and ex["name"] not in flows:
raise SystemExit("FATAL: %s flow %r references subflow %r, which is not "
"declared" % (IMPORT_FILE, alias, ex["name"]))
# ---- authenticator configs, copied verbatim ------------------------------------
configs: dict[str, dict] = {}
for cfg in realm.get("authenticatorConfig") or []:
alias = cfg.get("alias")
if not isinstance(alias, str) or not alias:
raise SystemExit("FATAL: %s has an authenticatorConfig with no alias"
% IMPORT_FILE)
body = cfg.get("config") or {}
if not isinstance(body, dict) or not body:
raise SystemExit("FATAL: %s authenticatorConfig %r has no config body"
% (IMPORT_FILE, alias))
# Values are stringified because Keycloak stores authenticator config values as
# strings. `negate: "false"` in the import and a stored "false" are the same
# declaration, and a YAML-unquoted `false` must not become a Python bool that
# then never matches.
configs[alias] = {k: str(v) for k, v in sorted(body.items())}
# Every configAlias a flow references must be defined, else the reconciler reports
# "git declares config X but defines no key/value for it" on every run forever.
for alias, spec in flows.items():
for ex in spec["executions"]:
ca = ex.get("configAlias")
if ca and ca not in configs:
raise SystemExit(
"FATAL: %s flow %r execution %r references authenticatorConfig %r, "
"which is not declared" % (IMPORT_FILE, alias, ex["name"], ca))
# ---- the enrolment required action, DERIVED (see the header) -------------------
offers_passkey = any(
ex["kind"] == "authenticator" and ex["name"] == PASSKEY_AUTHENTICATOR
for spec in flows.values() for ex in spec["executions"])
if not offers_passkey:
raise SystemExit(
"FATAL: %s declares a webAuthnPolicyPasswordless* policy but no %r execution "
"in any flow. The policy decides what a passkey must BE; without that "
"execution nothing ever OFFERS one, so the policy is unreachable "
"configuration and this desired state would be meaningless"
% (IMPORT_FILE, PASSKEY_AUTHENTICATOR))
want = {
"realm": realm_name,
"webAuthnPolicyPasswordless": policy,
# enabled: passkey enrolment must be possible, since the flow offers passkeys.
# defaultAction FALSE: enrolment stays OPT-IN. True would force every user to
# enrol a passkey at next login — the platform-wide lock-out the realm import's
# "additive and optional" posture exists to avoid. Derived, not copied; see the
# header for why the import cannot declare this itself.
"requiredAction": {"alias": ENROL_ACTION, "enabled": True,
"defaultAction": False},
"browserFlow": browser_flow,
"flows": flows,
"authenticatorConfig": configs,
}
body = json.dumps(want, indent=2, sort_keys=True, ensure_ascii=False)
indented = "\n".join((" " + ln) if ln else "" for ln in body.splitlines())
return (HEADER + BIND_BLOCK % (BIND_KEY, committed_bind(root))
+ " %s: |\n" % DATA_KEY + indented + "\n")
HEADER = """---
# =============================================================================
# GENERATED FILE — DO NOT EDIT BY HAND, except the one bind-browser-flow line below.
#
# source: platform/keycloak/keycloak-realm-import.yaml (spec.realm)
# regenerate: python3 ci/render-keycloak-passkey-policy.py --write
# gated by: conformance/tests/l0-static/keycloak-passkey-reconciler
# (runs this renderer with --check on every merge)
#
# WHAT THIS IS: the DESIRED STATE for the `homelab` realm's PASSKEY configuration —
# the WebAuthn passwordless policy, the enrolment required action, and the declared
# browser authentication flow tree. platform/keycloak/passkey-reconciler.yaml mounts
# it and (a) converges the policy and the required action onto it, (b) builds the
# declared flow tree BLUE/GREEN under a new alias and verifies it by readback, never
# writing the flow users are logging in through, and (c) BINDS that verified flow as
# the realm's browserFlow ONLY when `bind-browser-flow` below is "true".
#
# EVERYTHING HERE IS GENERATED EXCEPT ONE LINE: `bind-browser-flow`. That is an
# operator decision, carried through by the renderer from this committed file (see
# ci/render-keycloak-passkey-policy.py). It defaults to "false".
#
# WHY IT IS A SEPARATE OBJECT: the realm import is a ONE-SHOT that never re-runs
# once Done=True, so a realm-level field or a flow edited in git never reaches the
# realm. Measured on prod 2026-10-18, hours after the passkey declaration merged:
# Done=True / Started=False, with the whole passkey block inert. The reconciler is
# the level-triggered loop that closes that; this file is what it reconciles
# TOWARDS.
#
# WHY IT IS GENERATED AND NOT AUTHORED: a hand-written copy of the RP ID, the
# user-verification requirement and the flow tree would be a second source of truth,
# free to disagree with the realm import — the same "two declarations that can
# disagree" shape as the bug being closed. It is rendered from the import and the
# render is gated, so the two cannot drift.
#
# STRINGS ARE VERBATIM: webAuthnPolicyPasswordlessRpId is the ${BASE_DOMAIN}
# substitution token exactly as the realm import writes it. Both files are rendered
# by the SAME Flux Kustomization (./platform-config), so the token resolves to the
# same value in both and the domain is decided in exactly one place.
#
# `requiredAction` is the ONE entry that is DERIVED rather than copied: the realm
# import deliberately declares no `requiredActions` block, because Keycloak 26.0.6
# treats that as REPLACE and declaring one action would delete CONFIGURE_TOTP and
# nine more (a hard lock-out of every platform-admin). It is derived instead from
# the presence of a webauthn-authenticator-passwordless execution in the flow:
# passkeys are offered, therefore enrolment must be enabled — and defaultAction is
# false, so enrolment stays opt-in.
#
# A field NOT declared on the realm in the import is simply ABSENT here, and the
# reconciler leaves it alone. Absence means UNMANAGED, never "force to a default".
# =============================================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: %s
namespace: %s
labels:
app.kubernetes.io/name: %s
app.kubernetes.io/part-of: keycloak
data:
""" % (CONFIGMAP_NAME, NAMESPACE, CONFIGMAP_NAME)
BIND_BLOCK = """ # ---------------------------------------------------------------------------
# THE BIND SWITCH -- the ONLY hand-edited line in this file. Exactly "true" or
# "false", QUOTED.
#
# "false" the reconciler builds and verifies the candidate browser flow and
# logs `WOULD BIND <alias>`, but NEVER changes the realm's browserFlow.
# Whatever the realm is bound to -- including a rollback -- is left alone.
# "true" it binds the verified candidate in one atomic PUT, and keeps the
# realm bound to it: a binding changed by hand is changed back.
#
# GO LIVE: test the candidate in a browser first (platform/keycloak/README.md,
# "Passkeys"), then a PR setting this to "true".
# ROLL BACK: a PR setting this to "false" FIRST (or the next run rebinds), then
# one write setting browserFlow back to the previous alias from the Job log.
# ---------------------------------------------------------------------------
%s: "%s"
"""
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
g = ap.add_mutually_exclusive_group()
g.add_argument("--write", action="store_true", help="write %s" % OUT_FILE)
g.add_argument("--check", action="store_true",
help="exit 1 if the committed file has drifted from the source")
args = ap.parse_args()
want = render(ROOT)
target = ROOT / OUT_FILE
if args.write:
target.write_text(want, encoding="utf-8")
print("wrote %s" % OUT_FILE)
return 0
if args.check:
if not target.is_file():
print("CHECK FAIL: %s does not exist. Run: python3 "
"ci/render-keycloak-passkey-policy.py --write" % OUT_FILE,
file=sys.stderr)
return 1
have = target.read_text(encoding="utf-8")
if have == want:
print("passkey-desired-state.yaml is in sync with %s" % IMPORT_FILE)
return 0
print("CHECK FAIL: %s has DRIFTED from %s — the reconciler would converge the "
"realm on a passkey policy the realm import does not declare, and build "
"the login flow from a tree nobody authored.\n"
"Regenerate with: python3 ci/render-keycloak-passkey-policy.py --write\n"
% (OUT_FILE, IMPORT_FILE), file=sys.stderr)
for line in difflib.unified_diff(have.splitlines(True), want.splitlines(True),
fromfile="committed " + OUT_FILE,
tofile="rendered from " + IMPORT_FILE):
sys.stderr.write(line)
return 1
sys.stdout.write(want)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+21
View File
@@ -0,0 +1,21 @@
---
# Flux Kustomization -> ./platform/buildkit — rootless BuildKit, the image builder CI uses
# (.gitea/workflows/images.yaml). Its own Kustomization so a stuck Certificate or a failing
# builder can never stall any other tier (Flux health-checks Certificates under wait:true).
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: buildkit
namespace: flux-system
spec:
interval: 10m
retryInterval: 2m
timeout: 5m
prune: true
wait: true
dependsOn:
- name: platform # cert-manager CRDs (Issuer/Certificate) come from its HelmRelease
sourceRef:
kind: GitRepository
name: flux-system
path: ./platform/buildkit
+17 -15
View File
@@ -234,26 +234,28 @@ data:
WG_CLIENT_CIDR: "10.90.0.0/24" # remote VPN clients; .1 = server. NOT a LAN range
# --- Bulk (external NFS) storage host (docs/04 bulk tier) ---------------------------------------
# THE address of the Proxmox host whose ZFS datasets back every `class: bulk` volume: the media
# library (Storage/plex, 8.33 T — plex, sonarr, radarr) and the legacy Nextcloud archive
# THE address of the NAS whose ZFS datasets back every `class: bulk` volume: the media library
# (Storage/plex, 8.33 T — plex, sonarr, radarr, qbittorrent) and the legacy Nextcloud archive
# (Storage/nextcloud, 270 G). The renderer emits it into each static NFS PersistentVolume's
# `spec.nfs.server` (catalog/render.py, BULK_NFS_SERVER); the export PATH is per-dataset and is
# declared in the ServiceDefinition, because the path is identical in every environment and only
# the ADDRESS is not.
#
# WHY THIS IS A KNOB AND NOT A LITERAL — the two clusters are on OPPOSITE SIDES of pfSense.
# The storage host has exactly one interface, vmbr0 192.168.1.9/24, on pfSense's LAN.
# dev nodes are 192.168.1.40/.41/.42 — the SAME L2 segment. They reach it directly, and one
# NFS export to them already exists on that host (/mnt/Storage/k8s-volumes in
# /etc/exports), so this path is proven, not theoretical. Base default = the dev value,
# per the convention of the LAN block above.
# prod nodes are 192.168.2.10/.11/.12 — pfSense's WAN. tcp/2049 does NOT traverse that today;
# 22 and 8006 answer only because pfSense port-forwards them via 192.168.2.102. Opening
# the storage path is a PENDING OPERATOR DECISION with two candidate outcomes that give
# DIFFERENT values here: 192.168.2.102 (another port-forward) or 192.168.1.9 (a route).
# So prod holds a loud TBD-PROD-* placeholder — see clusters/prod/kustomization.yaml for the
# blast radius of shipping it unset, which is NOT the usual "fails to apply".
BULK_NFS_SERVER: "192.168.1.9"
# IT IS NO LONGER A CROSS-NETWORK KNOB (2026-10-07). This used to read "the two clusters are on
# OPPOSITE SIDES of pfSense": the datasets lived on a Proxmox host whose only interface was
# vmbr0 192.168.1.9/24 on pfSense's LAN, which prod (192.168.2.0/24, pfSense's WAN) could reach
# only through a DNAT at 192.168.2.102. Both pools were physically moved onto a standalone
# TrueNAS at 192.168.2.30 — prod's OWN /24 — so there is no crossing to arrange and no candidate
# values to choose between. Measured 2026-10-07: 192.168.2.102:2049 no longer answers;
# 192.168.2.30:2049 and :111 do. There is now exactly ONE storage host and one correct value;
# this stays a knob only because the renderer reads it per-environment.
#
# *** CHANGING THIS VALUE IS NOT A PLAIN RECONCILE. *** A bound PersistentVolume's spec.nfs is
# immutable — measured by server dry-run 2026-10-07:
# spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is immutable after creation
# so Flux's apply FAILS rather than updating, and the pods keep their old mount. The PVs must be
# deleted and recreated; the operator sequence is docs/BULK-NFS-STORAGE.md §10.
BULK_NFS_SERVER: "192.168.2.30"
# Off-cluster S3 backup target (R-STATE-03, R-IDP-05) — the on-Proxmox MinIO, plain HTTP, path-style.
# Consumed by BOTH backup paths: every CNPG Cluster's barmanObjectStore.endpointURL (keycloak-db,
+98
View File
@@ -0,0 +1,98 @@
---
# Flux Kustomization: kured (Kubernetes Reboot Daemon) — reconciles ./platform/kured.
# Performs the cordon / drain / reboot / uncordon of a node that has a pending OS reboot, strictly
# one node at a time, inside a nightly window, gated on Longhorn volume health. Design rationale,
# every tuned value, and the honest list of what is NOT mitigated: platform/kured/helmrelease.yaml.
#
# WHY ITS OWN KUSTOMIZATION, AND NOT ONE LINE IN platform/kustomization.yaml
#
# The same argument the Cilium split-out recorded in platform/kustomization.yaml and intel-gpu-plugin
# reused, except the consequence is worse here than for either of them:
#
# * `platform` is `wait: true` over twelve-plus components, so its Ready condition cannot tell you
# "the reboot daemon is wedged" from "longhorn is slow".
# * The only emergency valve would be `flux suspend kustomization platform` — freezing storage,
# TLS, DNS, ingress, monitoring, Keycloak and KubeVirt in order to stop touching a DaemonSet.
# * That tier's 10m interval / 5m timeout are called out there as WRONG for a 3-node DaemonSet
# rollout. This is a 3-node DaemonSet rollout.
# * And `platform` gates platform-config, which gates apps-wave-0 (wait:true), which gates
# apps-wave-1. A reboot daemon that failed to go healthy inside that tier would stall EVERY
# APPLICATION in the cluster on "dependency not ready". A component added to make maintenance
# safer must not acquire the ability to cause an outage; that trade is the whole argument.
#
# dependsOn: [platform] — AND THIS ONE IS A REAL EDGE, NOT CONVENTION
#
# Not `cilium` (what intel-gpu-plugin uses) and not `platform-config` (what the leaf UIs use). It is
# `platform`, for two independent reasons:
#
# 1. CRD ORDERING (F-MON1). platform/kured ships a PrometheusRule, and the chart generates a
# ServiceMonitor. Both are CRs whose CRDs (monitoring.coreos.com) are installed by
# kube-prometheus-stack INSIDE the `platform` tier. Flux dry-runs every object in a pass before
# applying any of them, so a pass containing an unknown kind ABORTS — the failure that made the
# CDI StorageProfiles a true deadlock on prod on 2026-09-05 (see
# clusters/base/platform-config-cdi.yaml). Depending on `platform` guarantees the CRDs exist
# before this pass is attempted.
# 2. FUNCTIONAL PREREQUISITE. kured's reboot gate QUERIES Prometheus
# (`prometheusUrl` in the HelmRelease). Prometheus lives in platform/monitoring. If kured ran
# first it would get connection errors, treat the gate as unevaluable and skip every reboot —
# the correct fail-safe direction, but silent. Longhorn, whose volume health IS the gate, is
# also in that tier.
#
# It does NOT need platform-config: no Keycloak, no TLS, no split-horizon DNS, no Gateway, no
# HTTPRoute. kured serves no HTTP to users.
#
# wait: true — A HEALTH SIGNAL, NOT A GATE. Following intel-gpu-plugin rather than the leaf-UI
# template. A leaf UI is legitimately not-Ready for long stretches of a cold start while it waits on
# Keycloak and DNS, so its health is uninformative; kured has no such dependency — it is a DaemonSet
# that opens a socket and watches a file, so on healthy nodes it is Ready in seconds and not-Ready
# genuinely means BROKEN. This turns `flux get kustomization kured` into a one-line answer to "is
# reboot automation live on all three nodes", which is the question the operator must be able to
# answer before trusting a kernel upgrade to it.
#
# NOTHING IN THE GRAPH dependsOn THIS, so wait:true gates nothing — the blast radius of an unhealthy
# kured is this Kustomization's own Ready condition. Do not add `dependsOn: [kured]` anywhere: that
# would hand a node-rebooting DaemonSet the power to stall whatever depends on it, which is precisely
# what the placement above exists to prevent.
#
# prune: true is safe and deliberate. If this Kustomization is deleted, kured's DaemonSet goes with
# it and reboots simply stop happening — nodes are left uncordoned and running. The one caveat is
# that pruning mid-reboot would leave a node CORDONED with no daemon to uncordon it; that is a
# `kubectl uncordon` away and is covered in runbooks/node-reboot-automation.md.
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: kured
namespace: flux-system
spec:
# A HelmRelease plus five static manifests, nothing to re-render; kustomize-controller is
# source-triggered, so this is only the drift-correction floor.
interval: 10m
retryInterval: 2m
# 10m, not the 5m the `platform` tier uses: a 3-node DaemonSet rollout under `wait: true` can
# exceed 5m on a cold start (image pull on three nodes), which is one of the reasons the Cilium
# split-out gave for leaving that tier.
timeout: 10m
prune: true
# See the header: a health SIGNAL for a component nothing depends on, not a gate.
wait: true
dependsOn:
- name: platform # monitoring CRDs + Prometheus + Longhorn; see the header, this is load-bearing
sourceRef:
kind: GitRepository
name: flux-system
path: ./platform/kured
# REQUIRED, not boilerplate. platform/kured/alerts.yaml renders `${BASE_DOMAIN}` into the
# `runbook_url` annotation of all three rules. Without this block that token becomes the EMPTY
# STRING and the Kustomization still reports Ready — Flux's substitution fails OPEN — so every
# alert would ship a dead link to `https:///runbooks/node-reboot-automation`. The gate stage
# `flux-postbuild-substitution` asserts the converse (a rendered token implies this block), and
# BASE_DOMAIN is declared in clusters/base/cluster-vars.yaml.
#
# Nothing else in this path contains `${...}` or a bare `$`: the HelmRelease's alertFilterRegexp is
# a plain anchored alternation, and there are no shell snippets anywhere in the component — which
# is deliberate, because envsubst would eat a braced shell variable and `$$` would trip the
# dollar-collapse assertion in the same check.
postBuild:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
+14
View File
@@ -85,4 +85,18 @@ resources:
# pm-agent-credentials.sops.yaml, so both overlays add a SOPS decryption patch for it (by
# name). See clusters/base/pm-agent.yaml and platform/pm-agent/README.md.
- pm-agent.yaml # Flux Kustomization -> ./platform/pm-agent (dependsOn apps-wave-0)
# kured (Kubernetes Reboot Daemon) — its OWN Kustomization (dependsOn platform, wait:true), NOT a
# line in platform/kustomization.yaml. Same reasoning as the Cilium and intel-gpu-plugin
# split-outs, with a sharper consequence: `platform` gates platform-config and every apps wave, so
# a wedged reboot DaemonSet placed in that wait:true tier could stall every application in the
# cluster — a component added to make maintenance SAFER must not be able to cause an outage.
# wait:true here is a HEALTH SIGNAL, not a gate: nothing dependsOn it, and nothing should.
# The `platform` edge is load-bearing rather than conventional — it supplies the
# monitoring.coreos.com CRDs this component's PrometheusRule/ServiceMonitor need (F-MON1), and
# Prometheus itself, which kured QUERIES before every reboot to gate on Longhorn volume health.
# It reboots a node only when /var/run/reboot-required exists, which on these nodes only a
# deliberate operator kernel upgrade creates — the human gate is the sentinel, not the daemon.
# See clusters/base/kured.yaml and platform/kured/helmrelease.yaml.
- kured.yaml # Flux Kustomization -> ./platform/kured (dependsOn platform)
- buildkit.yaml # Flux Kustomization -> ./platform/buildkit (dependsOn platform)
- cluster-vars.yaml # overlay knob surface (base defaults)
+8 -5
View File
@@ -8,8 +8,8 @@
# NOTHING downstream reconciles — the incident behind the headlamp/oauth2-proxy/homepage moves
# (DECISIONS-LOG: "Headlamp gating stall").
# * SPECIFIC, AND THE REASON THIS IS NOT A JUDGEMENT CALL: this DaemonSet cannot start until the
# operator creates the out-of-band `wg-server-key` Secret (runbooks/vpn-remote-access.md
# step 1). That is the correct fail-closed state — an unenrolled VPN must not come up — but in
# operator replaces the `REPLACE-ME` placeholder in platform/vpn-ingress/wg-server-key.sops.yaml
# with a real key (runbooks/vpn-remote-access.md step 2). That is the correct fail-closed state — an unenrolled VPN must not come up — but in
# the gating tier it would have been a self-inflicted platform-wide outage ON MERGE, with the
# cause (a missing manual step) invisible from any Flux condition.
#
@@ -51,6 +51,9 @@ spec:
substituteFrom:
- kind: ConfigMap
name: cluster-vars
# NO `decryption:` stanza is needed in the overlays: this path carries no *.sops.yaml. The server
# key is an out-of-band Secret (platform/vpn-ingress/kustomization.yaml says what to do if that
# ever changes).
# SOPS decryption is layered per-environment by the overlay (clusters/dev and clusters/prod each
# patch THIS Kustomization by name), identical to headlamp/crafty/velero — this path carries
# platform/vpn-ingress/wg-server-key.sops.yaml, the WireGuard server private key. The `sops-age`
# Secret is created out of band and referenced by NAME only. Without the overlay patch the Secret
# applies cleanly full of ENC[...] and only the tunnel is broken, so the patch is as load-bearing
# as the encrypted file itself.
+25 -7
View File
@@ -122,6 +122,24 @@ patches:
provider: sops
secretRef:
name: sops-age
# vpn-ingress (the WireGuard remote-access VPN, clusters/base/vpn-ingress.yaml) carries
# platform/vpn-ingress/wg-server-key.sops.yaml — the WireGuard SERVER private key, the one
# secret in that component. Without this stanza kustomize hands Flux the raw ciphertext and the
# Secret applies full of ENC[...]: the APPLY SUCCEEDS and every Flux condition stays green, and
# the only symptom is a VPN that no client can handshake with. The DaemonSet's init container
# validates the value with `wg pubkey` specifically so that state is a named CrashLoop instead.
# The `sops-age` Secret is out-of-band (name only).
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: vpn-ingress
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
# account and the Anthropic subscription token from `claude setup-token`. Without this
@@ -261,15 +279,15 @@ patches:
- op: replace
path: /data/CRAFTY_L4_VIP
value: "192.168.1.55"
# Bulk (external NFS) storage host: the Proxmox host itself, on the SAME LAN as dev's nodes
# (192.168.1.40/.41/.42), which already hold an NFS export from it (/mnt/Storage/k8s-volumes).
# Restated verbatim rather than inherited, per this overlay's "supply every knob" convention.
# Dev is therefore the environment in which the bulk manifests can actually be VALIDATED
# end-to-end before prod's network path exists — provided the additive `zfs set sharenfs=`
# change described in docs/BULK-NFS-STORAGE.md is applied for the dev node addresses.
# Bulk (external NFS) storage host. THIS VALUE IS INERT — dev is decommissioned, its nodes
# (192.168.1.40/.41/.42) no longer exist, and the Proxmox host this used to name (192.168.1.9)
# went with the pools it held. It is restated per this overlay's "supply every knob"
# convention, not because anything consumes it. It carries the real address so that no file
# in the tree renders a host that is gone; it has NOT been proven from a dev node, because
# there is no dev node to prove it from.
- op: replace
path: /data/BULK_NFS_SERVER
value: "192.168.1.9"
value: "192.168.2.30"
# Off-cluster backup target: the on-Proxmox MinIO (plain HTTP, path-style) that every CNPG
# barmanObjectStore and Velero's backupStorageLocation write to.
#
+53 -47
View File
@@ -113,6 +113,24 @@ patches:
provider: sops
secretRef:
name: sops-age
# vpn-ingress (the WireGuard remote-access VPN, clusters/base/vpn-ingress.yaml) carries
# platform/vpn-ingress/wg-server-key.sops.yaml — the WireGuard SERVER private key, the one
# secret in that component. Without this stanza kustomize hands Flux the raw ciphertext and the
# Secret applies full of ENC[...]: the APPLY SUCCEEDS and every Flux condition stays green, and
# the only symptom is a VPN that no client can handshake with. The DaemonSet's init container
# validates the value with `wg pubkey` specifically so that state is a named CrashLoop instead.
# The `sops-age` Secret is out-of-band (name only).
- patch: |-
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: vpn-ingress
namespace: flux-system
spec:
decryption:
provider: sops
secretRef:
name: sops-age
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
# account and the Anthropic subscription token from `claude setup-token`. Without this
@@ -522,60 +540,48 @@ patches:
path: /data/LAN_CIDR
value: "192.168.2.0/24"
# ------------------------------------------------------------------------------------------
# BULK (EXTERNAL NFS) STORAGE HOST — *** NOT YET DECIDED. THIS PLACEHOLDER IS LOAD-BEARING. ***
# BULK (EXTERNAL NFS) STORAGE HOST — the TrueNAS at 192.168.2.30.
# ------------------------------------------------------------------------------------------
# Backs every `class: bulk` volume: the 8.33 T media library (Storage/plex — plex, sonarr,
# radarr) and the 270 G legacy Nextcloud archive (Storage/nextcloud).
# radarr, qbittorrent) and the 270 G legacy Nextcloud archive (Storage/nextcloud).
#
# THE STORAGE HOST IS NOT REACHABLE FROM PROD AT ALL RIGHT NOW. It has one interface,
# vmbr0 192.168.1.9/24, on pfSense's LAN; prod's nodes are on 192.168.2.0/24, pfSense's WAN.
# 22 and 8006 answer from prod only because pfSense port-forwards them via 192.168.2.102;
# 2049 and 111 do not. Opening that path is an OPERATOR decision, and the two candidate
# outcomes give DIFFERENT values here:
# port-forward 192.168.2.102:2049 -> 192.168.1.9:2049 => "192.168.2.102"
# a WAN->LAN route to 192.168.1.0/24 => "192.168.1.9"
# Guessing either one authors a prod cluster that mounts nothing, or — worse, if the guess
# ever became reachable — mounts something unintended. So it stays a placeholder.
# THIS WAS THE REPO'S ONE LOAD-BEARING PLACEHOLDER, AND THE REASON IT COULD BE IS NOW GONE.
# The datasets lived on a Proxmox host with a single interface on pfSense's LAN
# (192.168.1.9), which prod could reach only through a DNAT at 192.168.2.102 — so the correct
# value here depended on an undecided operator choice, and guessing it would have authored a
# cluster that mounts nothing. Both pools were physically moved onto a standalone TrueNAS at
# 192.168.2.30, prod's OWN /24. No crossing to arrange, no candidates to choose between.
# Measured 2026-10-07: 192.168.2.102:2049 no longer answers; 192.168.2.30:2049 and :111 do.
#
# WHICH PORTS THE DECISION MUST OPEN: exactly ONE, tcp/2049. The PVs mount nfsvers=4.1, and
# NFSv4 carries mount and lock in-protocol — no rpcbind/111, no mountd, no statd. Choosing v4
# over v3 was in part this decision's cost: v3 would have needed four ports and three of them
# on ephemeral numbers.
#
# WHAT THE EXPORT MUST ALLOW IS ALREADY SETTLED, and it is the same under BOTH outcomes.
# Measured 2026-09-07 from this workstation (192.168.2.132) through the existing port-forward:
# the storage host logged the connection as coming FROM 192.168.2.132, not from a pfSense LAN
# address (`ss -tn` peer 192.168.2.132, and the host's only route off-LAN is its default via
# 192.168.1.1). pfSense DNATs WITHOUT source NAT, so the NFS server will see prod's REAL node
# addresses either way. The additive `zfs set sharenfs=` values are in docs/BULK-NFS-STORAGE.md.
#
# *** BLAST RADIUS — READ BEFORE MERGING THIS TO A BRANCH PROD RECONCILES. ***
# A PersistentVolume's spec.nfs.server is an unvalidated string: the API server ACCEPTS this
# placeholder, the PV is created and the PVC BINDS to it (binding is pure control-plane). The
# failure surfaces only when a pod mounts — MountVolume.SetUp "Failed to resolve server
# TBD-PROD-BULK-NFS-SERVER" — so plex, sonarr, radarr and nextcloud would sit in
# ContainerCreating on prod. Unlike the LAN knobs above, this placeholder does NOT fail at
# apply time and there is no CRD validation to lean on. Set this value BEFORE prod reconciles
# a revision containing the bulk volumes; the dev overlay renders a working value, which is
# where these manifests are meant to be proven first.
# ONE PORT IS STILL ALL IT NEEDS: tcp/2049. The PVs mount nfsvers=4.1 and NFSv4 carries mount
# and lock in-protocol — no rpcbind/111, no mountd, no statd. The NAS advertises
# `+3 +4 +4.1 +4.2` (/proc/fs/nfsd/versions, measured), so 4.1 negotiates.
- op: replace
path: /data/BULK_NFS_SERVER
# 192.168.2.102 is pfSense's WAN address, which DNATs tcp/2049 through to the Proxmox
# host's REAL address 192.168.1.9 on pfSense's LAN. Prod sits on the WAN side and cannot
# reach 192.168.1.9 directly, so the cluster mounts the NAT address. Dev is on the LAN and
# keeps the base value 192.168.1.9.
# VERIFIED ON THE NAS 2026-10-07, before any cluster touched it (`exportfs -v`):
# /mnt/Storage/plex 192.168.2.10/.11/.12 rw,sec=sys,secure,root_squash,no_all_squash
# /mnt/Storage/nextcloud 192.168.2.10/.11/.12 ro,sec=sys,secure,root_squash,no_all_squash
#
# PROVEN from k3s-prod-1 on 2026-09-12, not inferred:
# - mount of 192.168.2.102:/mnt/Storage/plex negotiated nfs4 vers=4.1 proto=tcp
# - a write as uid 1000 succeeded. Tested as 1000 and NOT root deliberately: the export
# carries no_root_squash, so a root write would have passed and proved nothing, while
# LSIO images default to PUID 911 and would have gone Ready then failed every import
# with EACCES. The tree is 0755 1000:3000, so uid 1000 is the only writer.
# - ln() across downloads/ -> tv/ returned link count 2 (same inode), so the arrs get
# real atomic moves instead of silently degrading to copy-then-delete on EXDEV.
# pfSense DNATs WITHOUT source NAT (the host logs prod's own node addresses as the peer),
# so the ZFS export ACL names 192.168.2.10/.11/.12 directly rather than one translated IP.
value: "192.168.2.102"
# no_all_squash is the flag the entire permission model rests on: client uids pass through
# untouched, so the arrs' PUID=1000 arrives as the owner of a 0755 1000:3000 tree. sec=sys
# keeps that numeric rather than principal-mapped. The export deliberately does NOT carry
# no_root_squash, which the Proxmox one did: nothing here needs it, because every write to
# the tree happens after the LSIO drop to uid 1000 and the images only chown their Longhorn
# /config. Both subPath directories (plex/downloads, nextcloud/nextcloud) already exist at
# 0755 1000:3000, so a squashed root can still traverse them for subPath resolution.
#
# The ro= posture on the archive is now SERVER-enforced, which the old export also managed.
# The media tree's read-only half (plex) stays a CLIENT-side guarantee — all four media pods
# can land on any node, so the server sees the same three addresses for reader and writers
# alike and cannot tell them apart. Unchanged by the move; see docs/BULK-NFS-STORAGE.md §5.
#
# *** CHANGING THIS VALUE REQUIRES DELETING AND RECREATING THE PVs. *** A bound PV's
# spec.nfs is immutable — measured by server dry-run 2026-10-07:
# spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is immutable after creation
# so Flux's apply FAILS rather than updating and the pods keep their old, dead mount. The
# operator sequence is docs/BULK-NFS-STORAGE.md §10. Retain + the renderer's pre-set
# claimRef make it safe: no data is touched and each recreated PV rebinds its own PVC.
value: "192.168.2.30"
# ==========================================================================================
# OFF-CLUSTER DR TARGET — Cloudflare R2 (operator, 2026-09-05; bucket + token 2026-09-07).
# ==========================================================================================
+87 -1
View File
@@ -98,7 +98,10 @@ RUN set -eux; \
# --- runtime -----------------------------------------------------------------
ARG FEDORA_VERSION=44
FROM registry.fedoraproject.org/fedora-minimal:${FEDORA_VERSION}
# Stage `conformance-runner` is the image that ships; stage `verify` (end of file) is a TEST stage
# built FROM it that only runs assertions and is never pushed. build-image.sh and the CI
# pipeline build `--target verify` to prove it and `--target conformance-runner` for the image.
FROM registry.fedoraproject.org/fedora-minimal:${FEDORA_VERSION} AS conformance-runner
ARG CHAINSAW_VERSION=
ARG KUBECTL_VERSION=
@@ -198,3 +201,86 @@ WORKDIR /workspace
# prepend something the policy cannot see. `bash` is the interactive fallback for a human
# debugging the image locally.
CMD ["/bin/bash"]
# =============================================================================
# verify — a TEST stage, never shipped. Every assertion conformance/incluster/build-image.sh used
# to make with `run --user 65532 --read-only --tmpfs /workspace` now runs here, at build time,
# because CI builds on rootless BuildKit where no container runtime exists.
#
# WHAT A BUILD STEP CAN AND CANNOT REPRODUCE:
# * uid 65532 ............ exact (USER inherited from the conformance-runner stage).
# * no network ........... exact: RUN --network=none, and the first check proves it.
# * --read-only rootfs ... NOT reproducible (a RUN step always has a writable layer).
# Approximated by permission: everything uid 65532 can write must lie
# under /workspace, /tmp or /var/tmp. In the Job only /workspace (and
# /results) are writable — the image points HOME and TMPDIR there — and
# readOnlyRootFilesystem is enforced by
# platform/kyverno/admission/conformance-trigger-job-shape.yaml.
# =============================================================================
FROM conformance-runner AS verify
RUN --network=none /bin/bash -euo pipefail <<'VERIFY'
echo "--- no network (the build-time stand-in for the Job's isolation) ---"
if getent hosts gitea.home.keithjackson.dev >/dev/null 2>&1 || timeout 3 bash -c '</dev/tcp/1.1.1.1/443' 2>/dev/null; then
echo "UNEXPECTED: network reachable in the verify stage"; exit 1
fi
echo "confirmed: no network"
echo "--- id ---"; id
echo "--- HOME ---"; echo "HOME=${HOME}"
echo "--- bash ---"; bash --version | head -1
echo "--- python3 ---"; python3 --version
echo "--- git ---"; git --version
echo "--- kubectl ---"; kubectl version --client
echo "--- chainsaw ---"; chainsaw version
echo "--- baked versions ---"; cat /etc/conformance-runner-versions
echo "--- conformance-clone: fixed path, root-owned, NOT writable by the runner ---"
stat -c "%n %U:%G %a" /usr/local/bin/conformance-clone
if [ -w /usr/local/bin/conformance-clone ]; then
echo "UNEXPECTED: uid $(id -u) can write the one executable the policy will trust"; exit 1
fi
echo "confirmed: not writable by uid $(id -u)"
# Each of these MUST fail. Assert the non-zero exit as well as the message: a check that only
# greps stderr would pass a build where the guard had stopped rejecting.
expect_fail() {
local label="$1"; shift
local out rc=0
out="$(env "$@" 2>&1)" || rc=$?
echo "--- ${label} ---"
printf "%s\n" "${out}" | head -2
if [ "${rc}" -eq 0 ]; then
echo "UNEXPECTED: that should have failed, but it exited 0"; exit 1
fi
echo "(rejected, exit ${rc})"
}
expect_fail "conformance-clone rejects arguments (that is where an injection would live)" \
conformance-clone --layer l2
expect_fail "conformance-clone rejects a missing SHA" \
conformance-clone
expect_fail "conformance-clone rejects a branch name instead of a SHA (no silent run of the tip)" \
CONFORMANCE_GIT_SHA=main conformance-clone
expect_fail "conformance-clone rejects an UNRENDERED placeholder" \
CONFORMANCE_GIT_SHA=@@CONFORMANCE_GIT_SHA@@ conformance-clone
expect_fail "conformance-clone rejects a HOME off the workspace volume" \
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
HOME=/etc conformance-clone
expect_fail "conformance-clone refuses a REDIRECTED repository URL (the env-injection hole)" \
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
CONFORMANCE_REPO_URL=https://evil.invalid/x.git conformance-clone
expect_fail "conformance-clone refuses a clone target off the workspace volume" \
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
CONFORMANCE_SRC=/etc/src conformance-clone
echo "--- the root filesystem is not writable by the runner (expected: a failure) ---"
if touch /should-fail 2>/dev/null; then echo "UNEXPECTED: / is writable by uid $(id -u)"; exit 1; fi
echo "confirmed: / is not writable"
echo "--- everything uid $(id -u) CAN write lies under /workspace, /tmp or /var/tmp ---"
stray="$(find / -xdev \( -path /proc -o -path /sys -o -path /dev \) -prune -o -writable ! -type l -print 2>/dev/null \
| grep -vE '^/(workspace|tmp|var/tmp)(/|$)' || true)"
if [ -n "${stray}" ]; then echo "UNEXPECTED: writable outside the allowed paths:"; printf ' %s\n' ${stray} | head -20; exit 1; fi
echo "confirmed: no writable path outside /workspace, /tmp, /var/tmp"
echo "--- HOME is writable (the workspace emptyDir) ---"
touch "${HOME}/.probe" || { echo "UNEXPECTED: ${HOME} not writable"; exit 1; }
echo "confirmed: ${HOME} is writable"
echo "--- uid is non-zero ---"
[ "$(id -u)" -ne 0 ] || { echo "UNEXPECTED: running as root"; exit 1; }
echo "confirmed: running as uid $(id -u), not root"
echo "CONFORMANCE-RUNNER VERIFIED"
VERIFY
+9 -4
View File
@@ -61,10 +61,15 @@ credentials — the Job's own scoped ServiceAccount does (R-SEC-02).
> digest-pinned to the conformance-runner image, `command` exactly
> `/usr/local/bin/conformance-clone`, and **no `args` at all**. `ephemeralContainers` and
> `env`/`envFrom` were the same shape of hole and are closed in the same policy.
- `build-image.sh` — builds the above, **verifies the toolchain by running it** inside the
image as uid 65532 with `--read-only`, and prints the digest. It **never pushes**: it
prints the exact `podman push --digestfile …` command for the operator instead. See
`conformance/versions.env` `CONFORMANCE_RUNNER_IMAGE` for the post-push pin.
- `build-image.sh` — builds the above and **verifies the toolchain by running it**: the
Containerfile's `verify` stage executes every tool and every `conformance-clone` refusal as
uid 65532 with no network, at build time. It **never pushes**, and neither does anyone from a
workstation: CI (`.gitea/workflows/images.yaml`) builds the same Containerfile and `verify`
stage on rootless BuildKit (`platform/buildkit/`), and on a push to `main` publishes the
verified image under `sha-<commit>` and `main`. Renovate then moves
`CONFORMANCE_RUNNER_IMAGE_DIGEST` in `conformance/versions.env` onto the registry-returned
digest in a pull request (that file is a sensitive path, so the bump needs an
administrator's `/approve-override`).
`--verify-clone` additionally has `conformance-clone` really clone this repo at a real SHA
and asserts the checkout matches (opt-in, because it needs the Gitea host reachable — the
default verify stays offline so an air-gapped build is fully checked, not half-checked).
+51 -129
View File
@@ -1,43 +1,49 @@
#!/usr/bin/env bash
###############################################################################
# conformance/incluster/build-image.sh — build the pinned `conformance-runner`
# image and print its digest. Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1.
# image and prove it works. Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1.
#
# ./build-image.sh # build + verify + print the digest and push command
# ./build-image.sh # build the verify target, then the image
# ./build-image.sh --print-build-args # print KEY=VALUE build args (what CI consumes)
# ./build-image.sh --no-verify # build only (skip the in-image tool proof)
# ./build-image.sh --verify-clone # ALSO really clone this repo with conformance-clone.
# # Opt-in because it needs the Gitea host reachable; the
# # default verify is offline so an air-gapped build is
# # still fully checked rather than half-checked.
# ENGINE=docker ./build-image.sh # build with docker instead of podman
# IMAGE_TAG=foo ./build-image.sh # override the local tag (CI uses this)
#
# THIS SCRIPT NEVER PUSHES. Publishing is an operator action: the registry credential is
# not an agent's to hold, and the pushed digest is what a fail-closed admission policy
# then trusts. So this builds locally, proves the toolchain works INSIDE the image as the
# non-root uid the policy demands, and prints the exact `podman push` line for a human.
# THIS SCRIPT NEVER PUSHES — and nobody pushes this image from a workstation either.
# Publishing is done by CI: .gitea/workflows/images.yaml builds the SAME Containerfile on
# rootless BuildKit (platform/buildkit/), taking its build args from `--print-build-args`, and
# on a push to `main` pushes it to the Gitea registry and records the registry-returned digest
# in the job summary; Renovate then opens the pull request that moves conformance/versions.env
# onto that digest. The proof that the image works is the Containerfile's `verify` stage, which
# this script (podman locally) and CI both build.
#
# VERSIONS ARE NOT DEFINED HERE. They are read from infra/devcontainer/versions.env, the
# single source of truth (CHAINSAW_VERSION, KUBECTL_VERSION, FEDORA_VERSION) — the same
# pins the toolbox gate runs with. Nothing in this file invents a version.
#
# THE DIGEST CAVEAT — read it before pasting a digest into anything.
# The digest printed here is the LOCAL manifest digest. It is normally identical to the
# registry digest (podman pushes the manifest it already has rather than recompressing),
# but that is a property of the push, not a guarantee. So the push command below uses
# `--digestfile`: the digest the registry actually returns is the authoritative one, and
# that is the one that goes into conformance/versions.env.
# NO DIGEST IS PRINTED. A manifest digest does not exist until a registry holds the manifest,
# and the only digest anything may pin is the one the REGISTRY returns. CI captures that at
# push time (.gitea/workflows/scripts/images.sh) and Renovate carries it into
# conformance/versions.env.
###############################################################################
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${HERE}/../.." && pwd)"
ENGINE="${ENGINE:-podman}"
TAG_OVERRIDE="${IMAGE_TAG:-}"
VERIFY=1
VERIFY_CLONE=0
PRINT_ARGS=0
for arg in "$@"; do
case "$arg" in
--no-verify) VERIFY=0 ;;
--print-build-args) PRINT_ARGS=1 ;;
--verify-clone) VERIFY_CLONE=1 ;;
-h|--help) sed -n '2,/^####/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
@@ -48,8 +54,6 @@ log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGINE=docker to use docker)"
# --- pins: sourced, never invented -------------------------------------------
TOOLCHAIN_ENV="${REPO_ROOT}/infra/devcontainer/versions.env"
[ -f "${TOOLCHAIN_ENV}" ] || die "missing ${TOOLCHAIN_ENV} — the pins live there, not here"
@@ -59,14 +63,21 @@ source "${TOOLCHAIN_ENV}"
: "${KUBECTL_VERSION:?KUBECTL_VERSION not set in ${TOOLCHAIN_ENV}}"
: "${FEDORA_VERSION:?FEDORA_VERSION not set in ${TOOLCHAIN_ENV}}"
# THE build args, in ONE place; `--print-build-args` hands them to the CI pipeline, which builds
# this Containerfile on rootless BuildKit.
BUILD_ARGS=("CHAINSAW_VERSION=${CHAINSAW_VERSION}" "KUBECTL_VERSION=${KUBECTL_VERSION}" "FEDORA_VERSION=${FEDORA_VERSION}")
if [ "${PRINT_ARGS}" -eq 1 ]; then printf '%s\n' "${BUILD_ARGS[@]}"; exit 0; fi
command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGINE=docker to use docker)"
# --- image name: fixed by the admission policy -------------------------------
# platform/kyverno/admission/conformance-trigger-job-shape.yaml admits ONLY
# gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*. The repository path
# is therefore not a preference; a different path is a DENIED Job. Do not "improve" it here.
IMAGE_REPO="gitea.home.keithjackson.dev/home-network/conformance-runner"
# The tag is build convenience only (a human-readable handle for the push). Admission
# ignores it: only the digest is ever referenced from a Job.
IMAGE_TAG="chainsaw${CHAINSAW_VERSION}-kubectl${KUBECTL_VERSION}-fc${FEDORA_VERSION}"
# The tag is a local handle only. CI re-tags what it publishes (git SHA + `main`), and
# admission ignores tags entirely: only the digest is ever referenced from a Job.
IMAGE_TAG="${TAG_OVERRIDE:-chainsaw${CHAINSAW_VERSION}-kubectl${KUBECTL_VERSION}-fc${FEDORA_VERSION}}"
IMAGE_REF="${IMAGE_REPO}:${IMAGE_TAG}"
log "Building ${IMAGE_REF}"
@@ -76,86 +87,31 @@ info " CHAINSAW ${CHAINSAW_VERSION}"
info " KUBECTL ${KUBECTL_VERSION}"
info " FEDORA ${FEDORA_VERSION}"
"${ENGINE}" build \
--file "${HERE}/Containerfile" \
--tag "${IMAGE_REF}" \
--build-arg "CHAINSAW_VERSION=${CHAINSAW_VERSION}" \
--build-arg "KUBECTL_VERSION=${KUBECTL_VERSION}" \
--build-arg "FEDORA_VERSION=${FEDORA_VERSION}" \
"${HERE}"
# Provenance: CI passes the commit it is building, so a pulled image names its source tree.
LABEL_ARGS=()
[ -z "${BUILD_REVISION:-}" ] || LABEL_ARGS+=(--label "org.opencontainers.image.revision=${BUILD_REVISION}")
ARG_FLAGS=()
for a in "${BUILD_ARGS[@]}"; do ARG_FLAGS+=(--build-arg "$a"); done
# --- verify: the point of the whole exercise ---------------------------------
# The stock chainsaw image "had" chainsaw and still could not run ./run.sh. So prove the
# tools EXECUTE, and prove they execute as the NON-ROOT uid with a READ-ONLY root
# filesystem — the shape the admission policy forces. A pass here is the only evidence
# this image is usable; a build that merely succeeded is not.
# The stock chainsaw image "had" chainsaw and still could not run ./run.sh. So the proof is that
# the tools EXECUTE, as the NON-ROOT uid, with no network, that conformance-clone is not writable
# by that uid and refuses every hostile input, and that nothing outside /workspace is writable.
# That proof is the Containerfile's `verify` stage, built here and in CI alike (CI has no
# container runtime — rootless BuildKit only). See the stage's header for what a build step can
# and cannot reproduce of the Job's shape. A build that merely succeeded is not evidence.
if [ "${VERIFY}" -eq 1 ]; then
log "Verifying the toolchain inside the image (non-root, read-only rootfs)"
"${ENGINE}" run --rm \
--user 65532:65532 \
--read-only \
--tmpfs /workspace:rw,mode=1777 \
--entrypoint "" \
"${IMAGE_REF}" \
/bin/bash -euo pipefail -c '
echo "--- id ---"; id
echo "--- HOME ---"; echo "HOME=${HOME}"
echo "--- bash ---"; bash --version | head -1
echo "--- python3 ---"; python3 --version
echo "--- git ---"; git --version
echo "--- kubectl ---"; kubectl version --client
echo "--- chainsaw ---"; chainsaw version
echo "--- baked versions ---"; cat /etc/conformance-runner-versions
echo "--- conformance-clone: fixed path, root-owned, NOT writable by the runner ---"
stat -c "%n %U:%G %a" /usr/local/bin/conformance-clone
if [ -w /usr/local/bin/conformance-clone ]; then
echo "UNEXPECTED: uid $(id -u) can write the one executable the policy will trust"; exit 1
fi
echo "confirmed: not writable by uid $(id -u)"
# Each of these MUST fail. Assert the non-zero exit as well as the message: a check
# that only greps stderr would pass a build where the guard had stopped rejecting.
# (`|| true` inside the capture keeps `set -e`/`pipefail` from aborting on the
# expected failure — without it the verify dies at the first negative case.)
expect_fail() {
local label="$1"; shift
local out rc=0
out="$(env "$@" 2>&1)" || rc=$?
echo "--- ${label} ---"
printf "%s\n" "${out}" | head -2
if [ "${rc}" -eq 0 ]; then
echo "UNEXPECTED: that should have failed, but it exited 0"; exit 1
fi
echo "(rejected, exit ${rc})"
}
expect_fail "conformance-clone rejects arguments (that is where an injection would live)" \
conformance-clone --layer l2
expect_fail "conformance-clone rejects a missing SHA" \
conformance-clone
expect_fail "conformance-clone rejects a branch name instead of a SHA (no silent run of the tip)" \
CONFORMANCE_GIT_SHA=main conformance-clone
expect_fail "conformance-clone rejects an UNRENDERED placeholder" \
CONFORMANCE_GIT_SHA=@@CONFORMANCE_GIT_SHA@@ conformance-clone
expect_fail "conformance-clone rejects a HOME off the workspace volume" \
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
HOME=/etc conformance-clone
expect_fail "conformance-clone refuses a REDIRECTED repository URL (the env-injection hole)" \
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
CONFORMANCE_REPO_URL=https://evil.invalid/x.git conformance-clone
expect_fail "conformance-clone refuses a clone target off the workspace volume" \
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
CONFORMANCE_SRC=/etc/src conformance-clone
echo "--- rootfs is read-only (expected: a failure) ---"
if touch /should-fail 2>/dev/null; then echo "UNEXPECTED: rootfs is writable"; exit 1; fi
echo "confirmed: / is read-only"
echo "--- HOME is writable (the workspace emptyDir) ---"
touch "${HOME}/.probe" && echo "confirmed: ${HOME} is writable"
echo "--- uid is non-zero ---"
[ "$(id -u)" -ne 0 ] && echo "confirmed: running as uid $(id -u), not root"
'
log "Verifying the toolchain (Containerfile target: verify)"
"${ENGINE}" build "${ARG_FLAGS[@]}" --target verify --file "${HERE}/Containerfile" "${HERE}"
log "Toolchain verified"
fi
"${ENGINE}" build "${LABEL_ARGS[@]}" "${ARG_FLAGS[@]}" --target conformance-runner \
--file "${HERE}/Containerfile" --tag "${IMAGE_REF}" "${HERE}"
# --- verify the clone helper against the REAL repository (opt-in: needs network) ----
# LOCAL ONLY: this one `run`s the image, which needs a container engine, so CI does not do it.
# The negative cases above prove conformance-clone refuses bad input. This proves the
# positive case: it lands the EXACT commit asked for and can prove it did. Without this, the
# rev-parse assertion is only an unexercised line of shell.
@@ -184,46 +140,12 @@ if [ "${VERIFY_CLONE}" -eq 1 ]; then
log "conformance-clone verified against the real repository"
fi
# --- digest ------------------------------------------------------------------
DIGEST="$("${ENGINE}" image inspect --format '{{.Digest}}' "${IMAGE_REF}")"
# --- result ------------------------------------------------------------------
IMAGE_ID="$("${ENGINE}" image inspect --format '{{.Id}}' "${IMAGE_REF}")"
SIZE="$("${ENGINE}" image inspect --format '{{.Size}}' "${IMAGE_REF}")"
log "Built ${IMAGE_REF}"
info "local manifest digest ${DIGEST}"
info "image id ${IMAGE_ID}"
info "size $(( SIZE / 1024 / 1024 )) MiB"
cat <<EOF
================================================================================
NOT PUSHED. Publishing is an operator action — this script holds no registry
credential and will never acquire one.
OPERATOR: run these two commands, then paste the digest from the digestfile into
conformance/versions.env (CONFORMANCE_RUNNER_IMAGE) via a PR:
${ENGINE} login gitea.home.keithjackson.dev
${ENGINE} push --digestfile /tmp/conformance-runner.digest \\
${IMAGE_REF}
Then:
cat /tmp/conformance-runner.digest
# -> sha256:.... THIS is the authoritative digest, not the local one above.
# conformance/versions.env
CONFORMANCE_RUNNER_IMAGE="${IMAGE_REPO}@\$(cat /tmp/conformance-runner.digest)"
WHY THE DIGEST AND NOT THE TAG: platform/kyverno/admission/conformance-trigger-job-shape.yaml
is a fail-closed Enforce policy. It DENIES any conformance-trigger Job whose image is not
${IMAGE_REPO}@sha256:<digest>. A tag reference is denied,
and so is any other repository path.
TIGHTEN THE BOUNDARY AFTERWARDS: that policy's header asks for the wildcard
\`${IMAGE_REPO}@sha256:*\` to be replaced with the
LITERAL digest once the image exists, closing the "anyone who can push to this path" gap.
That is a separate, human-reviewed PR against the security boundary.
================================================================================
EOF
info "image id ${IMAGE_ID}"
info "size $(( SIZE / 1024 / 1024 )) MiB"
info "Not pushed. CI publishes this image from main (.gitea/workflows/images.yaml) and"
info "Renovate moves conformance/versions.env onto the registry-returned digest."
@@ -363,6 +363,54 @@ SCRAPE_OWNERS: list[dict] = [
"why": "velero_* powers VeleroBackupFailure / PartialFailure / NoRecentBackup — the rules "
"that failed to notice prod running with zero volume backups",
},
# ── ADDED 2026-10-17 (feat/automated-upgrades). A NEW OWNER, NOT A RELAXED ASSERTION. ──────
# This entry is purely additive: it registers a new metric family and its enablement, and it
# changes nothing about how any existing owner is evaluated. It makes the check STRICTER for the
# component it covers, not looser — it adds two `require` patterns plus a `companion` obligation
# that platform/kured must keep satisfying, so a future edit that disables kured's Service,
# ServiceMonitor or NetworkPolicy FAILS the gate instead of silently re-inerting three alerts.
#
# WHY THE CHECK HAD TO BE TOUCHED AT ALL, rather than the manifest being wrong: assertion (3)
# fails any alert over a metric no owner claims, and the escape hatch (KNOWN_UNWIRED_METRICS) is
# SHRINK-ONLY, so declaring `kured_*` as accepted debt is not permitted and would be the wrong
# answer anyway — the scrape genuinely IS wired here. The only two honest options were to
# register the owner (this) or to ship the reboot automation with no alerting at all, which for a
# component whose every failure mode is silent would have been the worse engineering choice.
# `conformance/**` is a sensitive approval path precisely so that this reasoning gets read.
{
"owner": "kured: service.create + metrics.create in the HelmRelease",
"prefixes": ("kured_",),
"file": "platform/kured/helmrelease.yaml",
# BOTH keys are required and the chart defaults BOTH to false. `metrics.create` makes the
# ServiceMonitor; `service.create` makes the Service it selects. Enable only the monitor and
# it selects nothing, so the single kured metric is never scraped — and a PrometheusRule over
# a series no target produces is INERT, which for this component means its own liveness
# alarm is the thing that breaks. Matched as two separate anchored blocks rather than one
# pattern because they are separate top-level values keys, not nested.
"require": [
r"service:\s*\n\s*create:\s*true",
r"metrics:\s*\n\s*create:\s*true",
],
"forbid": [],
"why": "kured_reboot_required is the ONLY metric kured exposes (one GaugeVec, "
"cmd/kured/main.go:92-96) and it powers all three platform-health.node-reboots "
"rules. Those rules are the only way to learn that OS reboot automation has stalled: "
"kured stays 1/1 Running while rebooting nothing, so a missed scrape here is "
"indistinguishable from a cluster that needed no reboots",
# The ServiceMonitor ALONE is not sufficient — the same NetworkPolicy trap Longhorn hit.
"companion": {
"file": "platform/kured/kustomization.yaml",
"require": [r"^\s*-\s*networkpolicy\.yaml\s*$"],
"why": "platform/kured/networkpolicy.yaml ships a default-deny baseline (the kured "
"namespace is a platform namespace, so Kyverno's app-namespace-scoped generator "
"does not cover it) AND the ingress allow that lets Prometheus reach :8080 "
"through it. Drop the networkpolicy from this kustomization and the deny-all "
"goes too, so this currently fails SAFE — but reinstating the baseline without "
"the allow reproduces Longhorn's measured failure exactly: targets DISCOVERED, "
"every scrape 'context deadline exceeded', TSDB empty, all three rules quiet. "
"Keeping the two in one file makes that pair inseparable",
},
},
]
# `up` is produced by EVERY scrape target, so no owner can meaningfully claim it and assertion (1)
@@ -29,7 +29,8 @@
# THEN (1) promtool is a PINNED member of the toolchain: PROMTOOL_VERSION is declared in BOTH
# infra/devcontainer/versions.env and ci/versions.env and the two AGREE (the repo's
# lockstep rule), the Containerfile takes it as a build ARG, installs it from the
# pinned prometheus release and records it for provenance, dev.sh passes it, and the
# pinned prometheus release and records it for provenance, build-image.sh (the one
# recipe `dev.sh build` and CI both use) passes it, and the
# CI runner installs it for the l0 stage;
# (2) the promtool ACTUALLY IN USE reports exactly that pinned version — a unit test is
# evidence only if the engine matches the one prod evaluates rules with;
@@ -246,10 +247,12 @@ check('echo "promtool=${PROMTOOL_VERSION}"' in cf_text,
"infra/devcontainer/Containerfile does not record promtool in /etc/dev-toolchain-versions — "
"that provenance line is how an operator tells a stale image from a current one")
DEVSH = ROOT / "infra/devcontainer/dev.sh"
check(DEVSH.is_file() and "--build-arg PROMTOOL_VERSION=" in DEVSH.read_text(),
"infra/devcontainer/dev.sh does not pass --build-arg PROMTOOL_VERSION — the ARG would be "
"empty and the build would fetch a bogus URL")
# The build recipe moved from dev.sh into build-image.sh on 2026-10-29, so that `dev.sh build`
# and the CI image pipeline (.gitea/workflows/images.yaml) share ONE recipe. Check the recipe.
BUILDSH = ROOT / "infra/devcontainer/build-image.sh"
check(BUILDSH.is_file() and '"PROMTOOL_VERSION=$PROMTOOL_VERSION"' in BUILDSH.read_text(),
"infra/devcontainer/build-image.sh does not pass PROMTOOL_VERSION in its BUILD_ARGS — the ARG "
"would be empty and the build would fetch a bogus URL")
INSTALL = ROOT / ".gitea/workflows/scripts/install-tools.sh"
if INSTALL.is_file():
@@ -445,8 +445,21 @@ join = re.search(r"--server https://\{\{\s*k3s_api_host", K3S_TEXT)
check(join is not None,
"roles/k3s_server does not join secondaries with `--server https://{{ k3s_api_host }}` "
"— without a join target the servers are separate single-node clusters")
join_when = re.search(r"--server https://.*?\n(?:.*\n){0,10}?\s*when:\s*k3s_server_mode\s*==\s*'secondary'",
K3S_TEXT, re.S)
# LINE-BOUNDED ON PURPOSE -- DO NOT "SIMPLIFY" BACK TO `.*` + re.S.
# This was `--server https://.*?\n(?:.*\n){0,10}?\s*when:...` with re.S, which made every `.`
# match newlines too. That is nested unbounded repetition (a `{0,10}` of a quantifier that can each
# swallow the whole document), so a FAILED or late match backtracks exponentially. It cost
# **19m54s of CPU in CI** (measured 2026-10-18 via `docker top` on the live job container) and made
# the whole merge gate take ~28 minutes instead of ~2 on any change touching `infra/**` -- because
# `ci/touched-requirements.sh` maps `infra/*` to R-ENV-01, which pulls in this whole-repo check.
# It was latent for as long as the regex existed and only crossed the threshold when
# roles/k3s_server grew by ~100 lines (the kubelet graceful-shutdown config, PR #71): K3S_TEXT is
# every *.yml in that role concatenated, so the role growing is the input growing.
# `[^\n]*` cannot cross a line, so the `{0,10}` is a genuine ten-line window with nothing to
# backtrack into. Measured on the same input: 0.2 ms, same verdict (match found).
join_when = re.search(
r"--server https://[^\n]*\n(?:[^\n]*\n){0,10}?[ \t]*when:[ \t]*k3s_server_mode[ \t]*==[ \t]*'secondary'",
K3S_TEXT)
check(join_when is not None,
"the join install task is not gated on k3s_server_mode == 'secondary' — the founder "
"would also try to join itself")
@@ -0,0 +1,108 @@
# =============================================================================
# L0 STATIC — the passkey configuration is derived from one declaration, continuously
# reconciled, and the browser login flow converges BLUE/GREEN: the BOUND flow is
# unwritable, and binding a new one is git's explicit decision (bind-browser-flow)
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
#
# R-IDP-03 (identity config is declarative) and R-IDP-04 (MFA available) are both
# implemented through a mechanism that FAILS OPEN: the KeycloakRealmImport is a ONE-SHOT.
# Measured on prod 2026-10-18, hours after the passkey declaration merged:
#
# kubectl -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}'
# Done=True Started=False HasErrors=False
#
# Once it rests at Done=True the operator never re-runs it, so the WebAuthn passwordless
# policy and the restructured `browser-mfa` flow that keycloak-realm-import.yaml declares
# are INERT — in git, applied by Flux, reported Ready, reaching the realm never. Merging
# them changed nothing about how anyone logs in, and nothing anywhere said so.
# Declarative config that silently does not apply is not declarative config.
#
# GIVEN the repository as authored,
# THEN the reconciler's desired state is GENERATED from the realm import (so the RP ID —
# which is baked into every passkey at enrolment and can NEVER be re-keyed — is
# declared exactly once); both files are rendered by the SAME Flux Kustomization, so
# their ${BASE_DOMAIN} token cannot resolve to two values; the renderer's exported
# policy keys and the reconciler's writable keys are the SAME tuple; the realm
# import declares NO requiredActions block (26.0.6 treats it as REPLACE, which would
# delete CONFIGURE_TOTP and lock out every platform-admin); the enrolment action is
# enabled but NOT a defaultAction, so enrolment stays opt-in; the reconciler carries
# no ServiceAccount token, mounts only what it needs, passes no mode flag, and
# contains no dollar sign; the endpoint allow-list contains NO delete, NO
# single-execution or config-edit write, and NO required-action write for any alias
# other than the one named literally; the protected-subtree guard exists, is wired
# into the single request choke point and is fail-closed; the SIBLING client-secret
# reconciler's allow-list is NOT widened; and the reconciler's own `--selftest`
# (extracted from the shipped ConfigMap and executed) proves compare-then-write,
# idempotence, strict read-modify-write, the RpEntityName gate, the bound flow and
# its sub-flows unwritable, the git bind switch and a rollback that sticks, the
# conditional-OTP regression and fail-closed — with mutations.py proving that
# selftest can FAIL.
#
# WHY THE FLOW IS BUILT BLUE/GREEN AND BOUND ONLY ON GIT'S SAY-SO, asserted here rather
# than only argued: converging a five-flow, ten-execution restructure needs
# create-subflow, create-execution, set-requirement and bind-config as SEPARATE admin API
# calls with no transaction. Doing that to the BOUND flow means a run that dies between
# them leaves a HALF-BUILT FLOW AS THE LOGIN PATH for Grafana, Gitea, Nextcloud, Headlamp,
# Semaphore, PiHole, Crafty, the *arr stack AND kubectl — this repo has had a total SSO
# outage from a realm change (2026-09-06), and the conditional-OTP sub-flow once 403'd
# every platform-admin. So every multi-call write lands on a NEW, unused candidate flow,
# which is verified by readback before anything is bound. Binding it is then ONE atomic
# PUT of browserFlow, made only when passkey-desired-state.yaml sets
# bind-browser-flow: "true" (default "false"): an untested login flow never goes live on
# a schedule tick, and with the switch off an operator's rollback is never undone.
#
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (git + PyYAML +
# stdlib); conformance/run.sh --layer l0 and ci/l0.sh run it directly, and this Chainsaw
# Test wraps the SAME script.
#
# NOT proven here: that the LIVE realm's policy, enrolment action or browser flow match
# git (that is cluster state — the reconciler's `--audit` mode is the read-only probe for
# it, and NO L2 test is added by this change, so the live realm is UNVERIFIED by CI);
# that a passkey ceremony actually completes or that the ALTERNATIVE fork degrades to the
# password form (needs a real browser against a real realm); that the five flow-building
# admin-API calls behave against a live Keycloak the way the 26.0.6 source says they do
# (every proof here is against an in-process stub); or that the CronJob's schedule fires.
# =============================================================================
apiVersion: chainsaw.kyverno.io/v1alpha1
kind: Test
metadata:
name: keycloak-passkey-reconciler
labels:
conformance.home.k3s/R-IDP-03: "true"
conformance.home.k3s/R-IDP-04: "true"
annotations:
conformance.home.k3s/requirements: "R-IDP-03, R-IDP-04"
conformance.home.k3s/layer: "L0"
# WHOLE-REPO SCOPE, for the same reason the sibling keycloak-client-secret-reconciler
# check declares it: this invariant spans platform/ (the realm import, the reconciler,
# the generated desired state), platform-config/ (which Flux Kustomization renders
# them, and therefore which substitution pass they share), ci/ (the renderer) and
# clusters/ (that both are reachable from both entrypoints). A change that breaks it
# need not attribute R-IDP-03/04 at all, and the requirement-scoped selection would
# then filter this check out of exactly the change that introduces the bug — which is
# how 12 unusable prod OIDC clients sat behind an entirely green cluster on
# 2026-09-06.
conformance.home.k3s/scope: "whole-repo"
# Not safety-critical in the harness's sense (it mutates nothing and needs no
# cluster), but it guards a component that holds WRITE access to the identity
# provider's realm settings.
conformance.home.k3s/safety-critical: "false"
conformance.home.k3s/executable: "true"
conformance.home.k3s/intent: >-
The realm's passkey configuration — policy, enrolment action AND browser login
flow — is declared once in the realm import, derived from it by a gated renderer,
and continuously reconciled; the flow converges blue/green, so a one-shot import
that never re-runs can neither leave the configuration inert nor let a reconciler
half-rewrite the login path of the whole platform.
spec:
steps:
- name: audit-passkey-declaration-renderer-and-reconciler
description: >-
Assert the desired state is derived from the realm import and shares its
substitution pass, that the reconciler cannot write the bound flow or delete
anything and binds only on git's bind-browser-flow, that the sibling reconciler
is unwidened, and run the reconciler's own selftest plus the mutation suite that
proves it can fail.
try:
- script:
content: bash ./check.sh
@@ -0,0 +1,754 @@
#!/usr/bin/env bash
# =============================================================================
# L0 static check (offline, no cluster) — keycloak-passkey-reconciler
# Serves: R-IDP-03 ("Identity config (realms, clients, groups, roles) is declarative."
# — spec docs/15-identity-and-access.md)
# R-IDP-04 ("IdP secrets SOPS-encrypted; MFA available."
# — spec docs/15-identity-and-access.md)
#
# WHY THIS EXISTS — THE REALM IMPORT IS A ONE-SHOT THAT FAILS OPEN
# ---------------------------------------------------------------
# platform/keycloak/keycloak-realm-import.yaml declares the WebAuthn PASSWORDLESS
# policy and a restructured `browser-mfa` browser flow. Measured on prod 2026-10-18,
# hours after that declaration merged:
#
# kubectl -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}'
# Done=True Started=False HasErrors=False
#
# A KeycloakRealmImport is a ONE-SHOT. Once it rests at Done=True the operator never
# re-runs it, so every realm-level field and every flow declared after the FIRST
# import is inert — in git, applied by Flux, reported Ready, reaching the realm
# never. The same shape as the 2026-09-06 client-secret incident (12 prod clients
# holding the literal `(env:...)` placeholder behind an entirely green cluster), and
# the same fix: a level-triggered reconciler. This check is the static half.
#
# THE RECONCILER HOLDS WRITE ACCESS TO THE IDENTITY PROVIDER. That is the reason this
# check is unusually thorough about its BLAST RADIUS rather than only its behaviour.
#
# GIVEN the repository as authored,
# THEN (1) THE DESIRED STATE IS DERIVED, NOT DECLARED TWICE: the reconciler's input
# (platform/keycloak/passkey-desired-state.yaml) is GENERATED from the realm
# import by ci/render-keycloak-passkey-policy.py, and this check RUNS the
# renderer with --check, so a hand edit that makes the two disagree is
# unmergeable. The RP ID is the sharpest case: it is baked into every passkey
# at enrolment and can NEVER be re-keyed, so two declarations that can skew
# about the domain is an unrecoverable-credential bug, not a cosmetic one.
# (2) BOTH FILES ARE RENDERED BY THE SAME FLUX KUSTOMIZATION.
# postBuild.substituteFrom is per-Kustomization, so a split would let the
# ${BASE_DOMAIN} token in the realm import and the one in the desired state
# resolve to two different values — and an RP ID mismatch invalidates every
# enrolled passkey with no error the user can act on.
# (3) THE DESIRED STATE USES NO SUBSTITUTION TOKEN THE REALM IMPORT DOES NOT, and
# declares no realm key outside the reconciler's write surface.
# (4) THE TWO WRITE SURFACES AGREE: the renderer's exported policy keys and the
# reconciler's POLICY_FIELDS are the SAME tuple. If they drifted, the renderer
# could emit a key the reconciler refuses (fail-closed forever) or the
# reconciler could accept a key the renderer never emits (dead surface).
# (5) THE RECONCILER MANIFEST IS SAFE AND WIRED: reconciled from BOTH cluster
# entrypoints, mounts exactly the ConfigMaps its code defaults to, carries NO
# ServiceAccount token, and contains no dollar sign (it ships inside a
# SUBSTITUTED Flux path, where an unescaped dollar-brace expansion is
# rewritten before any container runs).
# (6) THE CURRENTLY BOUND FLOW IS STRUCTURALLY UNWRITABLE — asserted against the
# SOURCE, not the comments. The flow converges BLUE/GREEN (build under a new
# alias, verify, flip browserFlow in one atomic PUT), so flow-create and
# execution-add endpoints ARE on the allow-list and their presence is not the
# hazard. What is asserted instead: nothing is DELETABLE (the superseded flow
# is the rollback target); the execution- and config-id-scoped write endpoints
# stay off the list because they carry no flow alias and so cannot be screened;
# no required-action write reaches any alias but the one named literally; and
# the guard the allow-list CANNOT express exists in the source and is wired
# into the single request choke point, FAIL-CLOSED until the live binding has
# been read. That last one is what the whole design rests on: a reconciler that
# half-rewrites the BOUND browser flow is a total SSO outage on a loop, and
# this repo has had one (2026-09-06).
# (6b) THE BIND IS GIT'S DECISION: the desired-state ConfigMap carries
# bind-browser-flow as exactly "true" or "false", the script reads it from the
# SAME mount, and the CronJob passes no flag that could widen it. With "false"
# (the default) the reconciler builds and verifies but never PUTs browserFlow,
# so an operator's rollback sticks; the selftest proves that end to end.
# (7) THE SIBLING RECONCILER IS UNTOUCHED: client-secret-reconciler.yaml's
# allow-list is NOT widened by this work. Its --selftest asserts it refuses
# /users, /groups, /roles, /authentication and the whole-realm endpoint, and
# its guarantee that prod's real user and the media-users / platform-admins /
# viewers groups survive every run must stay LITERALLY true.
# (8) THE RECONCILER'S BEHAVIOUR IS PROVEN, NOT ASSERTED: this check EXTRACTS the
# script from the ConfigMap and executes its `--selftest`, which drives the
# real code paths against an in-process stub Keycloak that MODELS the three
# Keycloak 26.0.6 behaviours that bite — the RpEntityName gate on the
# passwordless policy, the all-or-nothing policy rebuild that defaults every
# omitted key, and the required-action representation replace.
# (8b) AND THAT PROOF IS FALSIFIABLE: mutations.py injects plausible regressions
# one at a time and requires --selftest to CATCH every one. A passing suite
# proves nothing if it cannot fail, and "reported success while the realm was
# broken" is the exact failure this component exists to end.
# AND (9) THIS CHECK IS LOAD-BEARING: it DECLARES
# `conformance.home.k3s/scope: whole-repo` so ci/l0.sh runs it unconditionally
# via --include-scope, ci/gate.sh blocks the merge when the l0 stage fails, and
# ci/touched-requirements.sh maps platform/keycloak/ to these requirement IDs.
# An unreferenced test is precisely the "looks applied and isn't" failure this
# repo keeps hitting.
#
# NOT proven here (named honestly, in the style of the sibling L0 checks):
# - that the LIVE realm's policy, enrolment action or browser flow match git. That is
# cluster state. The reconciler's own `--audit` mode is the read-only probe for it
# and is what an L2 test should run; no such L2 test is added by this change, so the
# live realm is UNVERIFIED by CI.
# - that the declared browser flow actually WORKS as a login path. Nothing offline can
# prove that a passkey ceremony completes, that the ALTERNATIVE fork degrades to the
# password form, or that a platform-admin is still asked for TOTP. Those need a real
# browser against a real realm.
# - that the reconciler's first real run against prod will succeed. The flow is built
# through five admin-API endpoints whose request shapes were read from the 26.0.6
# source but never exercised against a live Keycloak.
# - that the admin credential decrypts to a usable password. Key PRESENCE is checkable
# offline; the plaintext is not, and must not be.
# - that the CronJob's image can be pulled or its schedule fires.
# =============================================================================
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
PYTHON="${PYTHON:-python3}"
IMPORT_FILE="platform/keycloak/keycloak-realm-import.yaml"
RECON_FILE="platform/keycloak/passkey-reconciler.yaml"
STATE_FILE="platform/keycloak/passkey-desired-state.yaml"
RENDERER="ci/render-keycloak-passkey-policy.py"
SIBLING_FILE="platform/keycloak/client-secret-reconciler.yaml"
# Honest SKIP (never green) if the thing under test is absent entirely.
for f in "$IMPORT_FILE" "$RECON_FILE" "$STATE_FILE" "$RENDERER"; do
if [ ! -f "${ROOT}/${f}" ]; then
echo "check SKIPPED: ${f} absent — nothing to audit" >&2
exit 3
fi
done
cd "${ROOT}"
"${PYTHON}" - "${ROOT}" <<'PY'
"""Static audit of the Keycloak passkey declaration, the renderer that derives the
reconciler's desired state from it, and the reconciler's blast radius. PyYAML parses the
manifests; lib/status.py is IMPORTED for the Flux graph walk rather than forked, so there
is one definition of "what a Flux path renders" in the harness."""
from __future__ import annotations
import ast
import json
import re
import subprocess
import sys
import tempfile
from pathlib import Path
import yaml
ROOT = Path(sys.argv[1])
# This body is fed to `python3 -` on stdin, so there is no __file__ to derive from; the
# test directory is named relative to the repo root the wrapper passes in.
DIR_PATH = (ROOT / "conformance" / "tests" / "l0-static"
/ "keycloak-passkey-reconciler")
sys.path.insert(0, str(ROOT / "conformance" / "lib"))
import status as st # noqa: E402
IMPORT_FILE = "platform/keycloak/keycloak-realm-import.yaml"
RECON_FILE = "platform/keycloak/passkey-reconciler.yaml"
STATE_FILE = "platform/keycloak/passkey-desired-state.yaml"
RENDERER = "ci/render-keycloak-passkey-policy.py"
SIBLING_FILE = "platform/keycloak/client-secret-reconciler.yaml"
STATE_CM_NAME = "keycloak-passkey-desired-state"
STATE_DATA_KEY = "passkey-desired-state.json"
BIND_KEY = "bind-browser-flow"
SCRIPT_KEY = "reconcile-keycloak-passkeys.py"
ENROL_ACTION = "webauthn-register-passwordless"
DOLLAR = chr(36)
failures: list[str] = []
def check(cond, msg):
if not cond:
failures.append(msg)
def load(rel):
return list(yaml.safe_load_all((ROOT / rel).read_text()))
# ================================================== (1) the desired state is DERIVED
proc = subprocess.run([sys.executable, str(ROOT / RENDERER), "--check"],
capture_output=True, text=True, timeout=120)
check(proc.returncode == 0,
f"{STATE_FILE} has DRIFTED from {IMPORT_FILE} (ci/render-keycloak-passkey-policy"
f".py --check failed). The reconciler would converge the realm on a passkey policy "
f"the realm import does not declare, and audit the login flow against a tree "
f"nobody authored. The RP ID is the sharpest case: it is baked into every passkey "
f"at enrolment and can NEVER be re-keyed, so two declarations that skew about the "
f"domain is an unrecoverable-credential bug.\n "
+ "\n ".join((proc.stdout + proc.stderr).strip().splitlines()[-12:]))
# The realm import must actually declare the things the renderer derives from. If it
# stops, the renderer fails -- but say so HERE too, because a SKIP-shaped failure in the
# renderer would otherwise be the only signal.
imp_docs = [d for d in load(IMPORT_FILE) if d]
imp = next((d for d in imp_docs if d.get("kind") == "KeycloakRealmImport"), None)
check(imp is not None, f"{IMPORT_FILE}: no KeycloakRealmImport document")
realm = ((imp or {}).get("spec") or {}).get("realm") or {}
policy_keys_in_import = sorted(k for k in realm
if k.startswith("webAuthnPolicyPasswordless"))
check(policy_keys_in_import,
f"{IMPORT_FILE} declares no webAuthnPolicyPasswordless* field, so there is no "
f"passkey policy for the reconciler to converge")
check(realm.get("browserFlow"),
f"{IMPORT_FILE} declares no spec.realm.browserFlow — a perfect flow that is not "
f"BOUND changes nothing about how anyone logs in")
# THE requiredActions TRAP, enforced rather than only commented. The realm import's own
# header explains it at length: Keycloak 26.0.6 treats requiredActions in a realm
# representation as REPLACE, not merge, so declaring ONE action imports exactly that
# action plus delete_account and skips DefaultRequiredActions.addActions entirely --
# deleting CONFIGURE_TOTP, UPDATE_PASSWORD, VERIFY_EMAIL and seven more. Losing
# CONFIGURE_TOTP is a hard lock-out of every platform-admin, because auth-otp-form in the
# browser flow still demands a TOTP they could then never enrol. The comment saying "DO
# NOT ADD ONE" is advice; this is the gate.
check("requiredActions" not in realm,
f"{IMPORT_FILE} declares spec.realm.requiredActions. Keycloak 26.0.6 treats that "
f"as REPLACE, not merge: declaring any action imports exactly those plus "
f"delete_account and NEVER runs DefaultRequiredActions.addActions, so "
f"CONFIGURE_TOTP, UPDATE_PASSWORD, VERIFY_EMAIL and seven more are DELETED. That "
f"is a permanent lock-out of every platform-admin, because auth-otp-form in this "
f"realm's browser flow still demands a TOTP they could no longer enrol. The "
f"enrolment action is already registered by default (priority 80, enabled, "
f"defaultAction false) and the passkey reconciler converges it through the "
f"per-alias admin API, which has no such hazard")
# ============================== (2)(3) the two files share one substitution pass
state_docs = [d for d in load(STATE_FILE) if d]
state_cm = next((d for d in state_docs if d.get("kind") == "ConfigMap"), None)
check(state_cm is not None, f"{STATE_FILE}: no ConfigMap")
desired = {}
if state_cm is not None:
check(state_cm["metadata"]["name"] == STATE_CM_NAME,
f"{STATE_FILE}: the ConfigMap is named "
f"{state_cm['metadata']['name']!r}, but the CronJob mounts {STATE_CM_NAME!r}")
data = state_cm.get("data") or {}
check(sorted(data) == sorted([STATE_DATA_KEY, BIND_KEY]),
f"{STATE_FILE}: expected exactly the data keys {STATE_DATA_KEY!r} and "
f"{BIND_KEY!r}, got {sorted(data)} — the reconciler's PASSKEY_STATE_FILE / "
f"PASSKEY_BIND_FILE defaults would not resolve")
# THE BIND SWITCH. An unquoted true/false is a YAML BOOLEAN, which a ConfigMap rejects
# at apply time -- failing the whole platform-config Kustomization, realm import
# included. The renderer refuses it too; asserted here so the reason is on record.
check(data.get(BIND_KEY) in ("true", "false"),
f"{STATE_FILE}: data.{BIND_KEY} is {data.get(BIND_KEY)!r}; it must be the QUOTED "
f"string \"true\" or \"false\"")
raw = data.get(STATE_DATA_KEY, "")
try:
desired = json.loads(raw)
except ValueError as e:
check(False, f"{STATE_FILE}: the {STATE_DATA_KEY} payload is not valid JSON "
f"({e}) — the reconciler would fail closed on every run")
# Both files must be rendered by ONE Flux Kustomization. substituteFrom is
# per-Kustomization, so a split lets the same ${BASE_DOMAIN} token resolve to two
# different values -- and an RP ID mismatch invalidates every enrolled passkey silently.
import_owners = set()
state_owners = set()
recon_owners = set()
for env in ("dev", "prod"):
entry = f"clusters/{env}"
if not (ROOT / entry).is_dir():
continue
applied, _ = st.reconciled_files(ROOT, entry)
check(RECON_FILE in applied,
f"{RECON_FILE} is not reachable from the {entry} entrypoint — the passkey "
f"healer would exist on one cluster only")
check(STATE_FILE in applied,
f"{STATE_FILE} is not reachable from the {entry} entrypoint — the reconciler "
f"would fail closed on that cluster forever")
check(IMPORT_FILE in applied, f"{IMPORT_FILE} is not reachable from {entry}")
# Which Kustomization DIRECTORY lists each file. Asserted structurally: both must be
# listed as resources of the same kustomization.yaml.
for kpath in ROOT.glob("*/kustomization.yaml"):
try:
k = yaml.safe_load(kpath.read_text()) or {}
except Exception:
continue
res = [str(r) for r in (k.get("resources") or [])]
base = kpath.parent.name
if any(r.endswith("keycloak-realm-import.yaml") for r in res):
import_owners.add(base)
if any(r.endswith("passkey-desired-state.yaml") for r in res):
state_owners.add(base)
if any(r.endswith("passkey-reconciler.yaml") for r in res):
recon_owners.add(base)
check(import_owners and import_owners == state_owners,
f"{IMPORT_FILE} is rendered by {sorted(import_owners)} but {STATE_FILE} by "
f"{sorted(state_owners)}. They MUST be the same Flux Kustomization: "
f"postBuild.substituteFrom is per-Kustomization, so a split would let the "
f"{DOLLAR}{{BASE_DOMAIN}} token in each resolve to a different value. An RP ID is "
f"baked into every passkey at enrolment and can never be re-keyed, so a domain the "
f"two files disagree about is an unrecoverable credential, not a cosmetic drift")
check(recon_owners == state_owners,
f"{RECON_FILE} is rendered by {sorted(recon_owners)} but its desired state by "
f"{sorted(state_owners)} — the reconciler could be applied without its input")
# (3) no substitution token the realm definition does not also use.
import_text = (ROOT / IMPORT_FILE).read_text()
TOKEN = re.compile(re.escape(DOLLAR) + r"\{([A-Za-z_][A-Za-z0-9_]*)")
state_tokens = set(TOKEN.findall((ROOT / STATE_FILE).read_text()))
import_tokens = set(TOKEN.findall(import_text))
# The renderer's own HEADER mentions ${BASE_DOMAIN} in prose; that is the same token the
# policy uses, so a subset check is the right assertion rather than an exact match.
check(state_tokens <= import_tokens,
f"{STATE_FILE} uses substitution token(s) {sorted(state_tokens - import_tokens)} "
f"that {IMPORT_FILE} does not. A token the realm definition never uses is either a "
f"hand edit or a renderer bug, and an UNDEFINED one renders as the EMPTY STRING "
f"silently — which for an RP ID means every passkey ceremony fails")
# ======================================= (4) the two write surfaces are the SAME tuple
recon_docs = [d for d in load(RECON_FILE) if d]
cm = next((d for d in recon_docs if d.get("kind") == "ConfigMap"), None)
cj = next((d for d in recon_docs if d.get("kind") == "CronJob"), None)
check(cm is not None, f"{RECON_FILE}: no ConfigMap carrying the reconciler script")
check(cj is not None, f"{RECON_FILE}: no CronJob running the reconciler")
script = ""
if cm is not None:
data = cm.get("data") or {}
check(len(data) == 1,
f"{RECON_FILE}: the reconciler ConfigMap must carry exactly one script key "
f"(found {sorted(data)}) — a second copy would be untested by this gate")
check(SCRIPT_KEY in data,
f"{RECON_FILE}: expected the script under {SCRIPT_KEY!r}, got {sorted(data)}")
script = next(iter(data.values()), "")
check(len(script.splitlines()) > 200,
f"{RECON_FILE}: the extracted script is implausibly short "
f"({len(script.splitlines())} lines) — the ConfigMap shape changed and the "
f"selftest below would prove nothing")
# THE nextcloud TRAP. This file is rendered by a Flux Kustomization with
# postBuild.substituteFrom, so envsubst rewrites it before any container runs.
check(DOLLAR not in script,
f"{RECON_FILE}: the reconciler script contains a dollar sign. It ships inside "
f"a SUBSTITUTED Flux path, where a dollar-brace expansion is rewritten at "
f"reconcile time (see apps/wave-1/nextcloud.yaml). Rewrite without it — Python "
f"never needs one; chr(36) spells it where a literal is genuinely required")
def tuple_literal(src, name):
"""The value of a module-level tuple assignment, via ast. Parsing the SOURCE rather
than importing it keeps this check offline and side-effect free, and means the thing
asserted is the text that ships."""
try:
tree = ast.parse(src)
except SyntaxError:
return None
for node in tree.body:
if isinstance(node, ast.Assign):
for t in node.targets:
if isinstance(t, ast.Name) and t.id == name:
try:
return ast.literal_eval(node.value)
except ValueError:
return None
return None
renderer_src = (ROOT / RENDERER).read_text()
# POLICY_FIELDS is a CONCATENATION of other tuples in both files, so literal_eval of the
# assignment itself cannot evaluate it. Resolve it from its parts instead. Both files name
# their parts from the same vocabulary; a part present in one and not the other simply
# contributes nothing, which is why the comparison below is of the resolved SETS.
def resolve_policy(src):
parts = ("POLICY_STR_FIELDS", "POLICY_ORDERED_LIST_FIELDS",
"POLICY_UNORDERED_LIST_FIELDS", "POLICY_LIST_FIELDS", "POLICY_INT_FIELDS",
"POLICY_BOOL_FIELDS")
out = []
for p in parts:
v = tuple_literal(src, p)
if v:
out.extend(v)
return sorted(set(out))
recon_keys = resolve_policy(script)
rend_keys = resolve_policy(renderer_src)
check(recon_keys and recon_keys == rend_keys,
f"the reconciler's writable policy keys {recon_keys} and the renderer's exported "
f"keys {rend_keys} DISAGREE. If the renderer emits a key the reconciler refuses, "
f"every run fails closed forever; if the reconciler accepts a key the renderer "
f"never emits, that is dead write surface on the identity provider")
# Every exported key must actually be a passwordless key. The two-factor
# webAuthnPolicy* family is a silent no-op for passkeys (the passwordless authenticator
# reads getWebAuthnPolicyPasswordless() only), so exporting one would be a write surface
# with no effect.
check(all(k.startswith("webAuthnPolicyPasswordless") for k in recon_keys),
f"the reconciler's writable keys include a non-passwordless field: "
f"{[k for k in recon_keys if not k.startswith('webAuthnPolicyPasswordless')]}. "
f"The two-factor webAuthnPolicy* family is never consulted by a passkey login, so "
f"writing it is a silent no-op")
# And the desired state must declare only those.
if isinstance(desired.get("webAuthnPolicyPasswordless"), dict):
extra = sorted(set(desired["webAuthnPolicyPasswordless"]) - set(recon_keys))
check(not extra,
f"{STATE_FILE} declares policy key(s) {extra} outside the reconciler's write "
f"surface — it would fail closed on every run")
# The 26.0.6 gate: the policy is applied ONLY when RpEntityName is non-empty.
check(desired["webAuthnPolicyPasswordless"].get(
"webAuthnPolicyPasswordlessRpEntityName"),
f"{STATE_FILE} carries no non-empty "
f"webAuthnPolicyPasswordlessRpEntityName. Keycloak 26.0.6 applies the "
f"passwordless policy only when that field is set "
f"(DefaultExportImportManager.updateRealm), so every realm PUT would be "
f"accepted with 204 and change nothing")
# THE ENROLMENT POSTURE. defaultAction true would force every user to enrol a passkey at
# next login -- a platform-wide lock-out risk, and the exact opposite of the realm
# import's stated "additive and optional" decision. It is one boolean, so it is gated.
ra = desired.get("requiredAction") or {}
check(ra.get("alias") == ENROL_ACTION,
f"{STATE_FILE}: requiredAction.alias is {ra.get('alias')!r}, expected "
f"{ENROL_ACTION!r} — the reconciler's allow-list names that alias literally, so "
f"any other value is unreachable and the pass would fail closed")
check(ra.get("enabled") is True,
f"{STATE_FILE}: requiredAction.enabled must be true, else passkey ENROLMENT is "
f"impossible while the login flow offers passkeys")
check(ra.get("defaultAction") is False,
f"{STATE_FILE}: requiredAction.defaultAction is {ra.get('defaultAction')!r}, which "
f"must be false. True forces EVERY user to enrol a passkey at next login — this "
f"IdP gates Grafana, Gitea, Nextcloud, Headlamp, Semaphore, PiHole, Crafty, the "
f"*arr stack AND kubectl, so a misfiring mandatory enrolment is a total platform "
f"lock-out. The realm import's whole stated posture is additive and OPTIONAL")
# ========================================= (5) the reconciler manifest is safe + wired
if cj is not None:
pod = cj["spec"]["jobTemplate"]["spec"]["template"]["spec"]
ctr = pod["containers"][0]
vols = {v["name"]: v for v in pod.get("volumes", [])}
mounts = {m["name"]: m for m in ctr.get("volumeMounts", [])}
env = {e["name"]: e for e in ctr.get("env", [])}
# NO KUBERNETES API ACCESS. This is what makes the mounted-ConfigMap design strictly
# narrower than reading the KeycloakRealmImport CR through the API: no Role, no
# RoleBinding, no token. It is also why this change adds no **/rbac.yaml.
check(pod.get("automountServiceAccountToken") is False,
f"{RECON_FILE}: the reconciler must set automountServiceAccountToken: false. "
f"It talks only to the Keycloak admin API and reads a mounted ConfigMap, so a "
f"ServiceAccount token is pure blast radius (R-SEC-02)")
check("serviceAccountName" not in pod,
f"{RECON_FILE}: the reconciler must not name a ServiceAccount")
cm_vols = {n: v for n, v in vols.items() if "configMap" in v}
names = {(v["configMap"] or {}).get("name") for v in cm_vols.values()}
check(STATE_CM_NAME in names,
f"{RECON_FILE}: the CronJob does not mount the {STATE_CM_NAME} ConfigMap, so "
f"the reconciler would fail closed on every run. Mounted: {sorted(names)}")
check(cm["metadata"]["name"] in names,
f"{RECON_FILE}: the CronJob does not mount its own script ConfigMap")
# NO SECRET IS MOUNTED AS A VOLUME. The admin credential is consumed via
# secretKeyRef only; projecting it as a file would widen what a readable mount leaks.
check(not [v for v in vols.values() if "secret" in v],
f"{RECON_FILE}: the reconciler mounts a Secret as a volume. The admin "
f"credential is referenced by secretKeyRef only; it must never be projected "
f"as a file here")
# The mount paths must be the ones the code DEFAULTS to, or the container starts and
# fails closed forever while every manifest looks correct.
state_vol = next((n for n, v in cm_vols.items()
if (v["configMap"] or {}).get("name") == STATE_CM_NAME), None)
if state_vol:
mp = (mounts.get(state_vol) or {}).get("mountPath")
check(mp == "/etc/keycloak-passkey",
f"{RECON_FILE}: the desired-state ConfigMap is mounted at {mp!r}, but the "
f"script's PASSKEY_STATE_FILE default is "
f"/etc/keycloak-passkey/{STATE_DATA_KEY}")
check(f'"/etc/keycloak-passkey/{STATE_DATA_KEY}"' in script,
f"{RECON_FILE}: the script's PASSKEY_STATE_FILE default no longer matches "
f"the mount path — they are a pair and must move together")
check((mounts.get(state_vol) or {}).get("readOnly") is True,
f"{RECON_FILE}: the desired-state mount must be readOnly")
check(f'"/etc/keycloak-passkey/{BIND_KEY}"' in script,
f"{RECON_FILE}: the script's PASSKEY_BIND_FILE default is not "
f"/etc/keycloak-passkey/{BIND_KEY}. An unresolved bind file reads as \"false\" "
f"(safe, but git's \"true\" would then silently never take effect)")
check(not (state_vol and (cm_vols[state_vol]["configMap"] or {}).get("items")),
f"{RECON_FILE}: the desired-state volume selects `items`, so one of its two "
f"keys may not be projected")
# The bind decision belongs to git. A container arg could only NARROW it today
# (--no-bind), but nothing in the CronJob may carry a mode flag at all: the shipped
# behaviour must be readable from passkey-desired-state.yaml alone.
cmd = [str(x) for x in (ctr.get("command") or []) + (ctr.get("args") or [])]
check(not [x for x in cmd if x.startswith("--")],
f"{RECON_FILE}: the CronJob passes {[x for x in cmd if x.startswith('--')]} to "
f"the reconciler. Its mode is decided by git (bind-browser-flow), not by flags")
# Retries burn the bounded candidate-alias budget on a deterministic failure.
check(cj["spec"]["jobTemplate"]["spec"].get("backoffLimit") == 0,
f"{RECON_FILE}: backoffLimit must be 0 — every failure here is deterministic "
f"within a tick, and each retried build consumes another candidate alias")
# The admin credential, referenced BY NAME. Never decrypted, never a literal.
for key in ("KEYCLOAK_ADMIN_USER", "KEYCLOAK_ADMIN_PASSWORD"):
e = env.get(key) or {}
ref = ((e.get("valueFrom") or {}).get("secretKeyRef") or {})
check(ref.get("name") == "keycloak-admin-credentials",
f"{RECON_FILE}: {key} must come from the keycloak-admin-credentials "
f"Secret by secretKeyRef, got {e!r}")
check("value" not in e,
f"{RECON_FILE}: {key} carries a literal `value`. A credential in a "
f"manifest is a plaintext secret in git")
check((env.get("KEYCLOAK_REALM") or {}).get("value") == desired.get("realm"),
f"{RECON_FILE}: KEYCLOAK_REALM is "
f"{(env.get('KEYCLOAK_REALM') or {}).get('value')!r} but the desired state "
f"declares realm {desired.get('realm')!r}. The reconciler would converge the "
f"wrong realm, or none")
check(ctr["securityContext"]["readOnlyRootFilesystem"] is True,
f"{RECON_FILE}: readOnlyRootFilesystem must be true")
check(ctr["securityContext"]["allowPrivilegeEscalation"] is False,
f"{RECON_FILE}: allowPrivilegeEscalation must be false")
check(pod["securityContext"].get("runAsNonRoot") is True,
f"{RECON_FILE}: runAsNonRoot must be true")
check(cj["spec"].get("concurrencyPolicy") == "Forbid",
f"{RECON_FILE}: concurrencyPolicy must be Forbid — two concurrent reconcilers "
f"would double the write attempts against the IdP during an outage recovery")
check(re.fullmatch(r"\S+ \S+ \S+ \S+ \S+", cj["spec"].get("schedule", "")),
f"{RECON_FILE}: schedule {cj['spec'].get('schedule')!r} is not a 5-field cron")
# ===== (6) THE BOUND FLOW IS STRUCTURALLY UNWRITABLE — asserted against the SOURCE
#
# This component converges the browser flow BLUE/GREEN: it builds the target structure
# under a NEW, unused alias, verifies it, then flips the realm's browserFlow in ONE
# atomic PUT. It therefore NEEDS flow-create and execution-add endpoints, so unlike the
# first design of this check the mere presence of a flow write is no longer the thing to
# forbid. What must hold instead is narrower and stronger:
#
# (a) nothing may be DELETED, ever — the superseded flow is the rollback target;
# (b) the single-execution and config-edit endpoints stay off the list — requirements
# are set through the flow-scoped endpoint, which the guard in (d) screens;
# (c) no required-action write may reach any alias but the one named LITERALLY;
# (d) and the guard the allow-list CANNOT express must exist in the source: a
# method-aware screen that refuses any flow write targeting the CURRENTLY BOUND
# flow's subtree, FAIL-CLOSED until that binding has been read.
#
# (d) is the load-bearing one. Without it the reconciler could mutate the flow users are
# logging in through, and a run dying mid-restructure would leave a HALF-BUILT FLOW BOUND
# AS THE LOGIN PATH for Grafana, Gitea, Nextcloud, Headlamp, Semaphore, PiHole, Crafty,
# the *arr stack AND kubectl. This repo has already had a total SSO outage from a realm
# change (2026-09-06).
allow = tuple_literal(script, "_ALLOWED") or ()
check(bool(allow),
f"{RECON_FILE}: could not parse the _ALLOWED endpoint allow-list out of the "
f"script. This check's central assertions about the write surface cannot be made, "
f"so it must FAIL rather than pass silently")
if allow:
check(all(isinstance(e, tuple) and len(e) == 2 for e in allow),
f"{RECON_FILE}: _ALLOWED is not a tuple of (method, path) pairs. A path-only "
f"allow-list cannot distinguish reading a flow from writing one, which this "
f"component's whole safety argument rests on")
writes = [(m, p) for m, p in allow if m in ("PUT", "POST", "PATCH", "DELETE")]
# (a) nothing is ever deleted.
check(not [(m, p) for m, p in allow if m == "DELETE"],
f"{RECON_FILE}: the allow-list contains a DELETE method. Nothing this program "
f"does requires deleting anything: a superseded flow is left unbound as the "
f"ROLLBACK TARGET, and DELETE /admin/realms/<realm> deletes the realm")
# (b) the endpoints that would let it edit live objects in place.
banned = [(m, p) for m, p in writes
if re.fullmatch(r"/admin/realms/\[\^/\]\+/authentication/executions/"
r"\[\^/\]\+", p)
or re.fullmatch(r"/admin/realms/\[\^/\]\+/authentication/config/\[\^/\]\+",
p)
or p.endswith("/authentication/register-required-action")]
check(not banned,
f"{RECON_FILE}: the allow-list permits {banned}. The single-execution update "
f"and config-edit endpoints must stay off it: requirements are set through the "
f"FLOW-SCOPED endpoint, which carries the flow alias in its path and is "
f"therefore screenable against the bound subtree. An execution- or "
f"config-id-scoped write is not, so it would be an unscreened hole")
# (c) no required-action write may reach any other alias.
ra_writes = [(m, p) for m, p in writes if "required-actions" in p]
check(all(p.endswith("required-actions/" + ENROL_ACTION) for _, p in ra_writes),
f"{RECON_FILE}: the allow-list permits a required-action write to an alias "
f"other than {ENROL_ACTION!r}: {ra_writes}. Disabling CONFIGURE_TOTP is a "
f"permanent lock-out of every platform-admin, because auth-otp-form in the "
f"browser flow still demands a TOTP that could no longer be enrolled")
check(not [p for _, p in ra_writes if "[^/]+" in p.split("required-actions/")[-1]],
f"{RECON_FILE}: a required-action write path uses a WILDCARD alias. The alias "
f"must be named LITERALLY so no other action is writable by construction")
# Nothing outside the realm + authentication surface at all.
stray = [(m, p) for m, p in allow
if "/users" in p or "/groups" in p or "/roles" in p or "/clients" in p]
check(not stray,
f"{RECON_FILE}: the allow-list reaches {stray}. Users, groups and roles must "
f"be unreachable (prod's real user and the media-users / platform-admins / "
f"viewers groups must survive every run); clients are the SIBLING "
f"reconciler's surface and the two must not overlap")
# ---- (d) THE PROTECTED-SUBTREE GUARD EXISTS, IS WIRED, AND IS FAIL-CLOSED ----------
# Asserted against the SOURCE, because this is the property the blue/green design rests
# on and a comment claiming it is worth nothing. Three things must all be true.
check(re.search(r"^def _screen_write\(", script, re.M) is not None,
f"{RECON_FILE}: the script has no _screen_write() function. That is the guard "
f"that refuses any flow write targeting the CURRENTLY BOUND flow's subtree — the "
f"one thing the allow-list cannot express, because its write entries are "
f"alias-parameterised. Without it this component can mutate the live login path")
# It must be CALLED from the single choke point, not merely defined.
check(re.search(r"_screen_write\(method, path", script) is not None,
f"{RECON_FILE}: _screen_write() is defined but never called from _http() with the "
f"request's method and path. A guard that is not on the request path is decoration")
# FAIL-CLOSED: the protected sets must start undetermined, and the reset that runs at
# the top of every run must put them back there.
reset_fn = re.search(r"def _reset_protected\(\):(.*?)(?=\n\S)", script, re.S)
check(reset_fn is not None
and "_PROTECTED_FLOW_ALIASES = None" in reset_fn.group(1)
and "_PROTECTED_EXECUTION_IDS = None" in reset_fn.group(1),
f"{RECON_FILE}: _reset_protected() does not set both protected sets to None. They "
f"must start each run UNDETERMINED, because while they are, every flow write is "
f"refused — that is the fail-closed half of the guard, and it is what stops a "
f"build running before the live binding has been read")
check(re.search(r"_reset_protected\(\)", script.split("def _selftest")[0]) is not None,
f"{RECON_FILE}: _reset_protected() is never called outside the selftest, so the "
f"protected sets are not reset per run and a previous run's binding could be "
f"trusted for this one")
# The create-flow endpoint carries NO alias in its path, so it must be screened on its
# BODY or it escapes the guard entirely. (Found by the selftest; recorded here so a
# future edit cannot silently undo it.)
check("_FLOW_CREATE_PATH" in script,
f"{RECON_FILE}: there is no separate screen for POST .../authentication/flows. "
f"That endpoint carries no alias in its path — the alias is in the request BODY — "
f"so without a body-based screen it bypasses the protected-subtree guard "
f"altogether")
# And the one function allowed to change the live binding must be distinct from the
# policy write, which must refuse to.
# FINDING 2026-10-29: sub-flows are named by displayName in 26.0.6 (recurseExecutions
# never sets `alias` on a sub-flow row). Reading `alias` shrank the protected subtree to
# the top-level flow alone while every selftest passed against a stub that returned it.
check(re.search(r'^def subflow_alias\(ex\):\n(?:.*\n)*? name = ex\.get\("displayName"\)',
script, re.M) is not None,
f"{RECON_FILE}: subflow_alias() no longer reads displayName. Keycloak 26.0.6's "
f"executions listing names a sub-flow ONLY by displayName; reading `alias` yields "
f"None and the protected subtree silently shrinks to the top-level flow")
# FINDING 2026-10-29: the requirement PUT's real target is body["id"].
check("_EXEC_UPDATE_PATH" in script and "_BUILD_EXECUTION_IDS" in script,
f"{RECON_FILE}: the requirement PUT (PUT /flows/<alias>/executions) is no longer "
f"screened on its body id. updateExecutions resolves the execution realm-wide by id "
f"and ignores the path alias, so a path-only screen lets it write the bound flow")
check("def declared_bind(" in script and "BIND_FILE" in script,
f"{RECON_FILE}: the reconciler no longer reads git's bind-browser-flow switch")
check("def bind_browser_flow(" in script,
f"{RECON_FILE}: there is no bind_browser_flow() function. The atomic flip must be "
f"one named, auditable operation, not an incidental field in some other write")
check(re.search(r"never rebinds the browser flow", script) is not None,
f"{RECON_FILE}: the policy write no longer refuses to change browserFlow. "
f"PUT /admin/realms/<realm> DOES read rep.getBrowserFlow(), so the policy pass "
f"can rebind the login path by accident — to an UNVERIFIED flow")
# ======================================== (7) the SIBLING reconciler is NOT widened
if (ROOT / SIBLING_FILE).is_file():
sib_docs = [d for d in load(SIBLING_FILE) if d]
sib_cm = next((d for d in sib_docs if d.get("kind") == "ConfigMap"), None)
sib_script = next(iter((sib_cm.get("data") or {}).values()), "") if sib_cm else ""
sib_allow = tuple_literal(sib_script, "_ALLOWED") or ()
check(bool(sib_allow),
f"{SIBLING_FILE}: could not parse its _ALLOWED list, so this check cannot "
f"prove the sibling was not widened")
if sib_allow:
# The sibling's list is PATH-ONLY by design. Its selftest asserts it refuses the
# whole-realm endpoint and the /authentication tree; assert the same here so a
# future edit that "just adds the realm endpoint there instead" is caught.
bad = [p for p in sib_allow
if isinstance(p, str)
and (re.fullmatch(r"/admin/realms/\[\^/\]\+", p)
or "authentication" in p or "/users" in p or "/groups" in p
or "/roles" in p)]
check(not bad,
f"{SIBLING_FILE}: its allow-list was WIDENED to {bad}. That reconciler's "
f"stated guarantee — prod's real user and the media-users / "
f"platform-admins / viewers groups survive every run — is expressed as "
f"endpoints it CANNOT reach, and its own --selftest asserts the refusal of "
f"/users, /groups, /roles, /authentication and the whole-realm endpoint. "
f"The passkey work is a SIBLING program with its own narrow list precisely "
f"so that guarantee stays literally true. Revert the widening")
# ============================== (8) the reconciler's BEHAVIOUR, proven by its selftest
if script:
with tempfile.TemporaryDirectory() as td:
p = Path(td) / "reconcile.py"
p.write_text(script)
proc = subprocess.run([sys.executable, str(p), "--selftest"],
capture_output=True, text=True, timeout=300)
tail = (proc.stdout + proc.stderr).strip().splitlines()
check(proc.returncode == 0,
"the passkey reconciler's --selftest FAILED (compare-then-write / "
"idempotence / strict read-modify-write / the RpEntityName gate / "
"flow-is-unwritable / the conditional-OTP regression / fail-closed):\n"
" " + "\n ".join(tail[-14:]))
if proc.returncode == 0:
print(" passkey reconciler --selftest: " + (tail[-1] if tail else "(none)"))
# ---- (8b) THE SELFTEST IS FALSIFIABLE, not decorative ----------------------
# A passing suite proves nothing unless it can be shown to FAIL. mutations.py
# injects plausible regressions one at a time -- a partial policy PUT, a
# rebind of browserFlow, a widened allow-list that makes the flow writable, a
# fail-open on an unreadable input -- and requires --selftest to catch every
# one. This matters more than usual here: the whole incident class is a
# component that REPORTED SUCCESS while the realm was broken, and a green test
# that cannot fail is the same lie one level up.
mut = DIR_PATH / "mutations.py"
if mut.is_file():
mproc = subprocess.run([sys.executable, str(mut), str(p)],
capture_output=True, text=True, timeout=900)
mtail = (mproc.stdout + mproc.stderr).strip().splitlines()
check(mproc.returncode == 0,
"the passkey reconciler's --selftest is NOT FALSIFIABLE — at least "
"one injected regression survived it, so the suite does not prove "
"what it claims:\n " + "\n ".join(mtail[-16:]))
if mproc.returncode == 0:
print(" passkey reconciler mutations: "
+ (mtail[-1] if mtail else "(no output)"))
else:
check(False,
"conformance/tests/l0-static/keycloak-passkey-reconciler/mutations.py "
"is missing — nothing proves the --selftest can fail, and an "
"unfalsifiable proof is the failure mode this component exists to end")
# ======================================================= (9) this check is load-bearing
L0 = (ROOT / "ci" / "l0.sh").read_text() if (ROOT / "ci" / "l0.sh").is_file() else ""
GATE = (ROOT / "ci" / "gate.sh").read_text() if (ROOT / "ci" / "gate.sh").is_file() else ""
TOUCHED = ((ROOT / "ci" / "touched-requirements.sh").read_text()
if (ROOT / "ci" / "touched-requirements.sh").is_file() else "")
SELF_YAML = DIR_PATH / "chainsaw-test.yaml"
SELF = SELF_YAML.read_text() if SELF_YAML.is_file() else ""
check(re.search(r'^\s*conformance\.home\.k3s/scope:\s*["\']?whole-repo', SELF, re.M)
is not None,
"keycloak-passkey-reconciler/chainsaw-test.yaml does not declare "
'conformance.home.k3s/scope: "whole-repo", so the merge gate selects this check '
"only when a change happens to attribute one of the requirements it is labelled "
"with. This invariant spans platform/, platform-config/, ci/ and clusters/, so a "
"change that breaks it need not attribute R-IDP-03/04 — which is how 12 unusable "
"prod OIDC clients sat behind an entirely green cluster on 2026-09-06")
check("--include-scope" in L0,
"ci/l0.sh no longer passes --include-scope to the conformance runner, so the L0 "
"stage is a purely requirement-scoped run again and the scope annotation is inert "
"decoration — this check would then run only by coincidence")
check(re.search(r"^for s in .*\bl0\b.*; do", GATE, re.M)
and re.search(r"fail\).*BLOCK=1", GATE),
"ci/gate.sh does not set the merge block when the l0 stage fails")
check(re.search(r"^\s*platform/keycloak/\*\)", TOUCHED, re.M),
"ci/touched-requirements.sh has no platform/keycloak/* arm, so editing the realm "
"import or the reconciler would attribute NO requirement and the keycloak tests "
"would be filtered out of the merge gate's touched subset")
# =============================================================================
if failures:
print("CHECK FAIL: %d assertion(s) failed" % len(failures), file=sys.stderr)
for f in failures:
print(" FAIL %s" % f, file=sys.stderr)
raise SystemExit(1)
print("keycloak-passkey-reconciler: all assertions passed "
"(desired state derived from the realm import, one substitution pass, write "
"surfaces agree, the BOUND flow is unwritable and fail-closed, nothing is "
"deletable, sibling allow-list unwidened, selftest green and falsifiable)")
PY
@@ -0,0 +1,456 @@
#!/usr/bin/env python3
# =============================================================================
# mutations.py -- prove the passkey reconciler's --selftest IS FALSIFIABLE.
#
# WHY THIS EXISTS
# ---------------
# A selftest that passes proves nothing on its own: a selftest asserting `True ==
# True` also passes. This reconciler exists because a one-shot realm import
# REPORTED SUCCESS while the realm was never converged (Done=True / Started=False
# with the whole passkey block inert, measured on prod 2026-10-18), and the same
# trap applies one level up -- a green test suite that cannot fail is the same lie
# in a different place.
#
# It matters more here than for the sibling client reconciler, because this program
# CONSTRUCTS AUTHENTICATION FLOWS and then REBINDS THE REALM'S LOGIN PATH. An
# untested safety property on that surface is not a documentation gap, it is an
# outage waiting for a schedule tick.
#
# So this injects a series of PLAUSIBLE regressions into the shipped script, one at
# a time, and requires that --selftest FAILS for every one. A mutation that
# SURVIVES means the suite does not actually check that property, and the check
# fails, naming it.
#
# THE FIRST SIX ARE THE BLUE/GREEN SAFETY ARGUMENT ITSELF. The design builds a new
# flow under an unused alias, verifies it, and flips `browserFlow` in one atomic
# write -- never mutating the flow users are logging in through. Each of those
# properties is enforced in code; these mutations are the proof that the enforcement
# is load-bearing rather than commentary. Several model the obvious "simplification"
# someone will propose:
# * "just build it under the declared alias, the suffix is ugly"
# * "bind it first, then check -- the check is just belt and braces"
# * "the protected-alias screen is redundant, the allow-list covers it"
# Every one of those would be a total SSO outage waiting to happen.
#
# ANCHORS ARE AUTHORED AGAINST THE EXTRACTED SCRIPT, not against the YAML manifest.
# The sibling mutations.py dedents by four because its anchors are copied out of the
# ConfigMap block scalar; these are copied out of the script as `python3 -` sees it,
# which is one less transformation to get wrong. check.sh passes the extracted file,
# so what is proven falsifiable is byte-for-byte what runs on the cluster either way.
#
# python3 mutations.py <path-to-extracted-reconciler.py>
#
# Runtime is ~60s (one subprocess per mutation, each running the full selftest
# against an in-process stub Keycloak; no network, no cluster).
# =============================================================================
from __future__ import annotations
import re
import subprocess
import sys
import tempfile
from pathlib import Path
# (name, the mistake being modelled, anchor, replacement)
MUTATIONS: list[tuple[str, str, str, str]] = [
# ---------------------------------------------------------------- blue/green safety
("build INTO the currently bound flow",
"'the suffix is ugly, just build under the declared alias' -- every "
"create-execution and set-requirement call would then land on the flow users are "
"logging in through, and a run dying part-way leaves a HALF-BUILT LOGIN PATH for "
"every SSO app and kubectl. This is the single regression the whole design exists "
"to make impossible",
" def mapped(alias):\n"
" return \"%s-%s\" % (alias, suffix)",
" def mapped(alias):\n return alias"),
("the protected-subtree screen is never called",
"'the allow-list already covers it' -- it does not: the allow-list's write entries "
"are alias-parameterised, so only this screen can say 'but not the flow that is "
"bound'. Without it the reconciler may write anywhere in the authentication tree",
" _screen_write(method, path, body if isinstance(body, dict) else None)",
" pass"),
("the per-run reset makes the protected sets EMPTY, not UNDETERMINED",
"reset them to frozenset() so the code reads more simply -- it silently removes "
"the fail-closed property, so a build that ran before the live binding had been "
"read would be PERMITTED, and could write straight into the live flow. (The "
"module-level initial value is deliberately NOT the anchor here: run() resets "
"these at the start of every run, so the initialiser is unreachable and mutating "
"it would prove nothing.)",
" global _PROTECTED_FLOW_ALIASES, _PROTECTED_EXECUTION_IDS, _BUILD_EXECUTION_IDS\n"
" _PROTECTED_FLOW_ALIASES = None\n"
" _PROTECTED_EXECUTION_IDS = None",
" global _PROTECTED_FLOW_ALIASES, _PROTECTED_EXECUTION_IDS, _BUILD_EXECUTION_IDS\n"
" _PROTECTED_FLOW_ALIASES = frozenset()\n"
" _PROTECTED_EXECUTION_IDS = frozenset()"),
("bind BEFORE verifying the build",
"'bind it first, the readback is just belt and braces' -- it is not: the readback "
"is what catches a build that was accepted but came out wrong, including the "
"conditional-OTP config landing under the wrong key. Binding first makes that the "
"live login path before anyone notices",
" verify = compare_shape(final_shape, expected, base, realm, token, configs,\n"
" \"the newly built flow %r\" % target)",
" verify = []"),
("a failed build is reported as SUCCESS",
"swallow the build error and return a converged result -- the realm is then left "
"on the old flow (which blue/green makes safe) but the run says it converged, so "
"nobody ever learns the passkey flow was never built. That is the one-shot "
"import's fail-open, reintroduced. NOTE: simply removing the early return here "
"is NOT a regression -- verify-before-bind catches a half-built flow anyway, which "
"is defence in depth working as intended -- so the mutation targets the reporting",
" problems.append(\n"
" \"flow: building %r FAILED (%s). Nothing was bound, so login is \"\n"
" \"UNAFFECTED -- the old flow is still live. The partial flow is left in \"\n"
" \"place, unbound and harmless\" % (candidate, e))\n"
" return 0, problems",
" return 1, []"),
("an existing candidate flow is reused WITHOUT verifying it",
"'if the alias exists we already built it' -- a half-built leftover from a crashed "
"run lives under exactly that alias, so this binds a partially constructed login "
"path",
" if cand_shape is not None and not compare_shape(\n"
" cand_shape, expected, base, realm, token, configs,\n"
" \"the existing flow %r\" % candidate):",
" if cand_shape is not None:"),
# ------------------------------------- 2026-10-29 review findings, each made falsifiable
("sub-flows identified by `alias` again",
"read `alias` off a sub-flow row, as the first version did -- Keycloak 26.0.6's "
"recurseExecutions sets only displayName (and flowId) on a sub-flow row, so every "
"sub-flow resolves to nothing and the protected subtree is the top-level flow alone: "
"a write into browser-mfa-forms would NOT be refused",
" name = ex.get(\"displayName\")\n"
" return name if isinstance(name, str) and name else None",
" name = ex.get(\"alias\")\n"
" return name if isinstance(name, str) and name else None"),
("an unnameable sub-flow is tolerated",
"carry on when a sub-flow row has no displayName -- the protected subtree is then "
"SMALLER than the live tree, which is the fail-open the guard exists to prevent",
" if name is None:\n"
" return None, None",
" if name is None:\n"
" name = \"\""),
("the requirement PUT is screened on its path only",
"trust the alias in PUT /flows/<alias>/executions -- updateExecutions resolves "
"body.id realm-wide and never checks it belongs to <alias>, so a candidate alias in "
"the path can carry a BOUND execution's id in the body",
" targets.append((\"execution\", (body or {}).get(\"id\")))",
" pass"),
("execution-addressed writes are not held to the build tree",
"'outside the bound subtree is enough' -- it is not: every OTHER flow in the realm is "
"outside it too, so a stray id would edit an unrelated flow",
" if what == \"execution\" and (_BUILD_EXECUTION_IDS is None\n"
" or target not in _BUILD_EXECUTION_IDS):",
" if False:"),
("the requirement PUT may carry any key",
"let the body carry flowId / displayName -- the same handler then RENAMES whatever "
"flow flowId names, bound or not",
" if extra:\n"
" raise Refused(",
" if False:\n"
" raise Refused("),
("git's bind switch is ignored",
"bind whenever verification passes, as the first version did -- the first live use of "
"an untested login flow becomes a schedule tick, and an operator's rollback is "
"undone within 15 minutes, forever",
" bind = git_bind and not no_bind",
" bind = not no_bind"),
("bind=false still binds inside the flow pass",
"keep the flag but forget to honour it where the bind happens",
" if not bind:\n"
" # THE BIND SWITCH IS OFF.",
" if False:\n"
" # THE BIND SWITCH IS OFF."),
("an absent bind switch means bind",
"default to true when the key is missing -- a ConfigMap predating the switch, or a "
"deleted line, would silently put an untested flow live",
" except FileNotFoundError:\n"
" return False",
" except FileNotFoundError:\n"
" return True"),
("an invalid bind switch is guessed at",
"read anything that is not \"true\" as false -- a typo then passes silently instead of "
"stopping the run, and the operator believes the flow is live",
" if raw == \"false\":\n"
" return False\n"
" raise ValueError(",
" return False\n"
" raise ValueError("),
("bind=false rebuilds beside a hand-edited bound flow",
"treat the hand-edited bound candidate as a 'half-built leftover' -- with the switch "
"off this builds an -r2 next to the operator's hotfix on every tick",
" if candidate in have and candidate == bound and not bind:",
" if False:"),
# ------------------------------------------------------------------- idempotence
("sub-flow aliases are compared as names",
"compare the sub-flow alias like any other field -- the flows this program builds "
"carry a hash suffix on every alias, so a built flow could NEVER be recognised as "
"converged: it would be rebuilt and the login path reflipped every fifteen "
"minutes, forever",
" out.append((level, ex[\"kind\"], \"\" if is_flow else ex[\"name\"],\n"
" ex[\"requirement\"], ex.get(\"configAlias\") or \"\", ex[\"name\"]))",
" out.append((level, ex[\"kind\"], ex[\"name\"],\n"
" ex[\"requirement\"], ex.get(\"configAlias\") or \"\", ex[\"name\"]))"),
("the converged short-circuit is removed",
"drop the 'bound flow already matches git' early return -- every run rebuilds and "
"reflips, which is a permanent write loop against the IdP and churns the realm's "
"login path on a schedule",
" if not drift:\n"
" log(\"flow ok (the bound flow %r matches git: %d execution(s), no \"\n"
" \"write)\" % (bound, len(expected)))\n"
" return 0, problems",
" if False:\n return 0, problems"),
("the execution priority is dropped from the requirement write",
"omit `priority` from the body -- Keycloak's updateExecutions deserialises a "
"missing int as 0 and compares it against the stored priority, so this RESHUFFLES "
"the execution order while only a requirement change was intended. The passkey "
"branch would end up in the wrong place in the tree",
" body={\"id\": ex.get(\"id\"), \"requirement\": want_req,\n"
" \"priority\": ex.get(\"priority\") or 0})",
" body={\"id\": ex.get(\"id\"), \"requirement\": want_req})"),
("the alias budget is unbounded",
"loop forever looking for a free alias -- a persistently failing build then creates "
"unbounded flow sprawl in the realm, and nothing here can delete any of it",
" for attempt in range(1, MAX_BUILD_ATTEMPTS + 1):",
" for attempt in range(1, 10000):"),
("fail-open when the bound flow cannot be read",
"carry on when the live flow's executions are unreadable -- the program then cannot "
"know which flows are live, so it cannot prove a build would miss them, and it "
"would build with the protected sets still undetermined",
" return 0, [\n"
" \"flow: the realm binds browserFlow to %r but its executions could not \"\n"
" \"be read, so this program cannot establish which flows are live. Flow \"\n"
" \"writes stay FAIL-CLOSED for this run -- nothing was built or bound\"\n"
" % bound]",
" _set_protected(set(), set())\n bound_shape, bound_raw = None, None"),
# ------------------------------------------------------- the conditional-OTP regression
("authenticator config contents no longer compared",
"check only that a config is BOUND, not what it says -- this is THE conditional-OTP "
"regression: the original bug wrote the role under "
"`condition.evaluated.user.role` instead of `condUserRole`, so the role resolved "
"to null, the REQUIRED conditional step threw 'Invalid role name submitted: null' "
"and every platform-admin got a 403 from every SSO-gated app",
" if str(have_cfg.get(key)) != str(want_cfg[key]):",
" if False:"),
("undeclared config keys are ignored",
"stop reporting keys the realm holds and git does not -- the stale "
"`condition.evaluated.user.role` key is the signature of the original null-role "
"bug, and a flow carrying both keys would look correct",
" extra = sorted(k for k in have_cfg if k not in want_cfg)",
" extra = []"),
("flow structure divergence detected but not reported",
"leave the per-step comparison out -- the realm's login flow could be the old "
"pre-passkey shape forever and every run would report the realm converged, which "
"is the exact fail-open of the one-shot import this program replaces",
" if observed[idx][:4] != expected[idx][:4]:",
" if False:"),
("the browserFlow BINDING is no longer compared",
"stop noticing that a correct flow is not the one bound -- a perfect, fully-built "
"passkey flow that nothing logs in through changes nothing at all",
" if bound_shape is not None:\n"
" drift = compare_shape(bound_shape, expected, base, realm, token, configs,",
" if False:\n"
" drift = compare_shape(bound_shape, expected, base, realm, token, configs,"),
# ------------------------------------------------------------- realm / policy writes
("partial policy PUT instead of read-modify-write",
"send only the drifted policy keys -- Keycloak 26.0.6 rebuilds the WHOLE "
"passwordless policy from the representation and substitutes its own DEFAULT for "
"every key the body omits, so a partial PUT silently resets everything it did not "
"mention while returning 204",
" body = dict(realm_rep)\n"
" for key in drift:\n"
" body[key] = wanted[key]",
" body = {RP_ENTITY_NAME: realm_rep.get(RP_ENTITY_NAME),\n"
" BROWSER_FLOW: realm_rep.get(BROWSER_FLOW)}\n"
" for key in drift:\n"
" body[key] = wanted[key]"),
("the policy pass rebinds browserFlow as a side effect",
"'while we are here, let the policy write fix the flow binding too' -- the realm "
"PUT does read rep.getBrowserFlow(), so this is genuinely in reach, and it would "
"rebind the login path to an UNVERIFIED flow from a pass whose stated surface is "
"the passkey policy",
" if body.get(BROWSER_FLOW) != realm_rep.get(BROWSER_FLOW):\n"
" raise RuntimeError(",
" body[BROWSER_FLOW] = \"browser\"\n"
" if False:\n"
" raise RuntimeError("),
("the RpEntityName gate removed from desired-state validation",
"accept a desired state with an empty RpEntityName -- Keycloak 26.0.6 applies the "
"passwordless policy ONLY when that field is non-empty, so every realm PUT would "
"be accepted with 204 and change nothing: reported success, zero convergence",
" if not policy.get(RP_ENTITY_NAME):\n"
" raise ValueError(",
" if False:\n raise ValueError("),
("the RpEntityName gate removed from the write path",
"drop the second, defence-in-depth guard -- it is unreachable while the "
"desired-state validation holds, which is exactly why deleting it would otherwise "
"be an invisible regression",
" if not body.get(RP_ENTITY_NAME):\n"
" raise RuntimeError(",
" if False:\n raise RuntimeError("),
("no readback verification on the policy write",
"trust the 204 -- a server that accepts the PUT and does not apply it is reported "
"as REPAIRED, which is 'reports success while the realm never converged' again, "
"and it is precisely what the RpEntityName gate does when it is missed",
" still = policy_drift(after, wanted)",
" still = {}"),
("AAGUID comparison made order-sensitive",
"compare the AAGUID allow-list in the order Keycloak returns it -- an allow-list "
"is a set membership test, so an order-sensitive comparison rewrites a CONVERGED "
"realm on every run forever while every run still reports success",
" if stored != sorted(declared_value):\n"
" drift[key] = (stored, sorted(declared_value))",
" if stored != declared_value:\n"
" drift[key] = (stored, declared_value)"),
("signature-algorithm order treated as insignificant",
"sort the signature algorithms before comparing -- it is a PREFERENCE LIST handed "
"to the authenticator, so ES256-then-RS256 is not the same declaration as "
"RS256-then-ES256, and a reorder in the realm would never be corrected",
" if stored != list(declared_value):",
" if sorted(stored) != sorted(declared_value):"),
("unmanaged policy keys forced to a value",
"iterate every writable key instead of only the declared ones -- a "
"webAuthnPolicyPasswordless field the realm holds and git does not declare would "
"be overwritten, turning 'not declared' into 'must equal my default'",
" for key in sorted(wanted):\n declared_value = wanted[key]",
" for key in POLICY_FIELDS:\n declared_value = wanted.get(key, \"\")"),
# ------------------------------------------------------------------ required actions
("required-action PUT sends only the two drifted fields",
"a minimal body looks safer -- but updateRequiredAction REPLACES the provider from "
"the representation, so name, priority and config are all blanked: the enrolment "
"action keeps working but loses its priority and display name",
" body = dict(rep)\n for key in drift:\n body[key] = wanted[key]",
" body = {}\n for key in drift:\n body[key] = wanted[key]"),
("the guarded default required actions are no longer audited",
"stop checking that CONFIGURE_TOTP / UPDATE_PASSWORD / delete_credential still "
"exist -- their disappearance is the signature of a requiredActions block in the "
"realm import, which 26.0.6 treats as REPLACE, and it is a silent permanent "
"lock-out of every platform-admin",
" missing = [a for a in GUARDED_ACTIONS if a not in aliases]",
" missing = []"),
# ----------------------------------------------------------------- inputs / coupling
("the three passes are coupled",
"abort the run at the first failing pass -- a policy write that fails for an "
"unrelated reason would then suppress the browser-flow pass, which is the most "
"safety-critical thing here",
" problems.extend(found)\n if found:\n rc = 1",
" problems.extend(found)\n if found:\n rc = 1\n"
" break"),
("policy-key validation removed from the desired state",
"accept any realm key from the ConfigMap -- an arbitrary realm attribute becomes "
"writable through a file edit, so a hand-edited ConfigMap could set sslRequired, "
"bruteForceProtected or any other top-level realm field on the live IdP",
" if key not in POLICY_FIELDS:",
" if False:"),
("fail-open on an unreadable desired state",
"treat a missing ConfigMap as 'nothing declared' -- the run reports success while "
"reconciling nothing, which is precisely the fail-open of the realm import this "
"whole component exists to replace",
" if not isinstance(raw, dict) or not raw:\n"
" raise ValueError(\"%s does not hold a non-empty JSON object\" % path)",
" if not isinstance(raw, dict) or not raw:\n return {}"),
("an undeclared authenticatorConfig reference is tolerated",
"stop validating that every configAlias a flow references is actually declared -- "
"the build then fails at the config-binding step, AFTER flows and executions have "
"already been created, leaving leftovers on every run",
" if ca and ca not in configs:\n"
" raise ValueError(",
" if False:\n raise ValueError("),
("the flow-cycle guard is removed",
"drop the `seen` check -- a cyclic flow declaration then recurses until the "
"interpreter dies, in a CronJob that retries",
" if alias in seen:\n"
" raise ValueError(\"flow %r is cyclic in the desired state\" % alias)",
" if False:\n"
" raise ValueError(\"flow %r is cyclic in the desired state\" % alias)"),
]
def main() -> int:
if len(sys.argv) != 2:
print("usage: mutations.py <extracted-reconciler.py>", file=sys.stderr)
return 2
src = Path(sys.argv[1]).read_text()
problems: list[str] = []
killed = 0
for name, why, old, new in MUTATIONS:
# An anchor that no longer matches is a FAILURE, not a skip: it means the code
# moved and this mutation silently stopped proving anything.
n = src.count(old)
if n != 1:
problems.append("%s: anchor matches %d times (expected exactly 1) -- the "
"code moved and this mutation no longer proves anything. "
"Re-anchor it against the current source." % (name, n))
continue
with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh:
fh.write(src.replace(old, new, 1))
path = fh.name
try:
proc = subprocess.run([sys.executable, path, "--selftest"],
capture_output=True, text=True, timeout=300)
finally:
Path(path).unlink(missing_ok=True)
if proc.returncode == 0:
problems.append("%s SURVIVED -- --selftest still passed with this regression "
"applied, so nothing in the suite actually checks it. The "
"mistake it models: %s" % (name, why))
continue
killed += 1
out = proc.stdout + proc.stderr
m = re.search(r"SELFTEST FAIL: (\d+) of \d+", out)
how = ("%s assertion(s) caught it" % m.group(1) if m
else "the selftest aborted (caught, less cleanly)")
print(" killed %-54s %s" % (name, how))
print()
if problems:
print("MUTATION CHECK FAIL: %d of %d mutation(s) not proven"
% (len(problems), len(MUTATIONS)), file=sys.stderr)
for p in problems:
print(" FAIL %s" % p, file=sys.stderr)
return 1
print("MUTATION CHECK OK: all %d injected regressions were caught by --selftest "
"(the suite is falsifiable, not decorative)" % killed)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -42,6 +42,19 @@
# whose every solver is an automated challenge type (dns01 / http01) — the
# same code path serves issuance AND renewal, so a solver a human must
# complete would defeat both;
# (5b) the ONE exception to (5) — WORKLOAD mTLS, added 2026-10-29 — is a Certificate
# annotated `homelab.keithjackson.dev/tls-scope: workload-mtls`. The spec's TLS
# requirements are about EXTERNALLY REACHABLE services (R-TLS-01; docs/08), and
# ADR-0006 says outright that "mTLS between workloads is a separate concern … not
# covered here". A public ACME CA cannot issue for a `*.svc.cluster.local` name or a
# client-auth certificate at all. The annotation is NOT trusted: an exempt
# Certificate must PROVE it is workload-internal — its Secret is served by NO Gateway
# listener, every DNS name / commonName is cluster-internal (a bare label or
# `*.svc[.cluster.local]`), it carries no IP/URI/email SANs, and its issuer is a
# NAMESPACED Issuer in its own namespace whose spec is `selfSigned` or a `ca` backed
# by a Secret that a tracked, likewise-exempt Certificate produces. Issuance and
# renewal stay unattended (cert-manager renews CA-issued certs itself), and (9)
# still applies. Every exemption is printed. First user: platform/buildkit.
# (6) each dns01 solver's provider credential is referenced BY NAME and that
# Secret is delivered SOPS-encrypted from git via a kustomization — the
# operator's one-time token hand-off (docs/08 OQ-4), never a per-cert step;
@@ -265,6 +278,7 @@ CERT_SECRETS: dict[tuple[str, str], str] = { # (namespace, secretName)
for rel, d in CERTS
}
listeners_checked = 0
GATEWAY_SECRETS: set[tuple[str, str]] = set() # every (namespace, Secret) a listener serves
for rel, gw in of_kind("Gateway", "gateway.networking.k8s.io/"):
gw_ns = ns_of(gw)
for lis in ((gw.get("spec") or {}).get("listeners") or []):
@@ -280,6 +294,7 @@ for rel, gw in of_kind("Gateway", "gateway.networking.k8s.io/"):
for r in refs:
kind = str(r.get("kind", "Secret"))
key = (str(r.get("namespace") or gw_ns), str(r.get("name")))
GATEWAY_SECRETS.add(key)
check(kind == "Secret",
f"{rel}: listener {lis.get('name')!r} certificateRef kind is {kind!r}; only "
"Secret refs can be traced to a cert-manager Certificate")
@@ -324,8 +339,61 @@ CM_NS = ns_of(cm_releases[0][1], "cert-manager") if cm_releases else "cert-manag
ISSUER_INDEX = {(d.get("kind"), name_of(d)): (rel, d) for rel, d in ISSUERS}
solver_creds: list[tuple[str, str, str, str]] = [] # (issuer, ns, secretName, ref field)
issuers_verified: set[str] = set()
# (5b) workload-mTLS exemption — see the header. Proven, never taken on trust.
SCOPE_ANN = "homelab.keithjackson.dev/tls-scope"
INTERNAL_NAME = re.compile(r"^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$")
NS_ISSUERS = {(ns_of(d), name_of(d)): (rel, d) for rel, d in of_kind("Issuer", "cert-manager.io/")}
EXEMPT_SECRETS = {(ns_of(d), str((d.get("spec") or {}).get("secretName", "")))
for _, d in CERTS
if ((d.get("metadata") or {}).get("annotations") or {}).get(SCOPE_ANN) == "workload-mtls"}
exempted: list[str] = []
def workload_mtls_ok(rel: str, cert: dict) -> bool:
"""True if `cert` claims the workload-mTLS scope AND proves it; records failures."""
ann = ((cert.get("metadata") or {}).get("annotations") or {}).get(SCOPE_ANN)
if ann is None:
return False
cns, cname = ns_of(cert), name_of(cert)
spec = cert.get("spec") or {}
ok = True
def need(cond, msg):
nonlocal ok
check(cond, f"{rel}: Certificate {cns}/{cname} claims {SCOPE_ANN}={ann!r} but {msg}")
ok = ok and bool(cond)
need(ann == "workload-mtls", "the only accepted value is 'workload-mtls'")
need((cns, str(spec.get("secretName", ""))) not in GATEWAY_SECRETS,
"its Secret is served by a Gateway listener — that is external TLS, which must be ACME (R-TLS-01)")
names = [str(n) for n in (spec.get("dnsNames") or [])]
if spec.get("commonName"):
names.append(str(spec["commonName"]))
for n in names:
internal = INTERNAL_NAME.match(n) and ("." not in n or n.endswith(".svc") or n.endswith(".svc.cluster.local"))
need(bool(internal), f"name {n!r} is not cluster-internal (bare label or *.svc[.cluster.local])")
for f in ("ipAddresses", "uris", "emailAddresses"):
need(not spec.get(f), f"it carries {f}, which a workload-internal certificate has no use for")
ref = spec.get("issuerRef") or {}
need(str(ref.get("kind", "Issuer")) == "Issuer",
"its issuer is not a NAMESPACED Issuer — a ClusterIssuer could sign for any namespace")
hit = NS_ISSUERS.get((cns, str(ref.get("name", ""))))
need(hit is not None, f"its Issuer {ref.get('name')!r} is not tracked in namespace {cns!r}")
if hit is not None:
ispec = hit[1].get("spec") or {}
kinds = sorted(ispec)
need(kinds in (["selfSigned"], ["ca"]),
f"its Issuer is {kinds} — only selfSigned or ca are accepted for workload mTLS")
if kinds == ["ca"]:
need((cns, str((ispec.get("ca") or {}).get("secretName", ""))) in EXEMPT_SECRETS,
"its CA Issuer's Secret is not produced by a tracked workload-mtls Certificate "
"in the same namespace — the CA itself would have to be minted by hand")
if ok:
exempted.append(f"{cns}/{cname}")
return ok
for rel, cert in CERTS:
spec = cert.get("spec") or {}
if workload_mtls_ok(rel, cert):
continue # (5b): proven workload-internal; (9) below still applies
ref = spec.get("issuerRef") or {}
kind = str(ref.get("kind", "Issuer"))
per_env = resolve_per_env(str(ref.get("name", "")))
@@ -379,6 +447,8 @@ for rel, cert in CERTS:
solver_creds.append((iname, want_ns, n, f"{provider}.{field}"))
check(len(issuers_verified) > 0, "no issuer was reachable from any Certificate — (5) proved nothing")
if exempted:
print(f" (5b) workload-mTLS certificates exempted from ACME after proving it: {', '.join(exempted)}")
# (6) the solver credential is delivered, SOPS-encrypted, from git — never per-cert by hand.
check(len(solver_creds) > 0,
+18 -21
View File
@@ -40,32 +40,29 @@ PYTHON_MIN="3.9"
# gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*
# Any other path, and any TAG reference, is DENIED at admission. Do not "tidy" these values.
CONFORMANCE_RUNNER_IMAGE_REPO="gitea.home.keithjackson.dev/home-network/conformance-runner"
# Build handle only. Admission never sees the tag; a Job must reference the digest.
# Local build handle only. Admission never sees the tag; a Job must reference the digest.
# CI publishes under `sha-<commit>` and `main` instead (.gitea/workflows/images.yaml).
CONFORMANCE_RUNNER_IMAGE_TAG="chainsaw0.2.12-kubectl1.36.4-fc44"
#
# ############################################################################
# # DIGEST DELIBERATELY EMPTY — THE IMAGE HAS NOT BEEN PUBLISHED YET. #
# # THE DIGEST IS WRITTEN BY RENOVATE, NOT BY HAND. #
# ############################################################################
# The image has been BUILT and its toolchain verified locally (non-root uid 65532,
# read-only rootfs: bash 5.3.9, Python 3.14.7, git 2.55.0, kubectl v1.36.4, chainsaw 0.2.12,
# and `./run.sh --layer l2 --no-cluster` completing with rc 0). It has NOT been pushed:
# pushing needs a registry credential, and publishing the artifact a fail-closed security
# boundary then trusts is an OPERATOR action, not an agent's.
# CI builds and verifies the image (non-root uid 65532, read-only rootfs, the
# conformance-clone refusals) and, on a push to main, publishes it and prints the digest the
# REGISTRY returned (.gitea/workflows/images.yaml). Renovate follows the image's `main` tag
# through the marker below and opens the pull request that moves this value. Nobody pushes
# this image from a workstation, and nobody pastes a digest here.
#
# OPERATOR, to complete the pin:
# 1. ./conformance/incluster/build-image.sh # rebuild, verify, print the commands
# 2. podman login gitea.home.keithjackson.dev
# 3. podman push --digestfile /tmp/conformance-runner.digest \
# "${CONFORMANCE_RUNNER_IMAGE_REPO}:${CONFORMANCE_RUNNER_IMAGE_TAG}"
# 4. Set CONFORMANCE_RUNNER_IMAGE_DIGEST below to `cat /tmp/conformance-runner.digest`
# — the digest the REGISTRY returned, not the local manifest digest the build printed.
# 5. Land steps 4 in a PR (this file is GitOps input, not a live edit).
#
# UNTIL THEN: this is empty on purpose, NOT a placeholder to be guessed at. An empty value
# makes any consumer that renders the Job fail visibly; a fake or stale digest would instead
# produce an ImagePullBackOff that looks like a cluster fault. Do not invent one.
CONFORMANCE_RUNNER_IMAGE_DIGEST=""
CONFORMANCE_RUNNER_IMAGE="${CONFORMANCE_RUNNER_IMAGE_DIGEST:+${CONFORMANCE_RUNNER_IMAGE_REPO}@${CONFORMANCE_RUNNER_IMAGE_DIGEST}}"
# ALL-ZERO UNTIL THE FIRST PUBLISH. Before 2026-10-29 this was EMPTY, so that a consumer
# rendering the Job would fail visibly. It is now the same impossible all-zero digest
# platform/pm-agent/ uses, because Renovate can only replace a digest that is there to match.
# A consumer MUST treat the all-zero value as "not published" (no consumer exists yet: the
# L2/L3 trigger is unwired, docs/plans/l2-l3-conformance-ci-wiring.md). Note also that
# chainsaw-job.yaml's @@CONFORMANCE_RUNNER_IMAGE_DIGEST@@ takes the 64 hex digits WITHOUT the
# `sha256:` prefix this value carries — the trigger must strip it.
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/conformance-runner tag=main
CONFORMANCE_RUNNER_IMAGE_DIGEST="sha256:0000000000000000000000000000000000000000000000000000000000000000"
CONFORMANCE_RUNNER_IMAGE="${CONFORMANCE_RUNNER_IMAGE_REPO}@${CONFORMANCE_RUNNER_IMAGE_DIGEST}"
#
# AFTERWARDS, TIGHTEN THE BOUNDARY: the header of conformance-trigger-job-shape.yaml asks for
# its `@sha256:*` wildcard to be replaced with the LITERAL digest once the image exists —
@@ -0,0 +1,127 @@
---
date: 2026-10-05
branch: docs/pm-agent-registry-pull-prereq
title: pm-agent reconciled exactly as designed — and is the first workload ever to pull from the in-cluster registry, which does not work yet
---
## What changed
Documentation only. No manifest, no policy, no code.
PR #57 merged and `pm-agent` reconciled. I measured what landed, and the measurement turned
up **two prerequisites for the next operator step that nothing in this repository
declares** — and that the handoff entry for #57 could not have known, because it listed the
image pull as unverified and that is exactly where it bit.
So this entry records the post-merge measurement, and
`platform/pm-agent/README.md`, `docs/plans/pm-agent-issue-to-pr.md` and
`runbooks/pm-agent.md` now name both prerequisites where they say "build, push, pin the
digest". Before this, that step read as one action; it is three, and the two new ones are
not an agent's to perform.
**The finding is not specific to pm-agent.** `conformance-runner` has the identical
registry path (`gitea.home.keithjackson.dev/home-network/...`), the identical "operator
pushes, a digest goes in a PR" model, and has never been pulled either — so the same two
blockers sit in front of the L2/L3 conformance wiring. Measured: **no pod anywhere in the
cluster references that registry** except the pm-agent dispatcher, which is therefore the
first workload ever to try it.
## What I verified
Read-only, through the pinned toolbox, against the live prod cluster.
**The merge reconciled, completely and exactly as designed.**
- `GitRepository/flux-system` artifact revision: `main@sha1:95597bd6883637…` — the merge
commit. `platform-config`, `pm-agent` and `apps-wave-0` all report that same
`lastAppliedRevision` with `Ready=True`. Compared field to field, per CLAUDE.md §2b,
rather than assumed from the merge.
- **The security boundary is live.** `ClusterPolicy/pm-agent-job-shape`:
`validationFailureAction=Enforce`, `failurePolicy=Fail`, `Ready=True`, and all six rules
present (`worker-must-be-the-committed-shape`,
`worker-must-be-one-container-with-no-args`,
`worker-volumes-must-be-workspace-or-tmp-emptydirs`,
`worker-may-not-declare-init-or-ephemeral-containers`,
`worker-env-names-must-be-the-committed-set`, `worker-may-not-use-envfrom`). This is the
check that mattered most: a ClusterPolicy added to the wrong kustomization list ships
green and never reaches the cluster, which is what happened to the conformance boundary
on 2026-09-29.
- Namespace contents: both ServiceAccounts, `pm-agent-config` (17 keys), the
`pm-agent-credentials` Secret, the `default-deny` NetworkPolicy, and both
CiliumNetworkPolicies with `VALID=True`.
- **SOPS decryption works through the new overlay patch.** Checked by KEY NAMES and VALUE
LENGTHS only, never values (CLAUDE.md §6): keys `gitea-token`, `claude-code-oauth-token`,
lengths 76 and 52 bytes — which are the base64 lengths of the committed placeholder
strings, so the Secret holds the decrypted placeholders and not ciphertext. A
ciphertext apply succeeds silently and would have left `ENC[AES256_GCM,…]` values an
order of magnitude longer.
- **`pm-agent-worker` really is bound to nothing.** The namespace has exactly one
RoleBinding (`pm-agent` → Role `pm-agent`, subject `ServiceAccount/pm-agent`), and no
ClusterRoleBinding anywhere names either pm-agent ServiceAccount.
- `gitea-allow-pm-agent-ingress` is applied in the `gitea` namespace, matching
`io.kubernetes.pod.namespace: pm-agent`.
- The dispatcher is `ImagePullBackOff` on the all-zero placeholder digest — the designed,
visible failure.
**The two prerequisites, measured.**
1. **The nodes cannot verify the registry's TLS certificate.** containerd's error on the
pull is not about the digest:
failed to resolve reference "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:000…":
tls: failed to verify certificate: x509: certificate is not valid for any names,
but wanted to match gitea.home.keithjackson.dev
"not valid for **any** names" means the certificate presented to the node carries no
matching SAN at all. From the operator's workstation the same name is healthy: it
resolves to `192.168.2.20` and serves a Let's Encrypt certificate
`CN=*.home.keithjackson.dev` with SANs `*.home.keithjackson.dev, home.keithjackson.dev`
— and `Certificate gateway/wildcard-home` is `Ready=True` for exactly those names. So
the certificate that *should* be served is fine, and what the NODE reaches is not what
the workstation reaches. **Correcting the digest will not clear this.**
2. **Anonymous pulls from this registry are refused.** Not inferred from a bare 401 — that
would have been wrong, because this Gitea answers 401 for *everything* anonymously:
`/v2/home-network/agent-runner/manifests/latest`, `/v2/home-network/conformance-runner/…`,
a randomly-named nonexistent repository, and `nosuchowner/nosuchimage` all return 401,
and `/api/v1/packages/Home-Network` returns `{"message":"token is required"}`. The
registry *does* issue a token to an anonymous client, but its `access` claim is `null` —
no pull scope. So the conclusion that holds is the simple one: **an unauthenticated
client gets nothing from this registry**, so a pull with no credential cannot succeed.
And `grep` over the tree finds **no `imagePullSecrets` anywhere** — not in
`platform/pm-agent/deployment.yaml`, not in `infra/agent/worker-job-template.yaml`, not
in `conformance/incluster/`.
## What I did NOT verify
- **Why the node sees a nameless certificate.** I measured that it does, and that the LAN
does not. I did not diagnose it: I did not read the nodes' resolver or
`/etc/rancher/k3s/registries.yaml` (node files, not reachable read-only through the API),
and I did not check whether the node resolves the name to the same `192.168.2.20` the
workstation does or to something else.
**A correction to a remark I first wrote here:** I cited
`cluster-vars`'s `GATEWAY_VIP: 192.168.1.53` as suspicious next to the nodes'
`192.168.2.x`. That was noise — the operator confirms those base `cluster-vars` values are
**dev-era leftovers for a cluster that is decommissioned**, so the 192.168.1.x addresses in
that file say nothing about prod and should not be read as evidence about anything. The
measured facts stand on their own and none of them depended on that knob.
- **Whether the package would be pullable once authenticated.** Nothing has ever been
pushed to that path, so "a credential is sufficient" is untested. The only thing
established is that no credential is insufficient.
- **Which fix the operator wants.** Two routes, and they are not equivalent:
(a) an `imagePullSecret` — a `dockerconfigjson` holding a Gitea token, SOPS-encrypted
like `pm-agent-credentials`, referenced from the Deployment *and* from the committed
worker Job template; note the Job template's shape is pinned by admission, and
`imagePullSecrets` is a field that policy does not currently constrain, so adding one is
a template change that needs a deliberate look at whether the policy should pin it too.
(b) allowing anonymous reads of that package in Gitea — no manifest change at all, but it
makes the image world-readable to anything that can reach the host.
I have implemented neither: both need an operator action I must not take, and (b) is a
posture decision. The docs now present both.
- **Everything the #57 entry listed as unverified about RUNTIME remains unverified.** The
image has still never been built or pushed. No dispatcher has authenticated to Gitea, no
issue has been triaged, no worker Job has been created — so the admission policy, though
now provably applied and Enforce, has still never refused or admitted a real Job. The
network policies have not carried traffic.
- **I did not re-run the merge gate against `main` after the merge.** This branch's own gate
run is the evidence for this change; the merged state's is CI run on PR #57.
@@ -0,0 +1,112 @@
---
date: 2026-10-05
branch: fix/vpn-runbook-ansible-command
title: vpn-remote-access runbook — step 1 named a script that does not exist, and step 2 cited the wrong reason the key is gitignored
---
## What changed
Two defects in `runbooks/vpn-remote-access.md` (merged in #55), both authored by me, both reported
by the operator the first time they tried to follow it.
**1. Step 1 named a command that does not exist.** It said:
cd infra/ansible && ./site.sh --tags system --limit k3s_prod
There is **no `site.sh` anywhere in this repo** (`find . -name site.sh` → nothing). The invocation
was wrong in four separate ways at once, which is what makes it worth recording rather than just
patching: wrong kind of thing (this repo drives Ansible with `ansible-playbook <playbook>`, never a
wrapper script — `playbooks/site.yml` exists and is the full bring-up chain, not this); wrong
playbook (the module work lives in `playbooks/node-baseline.yml`); wrong targeting flag (`--limit`
instead of this repo's `-e k3s_hosts=...` convention, which `infra/ansible/README.md` "Targeting
prod" documents for every playbook); and no toolbox wrapper, though Ansible exists only inside the
pinned container and `ansible.cfg` is only read from `infra/ansible`.
Replaced with the real command, plus the three things about it that are load-bearing:
./infra/devcontainer/dev.sh run bash -c \
'cd /work/infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags system'
- `-e k3s_hosts=k3s_prod` — the playbook defaults to `k3s_servers`, which is the **dev** group;
dev is decommissioned, so an unqualified run does nothing useful.
- `--tags system`, never untagged — the two kernel-module tasks carry that tag, and an untagged run
would also hit the `disks` tasks, which assert on block-device variables prod deliberately leaves
as placeholders (the playbook's own header warns about this). `--tags system` is broader than the
modules alone (sysctls, iscsid, qemu-guest-agent, swap masking) but each is idempotent and
already in its desired state on a live node.
**2. Step 2 gave the wrong reason the server key is safe from git — and the right reason is
fragile.** It told the operator to stage the key at `/work/.cache/server.key` "inside the repo's
gitignored cache". `.cache/` is **not** gitignored: `git check-ignore -v .cache/serverkey.txt`
matches nothing. The file was ignored only incidentally, by `.gitignore` line 11 `*.key` — so the
path happened to be safe for a reason I had not identified, and **any operator who renamed it
(`server.pem`, `wg-server`, `privatekey`) would have had a committable WireGuard private key sitting
in the checkout.** The gate would not have caught it either; `no-plaintext-secrets` does not
recognise a bare WireGuard key.
The step now states the real rule, marks the `.key` suffix as the thing doing the work, and gives
the operator a verification they can run before trusting it (`git check-ignore -v` must print a
rule, and `git status --short` must not list the file).
Nothing outside `runbooks/vpn-remote-access.md` changed; no manifest, no behaviour.
## What I verified
- **`site.sh` does not exist**: `find . -name 'site.sh' -not -path './.git/*'` → no output.
- **The real surface**: `infra/ansible/playbooks/` contains `node-baseline.yml` and `site.yml` (and
11 others); `node-baseline.yml`'s own header documents both
`-e k3s_hosts=k3s_prod` and the tag-scoping hazard. The `-e <playbook>_hosts=` convention is
confirmed by `infra/ansible/README.md` §"Targeting prod" and by the existing
`runbooks/node-memory-swap.md`, which invokes `ansible-playbook playbooks/node-health.yml -e
node_health_hosts=...`.
- **`k3s_prod` is a real inventory group**: `[k3s_prod:children]` in `inventory/hosts.ini`, with
`[k3s_prod_primary]`/`[k3s_prod_secondary]`.
- **`--tags system` covers the kernel-module tasks**: both "Persist kernel modules…" and "Load
kernel modules now" carry `tags: [system]` in `roles/node_baseline/tasks/main.yml`.
- **The gitignore claim, measured both directions**:
`git check-ignore -v .cache/server.key` → `.gitignore:11:*.key`;
`git check-ignore -v .cache/serverkey.txt` → no match. So the suffix is the only thing ignoring it.
- **Every path the component's docs reference resolves**: all six relative markdown links in
`platform/vpn-ingress/README.md` (checked from that file's own directory), and both Ansible
playbook paths.
- **Toolbox gate** from an isolated worktree: **rc 3**, with `spec-immutability`, `decisions-log`,
`lint`, `l0`, `render-golden`, `l1` and `approval-policy` all **pass**; the rc 3 is the two
environmental skips (`gitea-settings` — no local token; conformance L2/L3 — no `CLUSTER_AVAILABLE`).
**Not green. Reported as rc 3, not as pass.**
- **This branch touches NO sensitive path** (`runbooks/**` and `decisions/**` are not in
`infra/gitea/approval-policy.json`), so unlike #55 it needs no `/approve-override`.
## What I did NOT verify
- **The corrected command has not been RUN.** Ansible is an operator action, and this is the
decisive gap: I have verified the playbook, the group, the tag and the flag all exist and are
the documented convention, but not that the run succeeds end to end or that
`lsmod | grep '^wireguard'` then reports the module. It is a better-evidenced command, not a
proven one.
- **Whether `--tags system` is entirely side-effect-free on a live prod node.** Each task it runs is
idempotent by construction, but I did not dry-run it (`--check`) to confirm it reports zero
changes beyond the module load.
- **The rest of the runbook's commands past step 2** are unchanged and still unexercised — in
particular `kubectl exec ds/wireguard -- wg show`, which cannot be reached while the pods sit in
`Init` waiting on the server key.
- **Whether any OTHER document I wrote in #55 contains an invented command.** I re-checked every
file path and markdown link in the component (all resolve) and every script the docs tell an
operator to run, but "the referenced file exists" is weaker than "the command is correct" — which
is exactly the gap that produced `site.sh`.
## Post-merge state of #55, recorded here since its own entry predates the merge
Measured on live prod after #55 merged as `ad3f56e`:
- Flux applied it by the real path: Kustomization `vpn-ingress` `lastAppliedRevision:
main@sha1:ad3f56e`, equal to the GitRepository's artifact revision, `Ready=True`.
- All **18** Flux Kustomizations `Ready=True` — the new tier disturbed nothing, and `wait: false`
behaved as intended (`vpn-ingress` is Ready while its own pods are not, so nothing downstream
stalled on it).
- **`hostNetwork` is confirmed in effect, not merely configured**: the three pods report pod IPs
`192.168.2.10/.11/.12` — the node addresses, one per node.
- **It fails closed exactly as designed**, with a legible cause: all three pods `Init:0/1`,
`MountVolume.SetUp failed for volume "wg-server-key" : secret "wg-server-key" not found`, and no
Secret in the namespace. The #55 entry's "the DaemonSet has never been reconciled" is superseded
by this paragraph; its remaining unverified claims (the tunnel carrying a packet, the
iptables/nftables backend question) still stand.
@@ -0,0 +1,187 @@
---
date: 2026-10-05
branch: fix/vpn-server-key-in-sops
title: VPN server key moves into SOPS-encrypted git — the out-of-band Secret broke R-SEC-01 and the DR story
---
## What changed
**Operator correction (2026-10-05): "secrets are kept in source through sops".** They are right, and
#55 shipped the WireGuard server private key the wrong way: as a Secret the operator was told to
`kubectl create` out of band, referenced by name only. This branch moves it into the tree as
`platform/vpn-ingress/wg-server-key.sops.yaml`, where every other secret in this repo lives.
**Why the original choice was wrong, not merely unconventional.** Two concrete costs, both of which
#55's own README half-admitted and then shipped anyway:
1. **R-SEC-01 / spec docs/07 says secrets live in source, SOPS-encrypted, reaching the cluster only
through Flux.** An out-of-band Secret is outside the GitOps tree by construction.
2. **It broke DR for the one credential that cannot be re-derived.** A cluster rebuild restores
everything in git; it does not restore a hand-made Secret. Losing this key means **re-enrolling
every device**, because each client profile pins its public half. #55 papered over this with
"keep a copy in your password manager", which is a workaround for a self-inflicted gap.
Note `platform/kyverno/admission/secret-provenance.yaml` did **not** forbid the original shape —
it scopes to `app-namespace=true` namespaces and `vpn-ingress` is deliberately a platform namespace,
so admission would have accepted the hand-applied Secret. The rule it violated was the written
requirement, not an enforced control. Recording that because "the gate let it through" was never
evidence it was correct.
**How the unfillable-key problem is solved.** An agent still must not mint a long-lived private key
(CLAUDE.md §6), so the file is committed **already encrypted, with `privatekey: REPLACE-ME`**. That
makes the wiring complete and the gate green from the first commit — the resource line and both
overlay decryption patches are present — and leaves the operator exactly one act: replace the value.
The alternative, a commented-out resource line to uncomment later, is precisely the "file in git with
no generator entry: reviewed, merged, apparently done, still broken" shape this repo keeps getting
bitten by (`alert-runbooks-resolvable` exists because of it).
**The init-container guard is now a validation, not an emptiness check.** It was `[ -z "$key" ]`.
Because the committed placeholder is non-empty, the likely failure is now a value that is present
and *wrong*, so the guard pipes the value through `wg pubkey` — which accepts only a real 32-byte
curve25519 private key — and names which of four cases it hit:
| case | why it matters |
|---|---|
| `REPLACE-ME` placeholder | the expected fresh-install state |
| empty | blanked value |
| **starts with `ENC[`** | **the Secret reached the cluster UNDECRYPTED.** A missing overlay decryption patch applies cleanly and leaves every Flux condition green — this is the silent failure the guard exists for |
| rejected, none of the above | mangled value, or a *public* key pasted by mistake |
Files:
- `platform/vpn-ingress/wg-server-key.sops.yaml` (new) — SOPS-encrypted to the repo's age recipient,
`stringData.privatekey` only, placeholder value.
- `platform/vpn-ingress/wg-server-key.sops.yaml.example` (new) — plaintext template + operator
hand-off, following the `crafty-oauth2-proxy-credentials.sops.yaml.example` convention.
- `platform/vpn-ingress/kustomization.yaml` — the resource added; the "DELIBERATELY ABSENT: a
Secret" block replaced with the overlay-patch warning.
- `platform/vpn-ingress/daemonset.yaml` — the `wg pubkey` guard; volume comment rewritten.
- `clusters/dev/kustomization.yaml`, `clusters/prod/kustomization.yaml` — **the `vpn-ingress`
`decryption:` patch, in both.** This is the load-bearing half: #55's own kustomization comment
predicted that forgetting it applies ciphertext cleanly.
- `clusters/base/vpn-ingress.yaml` — its "NO decryption stanza is needed" comment was now false.
- `platform/vpn-ingress/README.md`, `runbooks/vpn-remote-access.md` — step 2 rewritten.
- `infra/vpn-ingress/new-peer.sh` — stale "step 1" pointer.
**A second finding: `wg` and `sops` were not in the same place — and the first fix for that was
also wrong.** Measured: `wg` existed only on the host (`/usr/bin/wg`), `sops` only in the dev
container, `qrencode` on neither. So `sops -d … | wg pubkey` could not run in one shell, and #55's
step 2 — which assumed it could — would have failed.
My first fix was to **split every step by where it runs**, and to tell the operator to
`dnf install qrencode` on their workstation. **Operator correction: "sop, wg, and any other tool
requirement is supposed to be baked into the devcontainer."** That is CLAUDE.md §5 and the
devcontainer README's "Don't install the toolchain on the host", and the split fix broke the
invariant rather than honouring it — it also produced a procedure that could never be reproduced in
CI, and which no one should have to follow.
**The real fix:** `wireguard-tools` and `qrencode` are now distro packages in
`infra/devcontainer/Containerfile`, alongside ansible/openssh/diffutils. Both are in Fedora 44's
repos (`wireguard-tools` 1.0.20260223 — the same upstream version as the container image this
component runs — and `qrencode` 4.1.1), unpinned like every other dnf package there, because the
pinned thing is the Fedora base and neither is a gate tool whose version changes what green means.
Three things got *simpler* rather than merely relocated, which is the tell that the operator's rule
was the right one:
1. **Step 2 is now one pipeline in one shell:** `sops set … "\"$(wg genkey)\""` generates the key
straight into the already-encrypted document. The plaintext never lands on disk, never reaches a
terminal, and there is no staged copy to shred. The previous version needed a copy in
`.cache/server.key`, a `git check-ignore` warning about the `*.key` suffix being the only thing
protecting it, and a sha256 comparison to prove the right bytes arrived. All of that is gone.
2. **`new-peer.sh` now derives the server's public key from `wg-server-key.sops.yaml` itself**
(`sops -d --extract … | wg pubkey`), so there is no local key copy to keep in step with the
encrypted one. It previously required the operator to keep the private key at a magic host path.
3. **`.cache/` is now gitignored at the repo root** (mirroring `infra/ansible/.gitignore`), so the
container has a persistent, non-committable scratch directory. `new-peer.sh` writes client
profiles to `.cache/wg-peers/` by default — necessary because the repo bind-mount is the
container's ONLY persistent read-write mount, so a profile written to `/tmp` in there is gone
before it can be moved to a desktop. Its old blanket "refuse to write inside the checkout" guard
is replaced by `git check-ignore`, which tests the actual property instead of a proxy.
**SENSITIVE PATHS — this PR touches `clusters/**` (two overlays) AND adds a `**/*.sops.yaml`, so
`ci/approval-policy.sh` will BLOCK it.** It needs the operator's `/approve-override <reason>` as a
PR **review** of type "Comment". I have deliberately not posted it (CLAUDE.md §1).
## What I verified
- **The encrypted file is real and correctly targeted.** `sops -e -i` in the pinned toolbox
produced `stringData.privatekey: ENC[AES256_GCM,...]` with cleartext keys, `encrypted_regex
^(data|stringData)$`, and exactly one age recipient:
`age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg` — the recipient in the root
`.sops.yaml`, i.e. the key Flux holds as `sops-age`.
- **Round-trip, without exposing anything**: `sops -d` then a Python read of key names only →
`stringData keys: ['privatekey']`, and `value is still the placeholder: True`.
- **Both overlay patches are present and name the right Kustomization** (`name: vpn-ingress`,
`namespace: flux-system`, `decryption.secretRef.name: sops-age`), inserted after each overlay's
`velero` stanza, mirroring headlamp/crafty/velero.
- **`kubectl kustomize platform/vpn-ingress` builds** and now emits five objects — Namespace,
ConfigMap, **Secret**, DaemonSet, NetworkPolicy — with `${WG_CLIENT_CIDR}` still an unexpanded
token for Flux.
- **The tool gap, measured on both host and container before the fix**: `wg` host-only, `sops`
container-only, `qrencode` on neither.
- **Both packages exist in the image's repos**, queried from inside the running container:
`wireguard-tools` 1.0.20260223 (repo `updates`) and `qrencode` 4.1.1 (repo `fedora`).
- **The image REBUILDS with them and both tools resolve inside it** —
`./infra/devcontainer/dev.sh build` succeeded, and `wg --version` / `qrencode --version` /
`sops --version` all answer in the rebuilt container. This is the claim the change rests on, so
it was executed rather than reasoned.
- **`new-peer.sh`'s new SOPS path fails correctly against the placeholder.** Run in the rebuilt
container it refused with "could not derive the server public key … it still holds the REPLACE-ME
placeholder", which is both the expected pre-key behaviour and a live test of the
`sops -d | wg pubkey` pipeline the whole design now depends on.
- **`.cache/` is ignored regardless of filename** now: `git check-ignore -v .cache/anything.conf`
and `.cache/serverkey.txt` both match `.gitignore:46:.cache/`. Before this change only `*.key`
matched, which is the fragility the previous runbook had to warn about.
- **Toolbox gate** from an isolated worktree: **rc 3**, with `spec-immutability`, `decisions-log`,
`lint`, `l0`, `render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two
environmental skips (`gitea-settings` — no local token; conformance L2/L3 — no cluster).
**Not green. Reported as rc 3, not as pass.**
- **The L0 gate caught THREE real defects in this branch before it could merge — including one
in the fix for another — and I had asserted one of them was fine without running it.** Both are recorded because the gate's value here is the
whole point of the exercise:
1. **`no-plaintext-secrets` FAILED**: "`wg-server-key.sops.yaml.example`: an unencrypted .example
template is referenced by a kustomization — it would be applied as a plaintext Secret". My
kustomization named the template in a *comment*, and the check matches comments too (correctly:
naming one is how a plaintext Secret gets applied by accident). `platform/crafty/kustomization.yaml`
never names its own example either — the convention was there to copy and I missed it. Fixed by
not naming the file; the hand-off lives inside the example, where it belongs.
**An earlier draft of this entry claimed this check passed. It did not. The claim was written
from expectation rather than from a run.**
2. **`flux-postbuild-substitution` FAILED** on `${key#ENC[}` in my new guard
(`daemonset.yaml:155`), flagged `[SILENT-EMPTY]`: envsubst consumes every `${...}`, so my
shell prefix-strip would have rendered to the empty string — the ciphertext branch of the guard
would never have fired, which is precisely the silent failure the guard was added to catch.
Rewritten with `case`, which uses no braces and so has nothing to escape.
3. **And then it failed a THIRD time, on the fix's own comment** — `[FATAL-PARSE]
daemonset.yaml:156 ${...}`, the prose explaining the rule, written with the literal form. That
class is worse than it looks: a `FATAL-PARSE` hazard fails the **entire** Kustomization, every
resource in the path, not just the line. Rewritten as "dollar-brace expansion". This is the
second time in this component that writing the hazard literally inside a comment about the
hazard has been the defect (the first was in #55), so the habit to build is: **in any
substituted path, describe the construct in words, never type it.**
- **After both fixes, `no-plaintext-secrets` and `flux-postbuild-substitution` pass** (446 and 198
assertions respectively), and `l0` is green.
## What I did NOT verify
- **That Flux decrypts this file on prod.** The overlay patches have never reconciled. This is the
whole risk of the change and it has exactly one observable outcome: after merge the pods should
CrashLoop reporting the **`REPLACE-ME` placeholder** (proving decryption worked), *not* reporting
**`ENC[`** (which would prove a patch is wrong). I built the guard to tell those apart because I
cannot test them.
- **That `wg pubkey` accepts the operator's real key through this path.** The guard is reasoned from
`wg pubkey`'s behaviour and has never run — `wg` is absent from both this workstation's container
and the gate, so the first execution will be in the pod.
- **The `sops set` command in the hand-off.** I verified `sops -e -i` and `sops -d` in the toolbox;
I did **not** run `sops set` with the quoting shown, and nested quoting inside
`dev.sh run bash -c` is exactly where a documented command tends to break. If it misbehaves, the
editor form (`sops <file>`) is the fallback and does not depend on quoting.
- **The sha256 comparison in step 2 step 4** is untested end to end (it needs the real key).
- **Whether anything else in #55 assumed a tool is available where it is not.** I checked `wg`,
`sops` and `qrencode` because step 2 depended on them. I did not audit every command in the
runbook the same way — and that class of defect has now produced two corrections in a row
(`site.sh`, then `sops | wg`), so assume more remain until each step has actually been run.
- **The live VPN still carries no traffic**, and the earlier gaps stand unchanged: the
iptables-vs-nftables question, `allowPrivilegeEscalation: false` versus the image's s6 init, and
any off-LAN reachability.
@@ -0,0 +1,73 @@
---
date: 2026-10-06
branch: fix/new-peer-ignores-template-comments
title: new-peer.sh refused the first VPN address because its duplicate check matched its own commented-out example
---
## What changed
The operator's first real enrolment failed:
```
$ ./infra/vpn-ingress/new-peer.sh keith-phone 2
new-peer.sh: 10.90.0.2/32 is already allocated in platform/vpn-ingress/peers.yaml. Pick another octet.
```
**Nothing was allocated.** `peers.yaml` had zero peers — the pod's own init container reported
`wg0.conf assembled: 0 peer(s)`. The duplicate-address check grepped the WHOLE file, and
`peers.yaml` line 66 is a **commented-out example stanza** I wrote, containing literally
`# AllowedIPs = 10.90.0.2/32`. So `.2` — the first address anybody would ask for, and the one my
own runbook uses in its example — was permanently un-allocatable, with a message asserting the
opposite of the truth.
Both halves fixed, because either alone leaves the trap half-set:
- **`infra/vpn-ingress/new-peer.sh`**: the check strips comment lines before matching
(`grep -vE '^[[:space:]]*#' | grep -q …`). That is the correct predicate rather than a patch: a
real stanza's `AllowedIPs` line is never commented, while both the template and every peer's
`# <name> — <owner>` label always are.
- **`platform/vpn-ingress/peers.yaml`**: the example now uses placeholders that cannot match
anything real — `# <device-name> — <owner>, enrolled <YYYY-MM-DD>` and
`AllowedIPs = 10.90.0.<octet>/32`. A future grep cannot be fooled by it either.
The bug class is worth naming: **a documentation example that is byte-identical to real data will
eventually be parsed as real data.** The same shape caused two earlier defects in this component —
`flux-postbuild-substitution` flagged a `${...}` written inside a comment *explaining* that hazard,
and `no-plaintext-secrets` flagged the `.sops.yaml.example` because the kustomization named it in a
comment. Three instances now; the rule is to make examples structurally unmatchable, not merely
commented out.
No sensitive paths (`platform/vpn-ingress/**`, `infra/vpn-ingress/**`, `decisions/**`), so this
merges on a green gate with no override.
## What I verified
Run in the pinned toolbox against the live tree, 2026-10-06.
- **`.2` now works**, and it went through the REAL key path: `new-peer.sh keith-phone 2` rendered a
profile with `Address 10.90.0.2/32`, `AllowedIPs = 10.90.0.2/32` in the printed stanza, full
tunnel, at `.cache/wg-peers/keith-phone.conf` (0600). The throwaway keypair was deleted after.
- **This incidentally closes a gap left open in #60**: the profile could only have been rendered by
deriving the server's public key via `sops -d --extract … | wg pubkey` from
`wg-server-key.sops.yaml` — so that pipeline is now proven **against the operator's real key**,
not just against a placeholder it was expected to reject.
- **A real duplicate is still refused.** Appending an uncommented
`AllowedIPs = 10.90.0.5/32` stanza and asking for octet 5 produced the correct refusal; the tree
was restored afterwards and `git diff --stat` showed only the intended template edit.
- **Zero active allocations remain**: `grep -vE '^[[:space:]]*#' peers.yaml | grep -c AllowedIPs`
→ `0`.
- **Toolbox gate**: rc 3, with `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`,
`l1` and `approval-policy` all pass; rc 3 is the two environmental skips (`gitea-settings`,
conformance L2/L3). **Not green. Reported as rc 3.**
## What I did NOT verify
- **No peer is enrolled by this branch.** The keypair I generated was a throwaway and was deleted;
the operator must run the script themselves, since the device's private key must exist only on
the device. So the end of the enrolment path — a stanza merged into `peers.yaml`, Flux rolling
the DaemonSet, and `wg show` listing a peer — is still unexercised.
- **The duplicate-NAME warning** (`grep -qi "^ *# *${NAME} "`) deliberately still inspects
comments, because per-peer labels are comments. It is no longer tripped by the template, but I
did not test it against a real enrolled peer.
- **Nothing about client traffic.** The WAN port-forward is still not configured and there is still
no off-LAN vantage point, so the tunnel has carried no packet.
@@ -0,0 +1,103 @@
---
date: 2026-10-06
branch: fix/peers-configmap-content-hash
title: A merged VPN peer never reached the tunnel — the peer register was a fixed-name ConfigMap, so nothing rolled the pods
---
## What changed
The operator enrolled a phone, merged the peer, and **`wg show` listed no peers.** Measured on prod
2026-10-06, with the peer already on `main`:
| observation | value |
|---|---|
| peer stanza on `main` | present (`AllowedIPs = 10.90.0.2/32`) |
| GitRepository artifact | `main@sha1:e1bebb8` — Flux had fetched it |
| `vpn-ingress` Kustomization `lastAppliedRevision` | `main@sha1:5928d81` — **behind**, 10m interval |
| live `wg-peers` ConfigMap, active `AllowedIPs` lines | **0** |
| DaemonSet volume reference | `configMap.name: wg-peers` — a **fixed** name |
| DaemonSet `generation` / `observedGeneration` | **1 / 1** — it had **never rolled** |
| init container, all three pods | `wg0.conf assembled: 0 peer(s)` |
The lag was only the reconcile interval. **The defect is the fixed name.** When Flux does update a
fixed-name ConfigMap it updates it *in place*: the DaemonSet's pod template is byte-identical, so
no rollout is triggered — and because the init container assembles `wg0.conf` **once at pod start**
into a tmpfs, the running tunnel keeps the old peer list indefinitely. Adding or revoking a peer
would have had **no effect at all** until something unrelated happened to restart the pods. For a
*revocation* that is a security defect, not an inconvenience: the operator would delete a stanza,
see a green PR, and the device would keep working.
My runbook and README both asserted "PR → merge → Flux rolls the DaemonSet (~1 min)". That was
never true.
**This repo already documents the fix, and I had read the file.** `runbooks/kustomization.yaml`:
*"The generated name carries kustomize's content hash, so editing any runbook rolls the server
automatically and a stale projection is impossible."*
So the register becomes a **content-hashed generator**:
- `platform/vpn-ingress/peers.yaml` (inline ConfigMap) → **`platform/vpn-ingress/peers.conf`**, a
plain file, plus a `configMapGenerator` in `kustomization.yaml`. The operator's enrolled peer was
carried across verbatim. Every line is WireGuard config and `#` is a legal comment there, so the
documentation now travels *inside the mounted file* instead of in a YAML wrapper that gets
stripped.
- **`namespace: vpn-ingress` added to the kustomization — REQUIRED, not tidiness.** A generated
ConfigMap inherits no namespace; every other object here declares `vpn-ingress`; and kustomize's
`nameReference` transformer will not rewrite a reference across a namespace boundary. Without it
the build emitted `ConfigMap wg-peers-m9kcckcb5t` while the DaemonSet still mounted the unhashed
`wg-peers`, which would not exist — the pods would wedge on a missing volume. I caught this only
by building the output and reading the volumes block.
- **`infra/vpn-ingress/new-peer.sh`**: `PEERS_FILE` repointed to `peers.conf`, and a missing
register is now **fatal** instead of skipped. The old guard was
`[ -r "$PEERS_FILE" ] && grep …`, so after the rename the only symptom would have been
duplicate-address detection quietly never firing again — the silent-skip shape this repo treats
as a defect in its own right.
- Stale `peers.yaml` references swept from the README, runbook, `daemonset.yaml`,
`networkpolicy.yaml`, the SOPS example, and the script's own **user-facing output** (it told the
operator to paste into "peers.yaml, under data.peers.conf", which would no longer exist).
- The runbook gained the verification that actually distinguishes this failure — the DaemonSet's
`generation` must *increase* after the merge — and a troubleshooting row for `0 peer(s)` after a
merge.
Flux's `prune: true` removes the orphaned fixed-name `wg-peers` ConfigMap on the next reconcile.
No sensitive paths (`platform/vpn-ingress/**`, `infra/vpn-ingress/**`, `runbooks/**`,
`decisions/**`), so this merges on a green gate with no override.
## What I verified
Built and run in the pinned toolbox, 2026-10-06 (kustomize v5.8.1 via `kubectl kustomize`).
- **The reference is now rewritten end to end**: generated `ConfigMap wg-peers-m9kcckcb5t` in
namespace `vpn-ingress`, and the DaemonSet mounts **`wg-peers-m9kcckcb5t`**. Before adding
`namespace:` the same build mounted the unhashed `wg-peers` — so the namespace line is load
bearing, measured both ways.
- **The hash tracks content, which is the whole mechanism.** `wg-peers-m9kcckcb5t` →
append a second peer → `wg-peers-dgg7ht9c74` → restore → `wg-peers-m9kcckcb5t`. Deterministic
and reversible, so an edit necessarily changes the pod template.
- **The operator's enrolled peer survived the file conversion byte-for-byte**: the active stanza in
`peers.conf` (`PublicKey = pBRSHrRy…`, `AllowedIPs = 10.90.0.2/32`) is identical to the one on
`main`, compared directly against `git show origin/main:platform/vpn-ingress/peers.yaml`.
- **The build still emits all five objects**: Namespace, ConfigMap, Secret, DaemonSet,
NetworkPolicy — and the `Namespace` object's own name is still `vpn-ingress` (the namespace
transformer did not rewrite it).
- **`new-peer.sh` works against the new register**: `spare-laptop 7` renders a profile and prints
"append this to platform/vpn-ingress/peers.conf"; `dupe 2` is **refused** because the operator's
real `.2` now occupies it — the first time the duplicate check has been exercised against real
data rather than against its own example.
- **Toolbox gate**: rc 3, with `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`,
`l1` and `approval-policy` all pass; rc 3 is the two environmental skips. **Not green. Reported
as rc 3.**
## What I did NOT verify
- **That the roll actually happens on the cluster.** The whole point of the change is unproven
until Flux applies it: the success criterion is the DaemonSet's `generation` increasing and
`wg0.conf assembled: 1 peer(s)` in the init log, followed by `wg show` listing the peer.
- **That the tunnel then carries traffic.** No handshake has ever been observed. The WAN
port-forward is reportedly configured but has never been exercised by a packet I can see, and I
have no off-LAN vantage point — runbook step 6 from cellular remains the only honest test.
- **Revocation**, which is the case where this defect was a security problem. Deleting a stanza
should now roll the pods and drop the peer; untested.
- **Whether the orphaned fixed-name `wg-peers` ConfigMap is actually pruned.** Reasoned from
`prune: true` on the Kustomization, not observed.
@@ -0,0 +1,126 @@
---
date: 2026-10-06
branch: docs/pm-agent-registry-pull-prereq
title: The nodes resolve gitea to the WAN, so the failing certificate is the router's — the second node process to need the issuer pin
---
## What changed
The image pull for `pm-agent` failed TLS verification, and the previous entry on this branch
honestly recorded it as **not diagnosed**. The operator asked for the diagnosis before any
registry design. This is it, and the fix turns out to be a pattern this repository already
has.
`infra/ansible/inventory/group_vars/k3s_prod/vars.yml` now pins
`gitea.{{ k3s_base_domain }}` → `192.168.2.20` in `node_baseline_hosts_pins`, beside the
Keycloak issuer pin that is there for the identical reason.
**It reaches the nodes the way every other node setting in this project does — through
Ansible, from the inventory.** Node configuration is Ansible's (and, for the dev VMs,
OpenTofu's); nothing here touches a node by hand. For prod specifically, Ansible is the
*only* surface: `infra/PROD-PROVISIONING.md` records that prod is 3× bare metal and that the
dev path (OpenTofu → Proxmox VM → cloud-init) does not apply, so there is no
cloud-init/tofu half to keep in parity for this. The one scoped, idempotent command:
cd infra/ansible && ansible-playbook playbooks/node-baseline.yml \
-e k3s_hosts=k3s_prod --tags hosts
`--tags hosts` runs ONLY the `/etc/hosts` task, so applying this pin cannot drift any other
baseline setting. Running the play is an operator action (it has become/root on prod), but
the configuration itself is declared, reviewed and version-controlled — not typed on a node.
The docs that described this as an undiagnosed blocker now describe the cause and the fix:
`platform/pm-agent/README.md`, `runbooks/pm-agent.md` (its symptom row now names the pin and
the play), and `docs/plans/pm-agent-issue-to-pr.md`.
Also recorded, in the two places an operator reads when creating the credential: **the agent
authenticates to Gitea with a local account and a scoped PAT, by decision** (operator,
2026-10-06), not with a federated identity. Written down because the alternative looks
obviously better until you check whether it is possible — see below.
## What I verified
Every step measured, read-only, in this order.
**The failing certificate is the router's, not the Gateway's.**
- containerd's error names TLS, not the digest: `tls: failed to verify certificate: x509:
certificate is not valid for any names, but wanted to match gitea.home.keithjackson.dev`.
"not valid for **any** names" means the presented certificate carries no SANs at all.
- `192.168.2.20` is the shared Gateway's own LoadBalancer VIP
(`gateway/cilium-gateway-shared`, from the Cilium pool `192.168.2.20-30`), and its `https`
listener serves `*.home.keithjackson.dev` with `wildcard-home-tls`. From the operator's
workstation the name resolves there and serves the real Let's Encrypt
`CN=*.home.keithjackson.dev` with correct SANs. **So the certificate and the Gateway are
healthy.**
- **The node asks a different resolver and gets a different answer.** Prod's
`node_baseline_dns_servers` is the LAN gateway, not Pi-hole — deliberately, as that file
explains. Public DNS for this zone is a CNAME to `wan.home.keithjackson.dev`: measured via
both `1.1.1.1` and `8.8.8.8`, `gitea.home.keithjackson.dev` →
`wan.home.keithjackson.dev.` → `136.51.115.252`, and the apex answers the same.
- **That address serves a certificate with no names.** Measured from the workstation against
the WAN address with the right SNI:
subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
issuer=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
No extensions in certificate
No extensions means no `subjectAltName`, which is exactly "not valid for any names". The
`CN=%D` is the router's placeholder.
**This repository had already measured the same thing, for Keycloak, and I did not need to
re-derive it.** `infra/ansible/inventory/group_vars/k3s_prod/vars.yml` says, above the pin:
"Since platform/gateway/gateway.yaml set an external-dns `target`, every public name is a
CNAME to wan.home.keithjackson.dev = the home WAN IP. The SOHO router does NOT hairpin: from
the LAN, WAN:443 answers with the router's own self-signed placeholder cert (measured from
k3s-prod-2/-3 2026-10-03: `curl` exit 60, ssl_verify_result=18)." Same cause, same symptom,
one name later. The pin exists precisely for node processes that must reach the Gateway
directly; containerd is one, and nobody had added it.
**The parity the pin has to satisfy:** `192.168.2.20` equals the live
`GATEWAY_VIP`, and `k3s_base_domain` equals the live `BASE_DOMAIN` — both read from the
cluster, not from the comment that claims prod serves `prod.home.keithjackson.dev` (it does
not; `flux-system/cluster-vars` reports `BASE_DOMAIN=home.keithjackson.dev` and
`GATEWAY_VIP=192.168.2.20`, and Ansible's `k3s_base_domain` is `home.keithjackson.dev`).
That comment is stale history.
**The credential decision, and why the obvious alternative is not available.** Gitea's API
accepts basic auth, PATs, SSH signatures and its own OAuth2 bearer tokens; as an OAuth2
provider Gitea implements **only the Authorization Code Grant** (plus PKCE/OIDC), so there
is no client-credentials grant, and nothing in Gitea validates a token issued by a
third-party OIDC provider. A Keycloak service-account token therefore cannot authenticate to
Gitea's API or to git-over-HTTP. Read from Gitea's own documentation
(`docs.gitea.com/development/api-usage`, `/development/oauth2-provider`), not assumed.
**Gate:** `./ci/gate.sh` in the pinned toolbox from a §0 worktree — see the branch's run; rc 3
with `gitea-settings` the only stage that did not run.
## What I did NOT verify
- **That the pin fixes the pull.** It is IaC, and nothing has applied it: `/etc/hosts` on the
nodes is written by the `node_baseline` play's `hosts`-tagged task, which is an operator
action. I did confirm the mechanism is not inert — `roles/node_baseline/tasks/main.yml`
renders `node_baseline_hosts_pins` into a marked `blockinfile` with `backup: true`, and
`state: absent` when the list empties, so a removed entry really leaves the node. Until that runs
and an image exists, "the node now reaches the Gateway" is a design claim. The evidence
that it *will* is the Keycloak pin working for the same cause — which I also did not
re-measure.
- **Why public DNS answers at all for an `internal` service.** `catalog/gitea.yaml` sets no
`exposure`, so it inherits `internal`, and the renderer contract says an internal service's
HTTPRoute carries `external-dns.alpha.kubernetes.io/controller: none` and publishes no
public record. Yet `gitea.home.keithjackson.dev` resolves publicly to the WAN CNAME. I did
not chase that: it may be a zone-level wildcard rather than a published record, and it did
not change the diagnosis. It is worth someone's attention, because "internal services
publish no public DNS" is an L0-checked claim and this looks like a case where the *name*
resolves regardless of what the renderer withheld.
- **Registry authentication.** Unchanged and still open: an unauthenticated client gets
nothing from this registry, and no `imagePullSecret` exists anywhere in the tree. With the
credential model now decided (local Gitea accounts with PATs) the shape is a
`dockerconfigjson` for an account whose token carries `read:package`, referenced from the
Deployment and the committed worker Job template — and the worker Job template's shape is
pinned by admission, so whether the policy should also pin `imagePullSecrets` deserves a
deliberate look rather than a quiet addition. Not built: minting that token is an operator
action, and I did not want to guess at a second credential's scope.
- **Anything about runtime.** The image still does not exist. No dispatcher has
authenticated, no issue has been triaged, no worker Job has been created, and the admission
policy — live, `Enforce`, six rules — has still never admitted or refused a real Job.
@@ -0,0 +1,113 @@
---
date: 2026-10-06
branch: fix/vpn-ingress-ip-forward
title: The VPN tunnel never came up — a hostNetwork pod cannot write net.ipv4.ip_forward, and wg-quick treats a failed PostUp as fatal
---
## What changed
The remote-access VPN reconciled for the first time with a real server key (#60 then #62) and
**the tunnel did not come up**. One line in my own manifest was the whole cause.
Measured in the `wireguard` container on prod, 2026-10-06:
```
[#] ip link add dev wg0 type wireguard
[#] wg addconf wg0 /dev/fd/63
[#] ip -4 address add 10.90.0.1/24 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] sysctl -q -w net.ipv4.ip_forward=1
sysctl: permission denied on key "net.ipv4.ip_forward"
[#] ip link delete dev wg0
**** Tunnel /config/wg_confs/wg0.conf failed, will stop all others! ****
```
Everything up to that point worked. **The cause is not a missing capability.** With
`hostNetwork: true` the runtime mounts `/proc/sys` **read-only** in the container — confirmed in the
pod's own `/proc/mounts`: `proc /proc/sys proc ro,nosuid,nodev,noexec,relatime` — precisely because
those sysctls are the *host's* globals and a container must not rewrite them. `CAP_NET_ADMIN` cannot
lift a read-only mount, so this can never succeed from here. And `wg-quick` treats any failed
`PostUp` as fatal, so it deleted the interface it had just finished building.
**The line was never needed.** `node_baseline` writes `net.ipv4.ip_forward=1` to
`/etc/sysctl.d/99-k3s-cilium.conf`, and reading the value from inside the pod returns `1`. I had
written the header comment *correctly* — "ip_forward is ALREADY 1 on these nodes … so no pod sysctl
is needed" — and then emitted the sysctl anyway, justified in its own comment as "re-asserted so
this component does not silently depend on another role's sysctl file surviving". That defensive
re-assertion is the entire outage. **The lesson is narrow and reusable: in a hostNetwork pod,
verify host state, never write it.**
Fix:
- **`platform/vpn-ingress/daemonset.yaml`**: the `PostUp = sysctl …` line is gone, replaced by a
**read** in the init container — `grep -qx 1 /proc/sys/net/ipv4/ip_forward` — which fails closed
with a message naming the `node_baseline` role. Reading `/proc/sys` is permitted; only writing is
not. Fail-closed still matters: a tunnel that comes up but cannot forward would satisfy every
probe in this component and silently drop every client's traffic.
- The header bullet that said "no pod sysctl is needed" now also says **do not attempt one**, with
the mechanism, so the next author does not re-add it for the same reason I did.
- The `securityContext` comment claimed NET_ADMIN was for "create the wg0 link, set the host sysctl,
and write the netfilter rules". The middle clause was false; corrected.
- The image's `chown: … Operation not permitted` startup run — plus its own "Permissions could not
be set … we will not provide support for it" — is now documented as **expected and harmless**, in
both the manifest and the runbook, so nobody chases it. Capabilities are dropped to `NET_ADMIN`
only, so its s6 init has no `CAP_CHOWN`; `/config` is a writable tmpfs, everything runs as root,
and `wg-quick` proceeds past it (as it did here — the failure was 30 lines later).
- **`runbooks/vpn-remote-access.md`**: a "what a healthy start looks like" block with the real log
lines; `ip_forward is 0` added to the init-failure table; and the note that `0/1 Running` with
`Startup probe failed: Unable to access interface: No such device` is the signature of `wg-quick`
tearing the tunnel down — read the `[#]` line it died on.
- **`platform/vpn-ingress/README.md`**: a table of what the first reconcile settled (below).
No sensitive paths: `platform/vpn-ingress/**`, `runbooks/**` and `decisions/**` only. This merges on
a green gate with no override.
## What I verified
Live prod, read-only, 2026-10-06. **Four questions this component's design rested on are now
measured rather than assumed — three went the right way, one was the defect above.**
- **The GitOps path is proven end to end.** `Kustomization vpn-ingress` `lastAppliedRevision:
main@sha1:cf7d5e4` equals the GitRepository artifact revision. The Secret carries
`kustomize.toolkit.fluxcd.io/name: vpn-ingress`, so Flux delivered it.
- **SOPS decryption works, proven by digest rather than by eye.** The value in
`platform/vpn-ingress/wg-server-key.sops.yaml` on `main`, decrypted, is 44 bytes, is **not** the
placeholder, and **`wg pubkey` accepts it**. Its SHA-256 equals the SHA-256 of the live Secret's
decoded `data.privatekey` (`d7c8f8de6f252649…` both sides). No key material was printed at any
point — lengths, booleans and digests only.
- **The `wg pubkey` guard fired correctly, and its `ENC[` branch was never needed.** During the
kubelet window before the updated Secret propagated, all three pods reported *"It is still the
committed REPLACE-ME PLACEHOLDER"* — the guard distinguishing a stale/unfilled value from
ciphertext, exactly as designed. It cleared on its own once the mount refreshed; **a transient
`REPLACE-ME` CrashLoop for a minute or two after the key PR merges is expected, not a fault.**
- **The kernel module reaches the pod**: `ip link add dev wg0 type wireguard` succeeded and
`/proc/modules` lists `wireguard`. So the `node_baseline` addition works and `CAP_SYS_MODULE` is
correctly not required.
- **`allowPrivilegeEscalation: false` does NOT break the image's s6 init** — the container reaches
`wg-quick` normally. The flag stays; the fear recorded in #55 is closed.
- **The iptables backends MATCH, which was my single biggest documented doubt.** The image ships
`iptables v1.8.13 (nf_tables)` via `/usr/sbin/xtables-nft-multi`, and Debian 13 is nf_tables. The
nightmare case — rules accepted into a table the kernel never consults, so handshakes succeed and
every forwarded connection hangs — cannot occur on this pairing.
- **`net.ipv4.ip_forward` is `1`** in the host netns the pod shares, and `/proc/sys` is `ro` there.
Both read directly from the pod.
- **Toolbox gate** from an isolated worktree: **rc 3**, with `spec-immutability`, `decisions-log`,
`lint`, `l0`, `render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two
environmental skips (`gitea-settings`, conformance L2/L3). **Not green. Reported as rc 3.**
## What I did NOT verify
- **That the tunnel now comes up.** This fix has not reconciled. The next reconcile is the test, and
its success criterion is specific: pods `1/1 Running`, and `wg show` reporting `interface: wg0`
with `listening port: 51820`.
- **That the MASQUERADE and FORWARD rules actually apply.** They are the next statements after the
one that failed, so **they have never executed**. The backends matching (above) removes the worst
failure mode but proves nothing about the rules themselves. First check after the pods go Ready:
`iptables -t nat -S POSTROUTING | grep 10.90.0.0/24` on a node.
- **Anything about client traffic.** Still zero peers enrolled, still no WAN port-forward, still no
off-LAN vantage point. The VPN has carried no packet.
- **Whether the transient `REPLACE-ME` window has an upper bound.** It cleared within ~2 minutes
here; I did not determine the kubelet refresh behaviour, so a longer stall after a key change is
uncharacterised.
- **The `sops set` hand-off command**, still. The operator filled the key in by their own route
(#62), so the exact quoting I documented inside `dev.sh run bash -c` remains unexercised.
+85
View File
@@ -0,0 +1,85 @@
---
date: 2026-10-07
branch: fix/bulk-nfs-truenas
title: Repoint bulk NFS at the TrueNAS (192.168.2.30); the PVs must be recreated, not reconciled
---
## What changed
Both ZFS pools were physically moved off the Proxmox host onto a standalone TrueNAS Community
Edition at **192.168.2.30**, which sits on prod's own `192.168.2.0/24`. `BULK_NFS_SERVER` moves from
`192.168.2.102` (the pfSense DNAT prod used to mount through) to `192.168.2.30`.
- `clusters/prod/kustomization.yaml` — `BULK_NFS_SERVER` → `192.168.2.30`. The comment block that
justified the DNAT, and the "NOT YET DECIDED / THIS PLACEHOLDER IS LOAD-BEARING" banner, are
replaced with the measured export configuration and the recreate requirement below.
- `clusters/base/cluster-vars.yaml` — base default `192.168.1.9` → `192.168.2.30`. That host no
longer exists.
- `clusters/dev/kustomization.yaml` — same; marked explicitly **inert**, since dev is decommissioned
and its nodes are gone. It carries the real address only so nothing in the tree renders a dead one.
- `docs/BULK-NFS-STORAGE.md` — §1 and §5 rewritten for TrueNAS (exports, service settings, the ZFS
`sharenfs` properties that had to be cleared, and the ACL hazard); §7 rewritten now that the
placeholder is gone; §9 retargeted from dev to prod; **new §10** documents the PV
delete-and-recreate. §2/§3/§4/§6 are unchanged by the move.
- `catalog/qbittorrent.yaml`, `platform/monitoring/node-pressure-dashboard.yaml` — two comment/prose
references to `192.168.2.102` as the live export address.
**The headline operational fact: repointing this knob is NOT a plain reconcile.** A bound PV's NFS
source is immutable, so Flux's apply fails rather than updating and the pods keep their old, dead
mount. The five PVs must be deleted and recreated. That sequence is §10 and is an operator action;
it is **not** performed by this PR.
## What I verified
**On the NAS (192.168.2.30), measured 2026-10-07 by the operator at my request:**
- `zfs list` — `Storage` → `/mnt/Storage`, `Storage/plex` → `/mnt/Storage/plex`,
`Storage/nextcloud` → `/mnt/Storage/nextcloud`. The PV paths resolve unchanged.
- Ownership intact: `/mnt/Storage/plex`, `tv/`, `downloads/`, `movies/`,
`downloads/complete/`, `nextcloud/nextcloud/` all `drwxr-xr-x 1000:3000`. `movies/` 1171 entries
and `tv/` 107, against 1165 / 107 measured on Proxmox.
- `acltype=nfsv4` (SCALE's built-in default, `SOURCE: default`), `aclinherit=passthrough`, and no
non-trivial ACLs — mode bits still govern.
- Three stale `sharenfs` dataset properties (`plex`, `nextcloud`, `docker-volumes`) carried over from
Proxmox; cleared with `zfs inherit`, re-checked empty.
- `exportfs -v`: both shares to `192.168.2.10/.11/.12` with `sec=sys,secure,root_squash,no_all_squash`,
`rw` on plex and `ro` on nextcloud.
- `/proc/fs/nfsd/versions` → `+3 +4 +4.1 +4.2`, so the PVs' `nfsvers=4.1` negotiates.
- **A defect was found and fixed during setup:** the plex share listed `192.168.2.13` — an address no
node holds — and so omitted `192.168.2.12` (k3s-prod-3). Corrected and re-verified.
**From the cluster:**
- Node InternalIPs are `192.168.2.10/.11/.12` (`kubectl get nodes`), confirming `.13` was wrong.
- All five NFS PVs currently name `192.168.2.102`.
- `192.168.2.30:2049` and `:111` accept connections; `192.168.2.102:2049` does not.
- **`spec.nfs` immutability, by server-side dry-run** — this is the finding that shapes the change:
`spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is immutable after creation`.
- The pods report `Ready` while their mounts are dead: a `stat /media` answers from attribute cache,
but a `readdir` blocks indefinitely (the PVs mount `hard`). Hence "scale to zero first" in §10.
**Gate:** see the PR; run in the pinned toolbox from this worktree.
## What I did NOT verify
- **Nothing has mounted 192.168.2.30.** No pod has touched the new server — the PVs still point at
the old address and cannot be updated in place. Every §9 probe (numeric-id passthrough, the uid-1000
write, the hardlink/EXDEV check, Plex's read-only posture) is **outstanding**, not passing. The
equivalents passed against the old host on 2026-09-12; that is not evidence about this one.
- **The §10 sequence is reasoned, not executed.** Retain + the renderer's pre-set `claimRef` are read
from the manifests and from `catalog/render.py`; I have not watched a PVC rebind after a PV delete
in this cluster.
- **`root_squash` is a behaviour change from the Proxmox export**, which carried `no_root_squash`. The
argument that nothing needs root on the tree (LSIO drops to 1000; the images chown only their
Longhorn `/config`; both subPath directories already exist at 0755) is reasoned from the images and
the measured permissions. It has not been exercised. If a mount or subPath resolution fails with
EACCES, Maproot `root:root` reproduces the old export and is the first thing to try.
- **Whether NFSv3 can be disabled.** Enabled Protocols is `NFSv3, NFSv4`; the PVs only need v4.1, so
narrowing would close the rpcbind/mountd/statd surface. Not done — it is a change to make after the
cutover is proven, not during it.
- **Whether pfSense / the legacy stack is actually retired.** I measured only that
`192.168.2.102:2049` stopped answering. The other legacy consumers of these datasets
(`192.168.1.6`, `10.123.1.10`, `192.168.1.18`) were dropped from the new exports; if any is still
live, it has lost access. Not checked.
- **L2/L3 conformance.** `vars.CLUSTER_AVAILABLE` is unset, so CI proves the L0/L1 offline subset
only. Nothing in the gate exercises an NFS mount.
+300
View File
@@ -0,0 +1,300 @@
---
date: 2026-10-17
branch: feat/automated-upgrades
title: Automated upgrades — kured + security-only unattended-upgrades, and Renovate finally gets a runner
---
## What changed
Two halves of one process. Neither is useful alone and they must land together.
### (a) OS upgrades and reboots
**`platform/kured/`** — the Kubernetes Reboot Daemon, chart `6.1.0` / app `1.23.0`, in its own Flux
Kustomization (`clusters/base/kured.yaml`, `dependsOn: [platform]`, `wait: true`), registered in
`clusters/base/kustomization.yaml`. Not a line in `platform/kustomization.yaml`: that tier is
`wait: true` and gates `platform-config` → every apps wave, so a wedged reboot daemon placed there
could stall every application in the cluster. A component added to make maintenance safer must not
be able to cause an outage.
**`infra/ansible/roles/unattended_upgrades/`** + `playbooks/unattended-upgrades.yml`, enabled for
prod in `inventory/group_vars/k3s_prod/vars.yml`. A **new** role rather than an extension of
`node_baseline` — different lifecycle, and other open branches touch that role.
**The design, and how it answers this repo's two standing objections to reboot automation.**
`roles/node_baseline/tasks/main.yml:61-67` says "IT DOES NOT REBOOT" (k3s-prod-2 hung on a warm
reboot 2026-09-03; physical power button required), and `docs/plans/cluster-upgrade-plan.md:291-299`
rejected system-upgrade-controller for "automating away the human gate between nodes". Both still
stand. **The human gate is the sentinel.** unattended-upgrades is security-only with the kernel
blacklisted, so it essentially never produces `/var/run/reboot-required`; that file appears when an
operator deliberately runs `node-baseline.yml --tags kernel`. kured then performs only the
*mechanical* half the operator would otherwise type by hand — including the ordering rule
`node_baseline`'s own output has to shout ("NEVER node 2 first"), which the operator keeps by
running the kernel play with `--limit <host>` one node at a time. `cluster-upgrade-plan.md:329-330`
names the condition under which its own rejection reverses (fast disks), and prod is that cluster.
**Quorum: concurrent reboots are structurally impossible.** All three nodes are control-plane+etcd
(quorum 2/3). `concurrency: 1` plus kured's single cluster-wide lock annotation on its own DaemonSet
means a node reboots only while holding that lock, enforced by API-server optimistic concurrency
rather than by timing. **`lockTtl: 0`** (expiry disabled) is the deliberate choice: a TTL exists so
reboots continue after the lock holder dies, which on 3 etcd nodes is exactly the catastrophe — a
hung node's lock expiring would let a second node take the cluster to 1/3. So a hang stops the cycle
forever and pages a human. `lockReleaseDelay: 30m` holds the lock after a node returns.
**The Longhorn gate.** Measured: 25 volumes, all `numberOfReplicas: 3`, one replica per node, with
`replica-soft-anti-affinity: false` and `disable-scheduling-on-cordoned-node: true`. So every drain
takes **all 25 volumes to degraded 2/3**, and recovery is serialized —
`replica-replenishment-wait-interval: 600`, `concurrent-replica-rebuild-per-node-limit: 1`. Reboot
the next node too early and volumes reach 1/3, where `node-drain-policy:
block-if-contains-last-replica` can block a drain forever. The inter-node gate is therefore **volume
robustness, not pod readiness**, and it is doubled: `lockReleaseDelay: 30m` (unconditional, covers
the 600s wait) plus kured's Prometheus alert gate (semantic, covers a rebuild longer than 30m).
**`alertFilterMatchOnly: true` is load-bearing.** The flag inverts the regexp's meaning: `false`
(chart default) makes it an *ignore* list and kured blocks on **any** firing alert. Measured
2026-10-17, five alerts were firing with no end in sight (`CNPGPrimariesCoLocated`,
`KubeDeploymentReplicasMismatch`, `KubeDeploymentRolloutStuck`, `KubePodNotReady`,
`OAuth2ProxyGateUnobserved` — the last three all tracing to `pm-agent`, ImagePullBackOff on a
placeholder digest for 12 days). Under the default, kured would have been blocked on day one and
never rebooted anything, indistinguishable from "no reboots needed". The allow-list is anchored:
`^(LonghornVolumeDegraded|LonghornVolumeFaulted|KubeNodeNotReady|NodeExporterDown)$`.
`NodeEtcdRequestsSlow` and the `NodeIOPressure*` family are **deliberately excluded** despite being
exactly what you'd want to pause for — prod-1 has a documented etcd WAL I/O starvation history and
including them risks a permanent wedge that looks like safety.
**The kernel floor (the highest-risk item), handled with two independent exclusions.** Measured: all
three nodes run `7.1.8+deb13-amd64` (`7.1.8-1~bpo13+1`, the backports metapackage), far above the
6.13 memattr floor — **but `linux-image-6.12.107` and `linux-image-6.12.94` are also still
installed, below the floor, bootable, and `apt-mark showhold` is empty on every node.** So: (1) the
allowed-origins list is `label=Debian-Security` only, and the running kernel comes from
`o=Debian Backports` (pin 100, the `NotAutomatic`+`ButAutomaticUpgrades` signature), so u-u cannot
*see* a kernel upgrade; (2) `^linux-` is blacklisted anyway, so widening origins later — the most
likely future edit — cannot re-arm the hazard. The role **asserts both against `apt-config dump`**,
apt's own parse of the whole directory, rather than trusting the file it just wrote. Also blacklisted:
`intel-microcode`, `amd64-microcode`, `grub-*`. `Remove-Unused-Kernel-Packages` is **false** against
the Debian default, because those older kernels are the documented rollback path. None of this stops
the kernel moving — the operator's deliberate Ansible run still does that, unchanged.
**The silent-no-op trap this closes.** Measured: `update-notifier-common`, `needrestart` and
`unattended-upgrades` were **absent on all three nodes**, so *nothing could create
`/var/run/reboot-required`*. kured deployed alone would have been permanently idle while 1/1 Running
and reporting `kured_reboot_required=0` — green forever. The role installs the sentinel writer and
**asserts a hook exists** by discovery (not a hard-coded path), failing loudly if not.
`needrestart` runs in auto mode with `k3s.service` blacklisted — auto-restart is what makes a
security-only policy effective, but restarting the control plane unattended is worse than a delayed
library fix. (k3s is not an apt package at all here: no `k3s`, no `containerd` in dpkg.)
**Alerts + runbook.** `platform/kured/alerts.yaml` — three rules over kured's *only* metric
(`kured_reboot_required`, one GaugeVec, read from `cmd/kured/main.go:92-96`) plus `kube_node_info`:
`KuredNotReporting` (`absent()` — the pipeline is dead and the other two are inert),
`KuredNodeCoverageGap` (`count(kured) < count(nodes)` — a node silently uncovered, which `absent()`
cannot see), `KuredRebootPending` (`for: 36h` — a reboot was offered and blocked). Companion
promtool suite `alerts.promtool-test.yaml` pins all three both ways, including that the rule
**stops** when a reboot succeeds. `runbooks/node-reboot-automation.md` registered in
`runbooks/kustomization.yaml`.
**The window is DAILY 03:00-06:00, and that choice is driven by the alert.** A weekly window means a
pending reboot legitimately waits 6 days, forcing `for: 8d`+ — longer than this cluster's 7d
Prometheus retention and reset by any restart past the 1h for-outage-tolerance. Daily caps the
legitimate wait under 24h and makes `for: 36h` both robust and meaningful.
### (b) Image / dependency automation
**Renovate was aspirational. It now has a runner.** `.gitea/workflows/renovate.yaml` — scheduled
daily 03:15 UTC, `workflow_dispatch` with `dry_run` defaulting to **true** for a safe first run.
Preflight **fails hard (exit 1)** if `RENOVATE_TOKEN` is missing rather than skipping, because a
skipped Gitea job reports `success` and that is precisely how `renovate.json` stayed inert.
**`renovate.json`: Renovate now owns both halves, and Flux image-automation is formally closed.**
ADR-0009 gave container image tags to Flux image-automation; that cannot be delivered on this
repo's terms. (1) `ImageUpdateAutomation` **pushes commits, it does not open PRs**, so it bypasses
the CLAUDE.md §1 gate; pointed at a side branch instead, nothing opens the PR. (2) It cannot work
here anyway: `main` is protected `user_can_push: false` and the `flux-system` GitRepository has **no
`spec.secretRef`** — the source is anonymous and read-only. (3) Enabling it means an in-cluster git
**write** credential for the repo that defines the cluster. So `docker`/`kubernetes` — disabled
*only* to avoid fighting that controller — are enabled, with `kubernetes.managerFilePatterns`
covering `platform/`, `platform-config/`, `apps/`, **`catalog/`** (apps are generated from it; a bump
only to the rendered output is reverted by the next render and fails `ci/render-golden.sh`),
`runbooks/serving/` and `infra/agent/`. Added a no-automerge `blast-radius` rule for `kured`,
`longhorn`, `cilium`, `cloudnative-pg`, `kube-prometheus-stack`, because the gate is static and does
not boot a node or form a quorum. `platform/kyverno/tests/**` added to `ignorePaths`.
### Sensitive paths — this PR needs a non-author approving review
It touches **`clusters/**`**, **`conformance/**`** and **`.gitea/**`**, all in
`infra/gitea/approval-policy.json`. It will **not** merge on a green gate alone. Per CLAUDE.md §1 I
have **not** posted `/approve-override` and must not — that is the self-approval the stage exists to
prevent.
**The `conformance/` edit, justified.** `+34/-0` in
`alert-metrics-have-scrape-targets/check.sh`: one new `SCRAPE_OWNERS` entry claiming `kured_`. It
**weakens nothing** — no existing owner's evaluation changes — and it *tightens* the check for my
own component, adding two `require` patterns (`service.create: true`, `metrics.create: true`; the
chart defaults **both** to false) plus a `companion` requirement that `networkpolicy.yaml` stay in
the kustomization. It had to be touched: assertion (3) fails any alert over an unclaimed metric, and
the escape hatch `KNOWN_UNWIRED_METRICS` is **shrink-only**, so declaring `kured_*` as debt was not
permitted and would have been wrong anyway. The alternative was shipping reboot automation with no
alerting, which for a component whose every failure mode is silent is the worse choice. The entry
carries a dated comment saying all of this.
## What I verified
Measured against the live cluster and nodes, read-only, on 2026-10-17.
- **Flux image-automation controllers ARE installed and idle.** `-n flux-system get deploy` shows
`image-automation-controller` and `image-reflector-controller` both `1/1`, age **43d**; all three
`image.toolkit.fluxcd.io` CRDs exist. And **zero** `ImageRepository`/`ImagePolicy`/
`ImageUpdateAutomation` objects — "No resources found" for each. The controllers have run for 43
days with nothing to do. `platform/kustomization.yaml:52` has the component commented out.
- **Renovate is aspirational, confirmed three ways**: no workflow in `.gitea/` or `ci/` (grep),
no Renovate CronJob/Deployment/Job in any namespace, and `renovate.json` itself disabled the
`docker`+`kubernetes` managers — so every plain `image:` tag was maintained by **nothing**, while
**seven files** claim "Renovate maintains this pin".
- **`flux-system` GitRepository has no `secretRef`** (empty), URL is the in-cluster Gitea, and its
artifact revision was `main@sha1:1a0b208…`, matching my base.
- **Nodes**: 3× Debian 13 trixie, all `control-plane,etcd`, k3s `v1.36.4+k3s1`, kernel
`7.1.8+deb13-amd64`. `apt-mark showhold` **empty**; `/etc/apt/preferences.d/` **empty**;
`/var/run/reboot-required` absent; `unattended-upgrades`/`update-notifier-common`/`needrestart`
all **absent**; `linux-image-6.12.107` and `6.12.94` installed alongside the backports kernel;
no `k3s`/`containerd` apt package; `apt-cache policy` origins and pins as quoted above.
- **No node taints** on any of the three (so no tolerations needed), and `system-node-critical`
exists.
- **Longhorn**: 1.12.1; `node-drain-policy: block-if-contains-last-replica`;
`replica-soft-anti-affinity: false`; `disable-scheduling-on-cordoned-node: true`;
`replica-replenishment-wait-interval: 600`; `concurrent-replica-rebuild-per-node-limit: 1`;
25 volumes all 3-replica/healthy; 25 replicas per node.
- **6 PDBs with 0 allowed disruptions**: 3× `longhorn-system/instance-manager-*` (structurally 0 —
single-node selector, `minAvailable: 1`) and 3× CNPG `*-primary`. All three Postgres primaries
were on `k3s-prod-1`.
- **kured drain flags do not exist.** Read `cmd/kured/main.go:438-449`: `Force`,
`DeleteEmptyDirData` and `IgnoreAllDaemonSets` are **hardcoded true**. Passing
`--ignore-daemonsets`/`--delete-emptydir-data`/`--force` as extraArgs would CrashLoop the
DaemonSet. All three are genuinely needed (12 DaemonSets, 135 emptyDir pods measured).
- **kured exposes exactly one metric** — the only `MustRegister` in the project.
- **Prometheus selectors are all `{}`** (`serviceMonitorSelector`, `serviceMonitorNamespaceSelector`,
`ruleSelector`, `ruleNamespaceSelector`), so a `PrometheusRule`/`ServiceMonitor` in a new namespace
is picked up with no label plumbing.
- **`count(kube_node_info) == 3`** and `count(longhorn_volume_robustness) == 100`, queried through a
read-only port-forward. The 5 chronically-firing alerts were read from `/api/v1/alerts`.
- **`LonghornVolumeDegraded`** is `longhorn_volume_robustness{state="degraded"} == 1`, `for: 10m` —
shorter than Longhorn's own 600s replenishment wait, so degradation reliably outlasts the latch.
- **kured chart 6.1.0 → appVersion 1.23.0** is the newest of 80 entries in the chart index.
- **Kyverno/PSA will not block it**: every policy is scoped
`app-namespace: "true"`; the namespace sets it explicitly `"false"`, and
`restrict-host-namespaces.yaml`'s own header states that carve-out. No cluster-wide PSA baseline.
- **`renovate.json` parses** as valid JSON after my edits (`json.load`).
- **kured is not installed** and the string `kured` appears nowhere in the repo — greenfield.
### CORRECTION 2026-10-18 — the first real run FAILED, and the sentinel design was wrong
`ansible-playbook playbooks/unattended-upgrades.yml -e k3s_hosts=k3s_prod` failed on all three
nodes at the package task:
```
fatal: [k3s-prod-1]: FAILED! => "No package matching 'update-notifier-common' is available"
```
**`update-notifier-common` is an UBUNTU package and does not exist in Debian.** Measured on trixie:
`apt-cache policy update-notifier-common` → `Candidate: (none)`. The original entry asserted this
package "owns the hook that creates /var/run/reboot-required" — true on Ubuntu, false here, and the
role could never have worked. This was the exact risk the first version of this entry flagged ("the
`find` patterns are a guess at which package ships what") landing in a worse place than predicted:
not a wrong glob, a nonexistent package.
Three further measurements settled what to do instead:
* `/usr/share/update-notifier/notify-reboot-required` is **absent**.
* **No `linux-image-*` postinst references `notify-reboot-required`** — invoking it is an Ubuntu
patch, so merely placing a script at that path would never be called.
* No hook under `/etc/apt/apt.conf.d` or `/etc/kernel/` mentions `reboot-required`.
So Debian trixie has **no stock mechanism at all**, and the role now ships the writer itself:
`/usr/local/sbin/k3s-notify-reboot-required` (compares the running kernel against the newest
installed via `linux-version`, from `linux-base`, which every `linux-image` depends on — measured
`linux-base 4.12.1` present) plus a `DPkg::Post-Invoke` hook at
`/etc/apt/apt.conf.d/99-k3s-reboot-required`. `/run` is a tmpfs (measured), so the sentinel
self-clears on boot and needs no cleanup task.
The verify assert changed shape too: it was a `find` glob over `/etc/kernel/postinst.d` and
`/etc/apt/apt.conf.d` for `*update-notifier*` / `*unattended-upgrades*`. That glob was **weaker
than it looked** — it would have matched Debian's own `unattended-upgrades` apt conffile and
reported "sentinel writer present" while no writer existed. It now stats the two exact paths this
role owns and requires the script to be executable.
**Newly verified (this correction):** the sentinel script was executed on prod-1 as the
unprivileged `k3s` user under `sh -x`. It resolved `running=7.1.8+deb13-amd64`,
`newest=7.1.8+deb13-amd64`, took the equal branch, exited 0 and wrote nothing — correct, since the
running kernel *is* the newest. `sh -n` and `bash -n` clean.
**Still NOT verified after this correction:** the *writing* half. Because running == newest on all
three nodes, the `touch` path has never executed, so I have not seen the sentinel actually created,
nor seen kured react to it. The honest test is the operator's next kernel upgrade. Also unverified:
whether a `DPkg::Post-Invoke` hook fires in the `unattended-upgrades` code path specifically (it
runs dpkg, so it should, but that is inference) — and whether `linux-version sort --reverse` orders
a mixed backports/stable kernel set the way I assume when a 6.12 and a 7.1 are both installed. It
produced the right answer with the current set; a future set could differ.
## What I did NOT verify
The honest list. Most of this needs an actual upgrade cycle, which I did not and could not run.
- **I never ran an OS upgrade, never rebooted a node, and never watched kured take a lock.** No part
of the runtime behaviour of either half has been observed. `sudo` needs a password I do not have,
so I could not run `apt`, `unattended-upgrade --dry-run`, or install anything. **Everything about
(a) working is inference from configuration and measured cluster state, not observation.**
- **The Ansible role has never been executed.** Not syntax-checked by `ansible-playbook --syntax-check`
either (no inventory access from the gate). Its asserts, its `apt-config dump` parsing, and the
sentinel-hook discovery are all unproven. The `find` patterns for the sentinel hook are a *guess at
which package ships what* — I could not read `update-notifier-common`'s file list (packages.debian.org
returned no such package for that suite/arch), which is exactly why the role discovers rather than
hard-codes. **If no hook matches, the role fails loudly — but it may fail on a correct node.**
- **Whether CNPG actually switches the primary in response to a drain-induced eviction.** All three
`*-primary` PDBs are structurally 0-disruption and `.spec.nodeMaintenanceWindow` is unset on all
three clusters. If the operator does not switch over, draining `k3s-prod-1` blocks on three PDBs at
once and hits `drainTimeout`. **This is the most likely way the first real reboot fails.** It
should be tested with one manual, watched `kubectl drain` before trusting the timer.
- **Whether `drainTimeout: 15m` is long enough.** Unmeasured. The `unifi` VMI has
`evictionStrategy: LiveMigrate` with `migrationMethod: BlockMigration`, i.e. a full root-disk copy
over the network on every `k3s-prod-1` drain. I do not know how long that takes.
- **Whether the alert gate actually blocks as intended.** I never saw kured query Prometheus. The
assumption that it treats a query error as "do not proceed" is from its documented behaviour, not
observed. The NetworkPolicy allowing kured→Prometheus:9090 and Prometheus→kured:8080 is
**unproven**; if either is wrong the symptom is silence, which is why `KuredNotReporting` exists —
but that alert is itself unproven against a live scrape.
- **The alert expressions have not run against real `kured_*` data**, because the metric does not
exist in this cluster yet. They pass promtool unit tests against synthetic series whose shape I
read from kured's source. `absent()` and the `count() < count()` comparison are the two most
likely to behave differently against real label sets.
- **`enabledManagers: ["regex"]` may be the wrong name.** Modern Renovate expects `custom.regex` for
`customManagers`. I left the pre-existing value alone rather than guess, but **the inline
`# renovate:` markers may simply not run.** Unverifiable without executing Renovate. Two markers
are additionally known-broken independent of this: `external-snapshotter`'s sits two comment lines
above its `newTag`, and Keycloak's `kustomization.yaml` has no marker at all despite a comment
claiming Renovate maintains it.
- **Renovate has still never run.** I added the runner; `RENOVATE_TOKEN` does not exist yet, so the
first scheduled run **will fail** (by design). Nothing about the config is proven correct until a
`dry_run: true` dispatch is read by a human. Expect it to want to open ~30 PRs.
- **No alert for "automatic patching silently stopped".** u-u's success lives in
`/var/log/unattended-upgrades/` on each node and nothing reads it. Likewise **no alert for the
Renovate schedule ceasing to fire**. Both are real gaps I did not close.
- **`coredns` is 1 replica and I did not fix it.** It is k3s-managed, out of this PR's scope, and it
means every `k3s-prod-1` reboot causes a cluster-wide in-cluster DNS gap. Recommended follow-up.
- **kured cannot honour "NEVER node 2 first".** It has no ordering control. The mitigation — run the
kernel play with `--limit` one host at a time — is a *procedure*, documented in the runbook, not an
enforced property. If the operator flags all three nodes at once, kured picks the order.
- **kured cannot press a power button.** The 2026-09-03 warm-reboot hang is unmitigated. Bounded, not
prevented: quorum holds at 2/3, the lock is held forever so nothing cascades, and
`KuredRebootPending` eventually fires.
- **Upstream version claims are a snapshot, partly from search results**, not all from authoritative
release APIs. I could not determine latest for versitygw, crafty, nginx-unprivileged, act_runner,
`virt-template-*`, the linuxserver wireguard date-tag, or the exact latest PostgreSQL 16 patch
(sources conflicted 16.10 vs 16.15). **The gitea `1.24.3 → v28.1.0` gap is almost certainly an
upstream versioning-scheme change, not 26 majors — do not act on that framing.**
- **I did not verify Renovate's `kustomize` manager still processes `images:`/`newTag`** now that
`docker` is enabled, which decides whether `external-snapshotter` and `intel-gpu-plugin` are
managed at all.
- **I did not raise any HelmRelease version ceiling**, so the 9 minor-locking ranges still cap
Renovate at patches. Bumping `kube-prometheus-stack` in particular will fail the gate until
`ci/versions.env`'s exact promtool/Prometheus `3.14.0` lockstep moves with it.
- **L2/L3 conformance did not run** (`vars.CLUSTER_AVAILABLE` unset), so nothing here is proven
against a cluster by CI either.
@@ -0,0 +1,106 @@
---
date: 2026-10-17
branch: feat/graceful-cluster-shutdown
title: "Graceful node shutdown was never configured, and scaling a StatefulSet deleted its PVC"
---
## What changed
All three nodes had to be powered off to move them onto a UPS. There was **no procedure for it**:
`infra/cluster.sh stop` is `virsh`-based and only ever applied to the decommissioned dev cluster, so
the whole sequence was improvised live. Two defects came out of doing it.
**1. Kubelet graceful node shutdown was unconfigured on every prod node.** No `shutdownGracePeriod`
anywhere in `infra/ansible/`, so `shutdown -h` stopped k3s, containerd went with it, and every pod
was SIGKILLed where it stood — 9 CloudNativePG instances mid-checkpoint, the UniFi VM, and 25
attached Longhorn volumes. `roles/k3s_server` now writes `kubelet-arg` into
`/etc/rancher/k3s/config.yaml` with `shutdown-grace-period=90s` and
`shutdown-grace-period-critical-pods=20s`.
The half that is easy to miss and silently defeats the other half: **graceful node shutdown is
enforced by logind, not by the kubelet.** The kubelet holds an inhibitor *delay* lock and systemd
waits at most `InhibitDelayMaxSec` — **default 5 seconds** — before proceeding regardless. A grace
period above that cap is truncated with no error, so the feature looks configured and still
hard-kills pods. The role now ships a `logind.conf.d` drop-in setting it to 120, and **asserts**
`k3s_logind_inhibit_delay_max_sec >= k3s_kubelet_shutdown_grace_period` rather than documenting the
relationship, because the failure is invisible.
**2. Scaling a StatefulSet to 0 DELETED its PVC and Longhorn volume.** `monitoring/loki` carries
`persistentVolumeClaimRetentionPolicy: {whenDeleted: Delete, whenScaled: Delete}`, so the quiesce's
`scale --replicas=0` destroyed `storage-loki-0` outright. Loki stores with `type: filesystem`, so
that PVC was the only copy of its log data. This is the defect in this change set that actually
destroyed something, and it was mine.
New `infra/maintenance/quiesce.sh` (`quiesce` | `resume` | `status`) encodes the sequence that
worked, and **refuses to scale any StatefulSet whose `whenScaled` is `Delete`** — it leaves it
running, accepts that its volume hard-detaches at poweroff, and prints which volumes to expect
`degraded`. A rebuild is a vastly better outcome than a deletion. Workload discovery is computed
from the live cluster (every Deployment/StatefulSet whose pods mount a PVC, Longhorn or TrueNAS NFS)
rather than hardcoded, so a newly onboarded service is covered without anyone remembering to add it.
`runbooks/cluster-maintenance-shutdown.md` (new, registered in `runbooks/kustomization.yaml`) is the
operator page: shutdown order, the TrueNAS-after-nodes constraint, power-on order, the
un-hibernate-Postgres step Flux will not do for you, and a "what looks broken but is fine" section
separating the `4/19`-Kustomizations-Ready cascade, the degraded-and-rebuilding volume and the
self-recovering `keycloak-operator` from the one thing that genuinely needs a human (a stuck
`oauth2-proxy` replica).
## What I verified
* **Graceful shutdown was genuinely absent**, not merely undocumented: `grep -rn
'shutdownGracePeriod|graceful-node-shutdown'` over `infra/ansible/` returns nothing, and no
`logind` or `InhibitDelay` setting exists anywhere in the tree.
* **The whole shutdown/restart cycle was executed end to end on prod 2026-10-17** and the cluster
came back: 3/3 nodes Ready with etcd `healthz` ok, 19/19 Flux Kustomizations Ready,
`platform-config`'s `lastAppliedRevision` equal to the GitRepository artifact revision, all 3
CNPG clusters `healthy` with primaries elected, the UniFi VM `Running`, PiHole resolving
(`home.keithjackson.dev` → `192.168.2.20` via `192.168.2.23`), and the Gateway serving
`200/200/401` for apex/gitea/plex.
* **The PVC-deletion mechanism is confirmed, not inferred:** `kubectl get sts loki -o
jsonpath={.spec.persistentVolumeClaimRetentionPolicy}` returns
`{"whenDeleted":"Delete","whenScaled":"Delete"}`, the PVC was `NotFound` after the cycle, and the
Longhorn volume count had gone 25 → 24.
* **The damage was repaired and verified:** `scale sts loki --replicas=1` (restoring the replica
count the chart declares) recreated `storage-loki-0` as a fresh 50Gi Bound PVC, `loki-0` reached
`2/2 Running`, and the volume count returned to 25.
* **The guard works against the live cluster**, tested both ways: `sts_deletes_pvc_on_scale
monitoring loki` returns true (would be skipped) and `pihole pihole` returns false (safe to
scale).
* **The script's read-only paths run clean on prod:** `status` reports correctly, and the discovery
function returns the 13 workloads currently holding a PVC.
* `bash -n` clean; the two edited Ansible YAML files parse.
## What I did NOT verify
* **The kubelet change has NEVER BEEN APPLIED OR TESTED.** This is the significant gap. I did not
run the role, did not restart k3s, and have **not** observed a node actually drain on
`shutdown -h`. Everything about it is reasoning from kubelet/systemd semantics plus the measured
absence of the setting. Applying it needs a **rolling** k3s restart (`serial: 1` — kube-vip keeps
the API up, etcd holds at 2/3), which is an operator action (CLAUDE.md §5). **Verify by draining
one node for real:** apply, then `shutdown -h now` on a single node and watch whether pods
terminate gracefully instead of vanishing.
* **Whether 90s/20s are the right numbers.** Chosen to comfortably cover a Postgres fast shutdown
and a Longhorn flush; not measured against either. The budget is spent on every reboot, so if a
node takes noticeably longer to go down, this is why.
* **Whether k3s accepts `kubelet-arg` in `config.yaml` as written.** The file already carries
`kube-apiserver-arg` as a list and `kubelet-arg` is the documented sibling, but I did not render
or apply it, so a typo would surface only at k3s restart — and a bad `config.yaml` is exactly the
kind of thing that makes k3s fail to come up. **Review this one before applying.**
* **The `assert` task's `regex_replace` on the grace period** assumes a plain `90s`-style value; a
value expressed as `1m30s` would parse to the nonsense integer `130`. Fine for the committed
defaults, wrong if someone writes a compound duration.
* **`resume`'s Flux-reconcile wait has never run**, and neither has `quiesce` as a script — the
2026-10-17 cycle was done with the equivalent commands by hand, so the script encodes a sequence
that worked but has not itself been executed end to end. Only `status` and the discovery and guard
functions have run.
* **Loki's lost log window is gone for good.** Up to 72h of logs (its `retention_period`), destroyed
by my scale-down. Nothing recovers it; Velero's schedules cover the namespace but the PVC was
deleted, not corrupted, and I did not attempt a restore because the data self-expires anyway.
* **No other `whenScaled: Delete` StatefulSet was audited.** The guard now catches them generically,
but I did not enumerate which other workloads carry the policy, so I cannot say loki was the only
one at risk. The runbook gives the one-liner to list them.
* **TrueNAS and the router are entirely outside this change.** TrueNAS is not in the Ansible
inventory and refuses SSH, so its shutdown stays a web-UI step with no automation and no check.
* **No runtime conformance.** `vars.CLUSTER_AVAILABLE` is still unset, so CI proves the L0/L1
offline subset only.
* I did not re-measure branch protection or runner state (CLAUDE.md §2b).
+311
View File
@@ -0,0 +1,311 @@
---
date: 2026-10-17
branch: feat/keycloak-passkeys
title: Passkeys declared for the homelab realm — and the realm import will not apply them
---
## What changed
`platform/keycloak/keycloak-realm-import.yaml` now declares passkey (WebAuthn Passwordless)
login for the `homelab` realm, in two parts:
1. **The WebAuthn Passwordless policy.** Ten `webAuthnPolicyPasswordless*` fields on the realm:
RP ID `${BASE_DOMAIN}`, RP entity name `Homelab SSO`, signature algorithms `[ES256, RS256]`,
`requireResidentKey: "Yes"`, `userVerificationRequirement: required`,
`authenticatorAttachment: "not specified"`, `attestationConveyancePreference: none`,
`acceptableAaguids: []`, `createTimeout: 300`, `avoidSameAuthenticatorRegister: true`.
Deliberately the **passwordless** family only; the two-factor `webAuthnPolicy*` family is left
at Keycloak's defaults because nothing in this realm uses `webauthn-authenticator`.
2. **The browser flow.** `browser-mfa` keeps its alias and stays bound as `browserFlow`; its
interior is restructured so a passkey is an **ALTERNATIVE** beside the password form:
```
browser-mfa (top level, unchanged)
browser-mfa-forms ALTERNATIVE
auth-username-form REQUIRED <- was auth-username-password-form
browser-mfa-passkey-or-password REQUIRED <- NEW sub-flow
webauthn-authenticator-passwordless ALTERNATIVE <- NEW: the passkey
browser-mfa-password-and-otp ALTERNATIVE <- NEW sub-flow
auth-password-form REQUIRED <- was half of the combined form
browser-mfa-conditional-otp CONDITIONAL (unchanged, relocated intact)
```
The design decisions, with their reasoning, are written into the manifest comments in this repo's
usual density. The ones that matter:
* **Additive, not replacing. Enrolment optional, not forced.** This IdP gates Grafana, Gitea,
Nextcloud, Headlamp, Semaphore, PiHole, Crafty, the \*arr stack *and* `kubectl` via the
`kubernetes` OIDC client. A mandatory passkey flow that misfires is a total lock-out from the
platform and the cluster API at once, leaving only the Keycloak bootstrap admin and the
break-glass cert kubeconfig. The gain from forcing it (one human stops typing a password) does
not price that risk.
* **No `requiredActions` block was added, on purpose.** Declaring one would have been the obvious
way to add `webauthn-register-passwordless`, and it would have been actively dangerous — see
"What I verified".
* **RP ID is `${BASE_DOMAIN}`, not `keycloak.${BASE_DOMAIN}`.** A passkey's RP ID is fixed at
enrolment and can never be re-keyed, so it must not be bound to a name that can move. This realm
already declares an `auth.${BASE_DOMAIN}` redirect URI, and this repo already owns a reconciler
built because a hostname move silently failed to reach the realm. Residual risk, unavoidable:
changing `BASE_DOMAIN` itself invalidates every enrolled passkey.
* **A passkey login is not also asked for TOTP.** `browser-mfa-conditional-otp` hangs off the
password branch only. The justification is `userVerificationRequirement: required` — the
ceremony already proves possession plus a verified human, phishing-resistantly and origin-bound.
Stacking a replayable shared secret on top of that buys friction. The manifest names the exact
one-line edit to reverse this decision.
* **Login becomes two screens** (username, then password) for every user, passkey or not, because
the server must know who is logging in before it can decide whether a passkey exists. Accepted
cost; it is the enabling change.
* **Usernameless login deliberately not used**, although Keycloak 26.0.6 supports it — see "What I
verified" for the measured reason it would have made every login open on a passkey prompt.
`platform/keycloak/README.md` gains a `## Passkeys` section: the decision table, the fact that
this does not reach the live realm, the two operator paths that would apply it, and the four-step
browser verification nobody has run.
No secret, key, credential or AAGUID was minted or committed. No file under `clusters/**`,
`conformance/**`, `ci/**`, `platform/kyverno/**`, `**/rbac.yaml` or `**/*.sops.yaml` was touched,
so this should not trip `ci/approval-policy.sh`'s sensitive-path review requirement.
## What I verified
**The live realm import has already completed and will never re-run.** Measured on prod,
2026-10-17:
```
$ kubectl --context k3s-prod -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}'
{"conditions":[{"message":"","status":"True","type":"Done"},
{"message":"","status":"False","type":"Started"},
{"message":"","status":"False","type":"HasErrors"}]}
```
`Done=True` / `Started=False`, exactly the one-shot state `platform/keycloak/README.md` already
documents for client secrets and client URLs. **So the change in this PR has zero effect on how
anyone logs in today**, and the client-secret/client-URL reconciler does not and cannot close the
gap: its hard endpoint allow-list makes `/authentication` unreachable by design, and realm-level
policy fields are not client fields.
**The CRD accepts every field I used.** Measured against the live CRD (not the upstream docs):
`kubectl --context k3s-prod get crd keycloakrealmimports.k8s.keycloak.org` lists all twelve
`webAuthnPolicyPasswordless*` properties with the types I wrote (`RequireResidentKey`,
`RpEntityName`, `RpId`, `UserVerificationRequirement`, `AttestationConveyancePreference`,
`AuthenticatorAttachment` as `string`; `SignatureAlgorithms`, `AcceptableAaguids`, `ExtraOrigins`
as `array/string`; `CreateTimeout` as `integer`; `AvoidSameAuthenticatorRegister` as `boolean`),
plus `authenticationFlows`, `authenticatorConfig` and `requiredActions`. The `spec.realm` schema
has **no** `x-kubernetes-preserve-unknown-fields`, so a misspelled field would have been pruned
silently rather than rejected — which is why this was checked rather than assumed.
**Running Keycloak is 26.0.6.** `quay.io/keycloak/keycloak:26.0.6` on `keycloak-0`/`keycloak-1`,
operator `quay.io/keycloak/keycloak-operator:26.0.6`. Every source claim below is read from the
`26.0.6` tag, not from memory or from docs.
**Every provider ID is quoted from upstream source, not guessed.**
| String I used | Where it is defined at tag 26.0.6 |
|---|---|
| `webauthn-authenticator-passwordless` | `WebAuthnPasswordlessAuthenticatorFactory.PROVIDER_ID`, display "WebAuthn Passwordless Authenticator" |
| `auth-username-form` | `UsernameFormFactory.PROVIDER_ID`, display "Username Form" |
| `auth-password-form` | `PasswordFormFactory.PROVIDER_ID`, display "Password Form" |
| `webauthn-register-passwordless` | `WebAuthnPasswordlessRegisterFactory.PROVIDER_ID` (referenced in comments only) |
**`requiredActions` is REPLACE, not merge — measured, and it is why I did not declare one.**
`model/storage-private/.../DefaultExportImportManager.java` lines 327-338:
```java
if (rep.getRequiredActions() != null) {
for (RequiredActionProviderRepresentation action : rep.getRequiredActions()) { ... }
DefaultRequiredActions.addDeleteAccountAction(newRealm);
} else {
DefaultRequiredActions.addActions(newRealm);
}
```
Declaring one required action therefore skips the entire default set. `RealmManager` is no
backstop: `setupRequiredActions` is `if (realm.getRequiredActionProvidersStream().count() == 0)`,
and the count is already non-zero by then (`RepresentationToModel.importRealm` runs at
`RealmManager.java:585`, the backstop at `:612`). A realm built that way would have had the passkey
action plus `delete_account` and nothing else — **no `CONFIGURE_TOTP`**, which would leave
`auth-otp-form` demanding a TOTP that no platform-admin could ever enrol: a hard lock-out
delivered by a change whose purpose was to *add* a login method.
**And declaring it would have bought nothing.** `DefaultRequiredActions.addWebAuthnPasswordlessRegisterAction`
registers `webauthn-register-passwordless` with `setEnabled(true)`, `setDefaultAction(false)`,
priority 80, gated on `Profile.Feature.WEB_AUTHN` — which is `Type.DEFAULT` in
`common/.../Profile.java:74`, i.e. on unless explicitly disabled. `enabled=true` /
`defaultAction=false` *is* the additive-and-optional posture this change wants.
**`authenticationFlows` has the opposite semantics, which is why declaring flows is safe.**
`importAuthenticationFlows` adds the declared flows, then backfills each unbound binding
(browser, registration, direct grant, reset credentials, client auth, first broker login, docker
auth) from the built-in defaults individually (`DefaultExportImportManager.java` ~1320-1410).
**The graceful degradation to password is measured from the flow engine, not assumed.** For a user
with no passkey: `WebAuthnPasswordlessAuthenticator.requiresUser()` returns `false`, so
`DefaultAuthenticationFlow.processSingleFlowExecutionModel` takes the `else` branch (`daf:456-462`),
whose four conditions all hold — a user is set (from `auth-username-form`), `configuredFor` is
false, `WebAuthnPasswordlessAuthenticatorFactory.isUserSetupAllowed()` is `false`, and
`WebAuthnAuthenticator implements Authenticator, CredentialValidator<...>` — so it raises
`CREDENTIAL_SETUP_REQUIRED`. The ALTERNATIVE loop (`daf:289-302`) catches exactly that, marks the
execution `ATTEMPTED` and proceeds to the next alternative, with the upstream comment *"the user,
since he has alternatives, should be able to go to another alternative and continue"*. Hence: no
passkey, no passkey screen, straight to the password form.
**Why usernameless was rejected, measured.** The `configuredFor` guard sits inside the
`requiresUser()` branch. With `requiresUser()` false and no user yet in context, that guard is
skipped and the engine just calls `authenticate(context)` — so a passkey execution placed before
the username form is offered to *everyone*, including users who will never own one.
**The policy sentinels are read from the shipped code, and one of my initial assumptions was
wrong.** `Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED = "not specified"` is the
realm-representation sentinel — but `WebAuthnConstants.OPTION_NOT_SPECIFIED = ""` (empty string) is
the WebAuthn-API-level one. I checked both because they disagree. The login theme's
`webauthnRegister.js` at 26.0.6 settles the behaviour:
```js
if (input.requireResidentKey !== 'not specified') {
if (input.requireResidentKey === 'Yes') { authenticatorSelection.requireResidentKey = true; }
else { authenticatorSelection.requireResidentKey = false; }
}
...
if (input.createTimeout !== 0) { publicKey.timeout = input.createTimeout * 1000; }
rp: {id: input.rpId, name: input.rpEntityName}
```
So `"Yes"` is a literal string compare (any other non-sentinel value means *false*), `"not
specified"` is compared literally for four fields, and `createTimeout` is **seconds** with `0`
meaning "send no timeout". It also shows 26.0.6 emits only the legacy `requireResidentKey` member,
never `residentKey: "required"` — the client maps that per WebAuthn L2, but the policy cannot
express `residentKey: "preferred"` at all.
`[ES256, RS256]` is exactly `WebAuthnPolicy.DEFAULT_POLICY` /
`Constants.DEFAULT_WEBAUTHN_POLICY_SIGNATURE_ALGORITHMS` — declared explicitly, not changed.
`getWebAuthnPolicyPasswordless` was read field by field to confirm `[]` for `acceptableAaguids`
means "no restriction" and that `RpEntityName` must be non-empty or the **update** path
(`DefaultExportImportManager.java:842`) drops the whole policy silently.
**The manifest parses and renders.** `yaml.safe_load` round-trips the file and the flow graph reads
back exactly as designed (five flows, the executions and priorities above, `browserFlow:
browser-mfa`, `requiredActions` absent). `python3 ci/render-keycloak-client-urls.py --check` →
`client-urls.yaml is in sync`, rc 0 (no client URL changed, so no regeneration was needed).
`yamllint -c ci/yamllint.yaml platform/keycloak/keycloak-realm-import.yaml` → rc 0; no line exceeds
the 160-column ceiling.
**`browser-mfa` keeps its alias deliberately.** `conformance/tests/l2-behavioral/keycloak-config-as-code`
asserts `kubectl get keycloakrealmimport homelab -o jsonpath={.spec.realm.browserFlow} | grep -q
'browser-mfa'`. Renaming the top-level flow would have failed that test.
**Envsubst safety.** `platform-config`'s Flux Kustomization declares
`postBuild.substituteFrom: cluster-vars` (`clusters/base/platform-config.yaml:26-29`), so this file
is substituted. The only brace token I added is `${BASE_DOMAIN}`, which is declared at
`clusters/base/cluster-vars.yaml:42` (`home.keithjackson.dev`). Every comment I wrote avoids braces
entirely — the RP-ID discussion says `keycloak.<BASE_DOMAIN>` with angle brackets precisely so an
unparseable token cannot fail the whole Kustomization.
**Gate result: rc 3, zero failures, `gitea-settings` the only stage the gate counts as not-run** —
the documented honest best local outcome. Run in the pinned toolbox from this worktree with all
three §3 workarounds (`DEV_KUBECONFIG=/dev/null`, plus an `ENGINE` shim bind-mounting the main
checkout's `.git` and the sibling spec repo with `SPEC_REPO=/home-network-k3s`):
```
[ ok ] stage spec-immutability: pass <- NOT skipped; both shim mounts worked, R-TEST-06 covered
[ ok ] stage decisions-log: pass <- archive frozen, 48 entries well-formed
[ ok ] stage lint: pass
[ ok ] stage l0: pass <- incl. flux-postbuild-substitution + keycloak-client-secret-reconciler
[ ok ] stage render-golden: pass
[ ok ] stage l1: pass
[ ok ] stage approval-policy: pass <- no sensitive path touched, so no non-author review needed
[skip] stage gitea-settings: SKIPPED <- needs SETTINGS_API_TOKEN, CI-only; expected
[skip] stage conformance: SKIPPED <- cluster-gated L2/L3; expected
MERGE GATE: UNDETERMINED — Stages that did not run: gitea-settings
```
No lint finding and no check failure names either file I changed. Touched requirements resolved to
`R-IDP-01 R-IDP-02 R-IDP-03 R-IDP-04 R-IDP-05 R-IDP-06`; coverage 51/51, 0 gaps.
**A note on how that number was obtained, because the first attempt was worthless.** My first gate
run returned the same rc 3 and the same seven passes — against a tree that did not contain this
change at all. The gate's `tracked_files()` reads **git**, and my edits were still uncommitted, so
every stage gated `origin/main`. The tell was in the log and I nearly missed it: the
touched-requirement subset reported UNDETERMINED with *"check out the feature branch you mean to
gate (not the base branch)"*, and `decisions-log` counted **47** entries rather than 48. **A green
gate run on an uncommitted worktree is a gate run on the base branch.** The numbers above are from
the second run, after committing and rebasing.
**The base moved under me mid-run.** `origin/main` advanced from `9a41a8e` to `1a0b208` (PR #70,
oauth2-proxy OIDC startup race) while I was working. This branch is rebased onto `1a0b208`, so the
gate above ran on the base CI will actually merge into. The rebase was clean — PR #70 touched
`platform/oauth2-proxy/helmrelease.yaml` and its own decisions entry, disjoint from these files.
## What I did NOT verify
**This change is unapplied. It changes nothing about the live realm, and I did not attempt to make
it.** `Done=True` / `Started=False` was measured, not inferred, and I ran no mutating `kubectl`,
no `flux reconcile`, no admin-API call and no `kcadm`. Whether the operator's eventual
admin-console reconstruction of this flow matches what is declared here is, by construction,
unverifiable from git.
**I never enrolled a passkey and never pointed a browser at any of this.** Nothing here has been
exercised by a real WebAuthn ceremony on any device. Specifically unverified:
* **That the RP ID works.** `home.keithjackson.dev` is a registrable-domain suffix of
`keycloak.home.keithjackson.dev`, so it *should* satisfy the browser's check — but I verified
that by reading the WebAuthn rule, not by watching a browser accept it. A wrong RP ID fails
**silently, in the browser, before Keycloak is involved**, with no server-side log line. If
enrolment produces no OS prompt and no error, this is the first suspect.
* **That `"not specified"` is the right literal for `authenticatorAttachment` in a realm import.**
I traced it to `Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED` and to the theme's string
compare, but I have not seen a realm import accept it. Omitting the field would default to the
same value; if an import ever rejects the literal, drop the line.
* **That `createTimeout: 300` behaves.** Seconds-vs-milliseconds is read from the theme JS. The
claim that 300 is long enough for the cross-device QR/hybrid flow, and that Chrome clamps rather
than rejects it, is reasoning about browser behaviour, not a measurement.
* **Whether a passkey user can still reach the password form.** I argued it from
`createAuthenticationSelectionList` producing a "Try another way" option, and I noted that for a
user holding both credentials the *first* screen is chosen by the user's own credential ordering
rather than by my priorities. Neither was observed.
* **The two-screen login regression for ordinary users.** `auth-username-form` +
`auth-password-form` replacing `auth-username-password-form` is a visible change for every user
including non-passkey family accounts, and nobody has logged in through it.
* **That conditional OTP for platform-admins still fires on the password branch.** The sub-flow and
its `conditional-otp-platform-admin` config are byte-identical and merely relocated one level
deeper, but "identical YAML" is not "still enforced at runtime". This is the highest-value
regression check and it is untested. The previous breakage of this exact step
(`condUserRole` vs a non-existent property key) 403'd every platform-admin login.
* **That a platform-admin authenticating by passkey is not challenged for TOTP.** That is the
intended behaviour of the shape I wrote; it is unobserved, and it is also a deliberate
relaxation of R-IDP-04 as previously implemented, which the operator may want to reverse.
**Whether the realm import reconciles into an existing realm: measured as NO for the completed
one-shot, but I did not test a re-import.** I did not delete and recreate the
`KeycloakRealmImport`, so I cannot say what a re-run would do to the existing `homelab` realm —
whether it would merge the new policy and flows, duplicate flow aliases, or fail. The 2026-09-06
total SSO outage documented in `platform/keycloak/README.md` is the reason I did not experiment.
**Treat the re-import path in the README as untested advice, not a runbook.**
**Nothing was proved about the live realm's current state.** I did not read the realm's existing
required actions, its existing WebAuthn policies, or its live flow executions — doing so needs the
admin credential and I did not decrypt or use it. So I cannot confirm that the live realm's
required-action set actually contains `webauthn-register-passwordless` today; I can only show that
Keycloak 26.0.6's defaults register it and that this repo's import never declared any required
actions to suppress them. **That inference has not been checked against the realm.**
**L2/L3 conformance did not run** (no cluster from the gate container; `vars.CLUSTER_AVAILABLE` is
unset in CI anyway), so the existing `keycloak-config-as-code` behavioural test did not re-execute
against this change. I reasoned about its assertion by reading it.
**No new conformance test was added.** Nothing in `conformance/` asserts the passkey policy or the
new flow shape, so a future edit can silently remove either and no gate stage will notice. That is
a real gap this PR leaves open; closing it would touch `conformance/**`, which is a sensitive path
needing non-author review.
**The quoted gate run predates this file's own gate-result paragraph.** It gated commit `207f5db`;
the gate numbers and the rebase note above were then written into this entry, so the pushed tree
differs from the gated one by this handoff's prose only — no manifest, no check, no generated file.
`decisions-log` is the only stage that reads this file, and it validates structure (frontmatter,
the three headings, non-stub bodies), which is unchanged. I did not re-run the full gate for a
prose edit; CI will gate the pushed commit.
**`gitea-settings` did not run locally** — it needs `SETTINGS_API_TOKEN`, which exists only in CI.
I did not re-measure branch protection, the runner, or `required_approvals` against the Gitea API
this session; the claims in `CLAUDE.md` §2 are taken on trust here and are explicitly flagged there
as needing re-measurement.
@@ -0,0 +1,99 @@
---
date: 2026-10-17
branch: fix/oauth2-proxy-oidc-startup-race
title: "oauth2-proxy dies on a cold start because OIDC discovery runs once, before Keycloak serves"
---
## What changed
Exposed by the 2026-10-17 whole-cluster shutdown for the UPS move. oauth2-proxy performs OIDC
discovery **once, at startup**, and `os.Exit(1)`s if it fails. On a cold boot Keycloak is not
serving yet, so the shared Gateway answers 503 and the proxy dies:
```
[provider.go:55] Performing OIDC Discovery...
[main.go:73] ERROR: Failed to initialise OAuth2 Proxy: error initialising provider: ...
failed to discover OIDC configuration: unexpected status "503": no healthy upstream
```
It then sits in **exponential CrashLoopBackOff long after Keycloak is healthy** — one of the two
replicas was still down minutes later and only a manual `kubectl delete pod` cleared it. There is no
retry flag; the process does not survive a cold start. A readinessProbe cannot help (the container
is already dead), and CrashLoopBackOff backoff is not tunable per-pod.
`platform/oauth2-proxy/helmrelease.yaml` gains an `extraInitContainers` gate (`wait-for-idp`) that
polls Keycloak's discovery doc until it answers, then lets the proxy start. A pod in `Init` does not
accrue CrashLoopBackOff backoff and retries promptly, which is the point.
**Bounded, and fails open.** After ~5 minutes it gives up and starts the proxy anyway, so the worst
case is exactly today's behaviour and never something new. An unbounded wait would trade a CrashLoop
for a pod wedged in `Init` forever, which is not an improvement.
## Why this shape and not the alternatives
* **`extraInitContainers` is the chart's own documented hook for precisely this.** Its commented
example in `values.yaml` is literally `- name: wait-for-idp`. The key was **confirmed against the
pinned chart** (`helm show values oauth2-proxy/oauth2-proxy --version 10.7.1` → `extraInitContainers: []`
at line 428), not assumed — a wrong Helm values key is silently ignored, which is the
"reviewed, merged, apparently done, still broken" shape this repo keeps getting bitten by.
* **No new base image.** The chart already runs an `alpine` init container for its built-in
`initContainers.waitForRedis` gate, so alpine is a base this chart uses regardless. Pinned to
`3.22` (verified to exist upstream) rather than the chart's own `latest`.
* **Flux `postRenderers` rejected:** it would work independently of the chart schema, but nothing in
this tree uses postRenderers, so it would introduce a mechanism with no precedent for a problem
the chart already has a hook for.
* **`--skip-oidc-discovery` rejected:** it removes the startup fetch entirely, which is arguably
more elegant, but it means hardcoding four Keycloak endpoints and I could not verify offline that
oauth2-proxy then fetches JWKS lazily rather than at startup. oauth2-proxy gates SSO for PiHole,
the \*arr apps, qBittorrent, Homepage, Prometheus, Alertmanager and Hubble — a wrong guess locks
the operator out of all of them, so the reversible, additive option wins.
The `$$`-free convention matters here: shell variables are written bare (`$i`, `$url`, `$(( ))`)
with **no braced form anywhere**, because `platform` declares `postBuild.substituteFrom cluster-vars`
and envsubst consumes braced expansions — a braced shell variable renders to the empty string and
silently breaks the loop. `${BASE_DOMAIN}` is braced on purpose. Same hazard and same convention as
the gitea and nextcloud OIDC bootstrap hooks, and `ci: flux-postbuild-substitution` enforces it.
## What I verified
* **The failure is measured, not theorised.** The log above is from the live pod
(`oauth2-proxy-5d6c74876b-bzb2p`, `lastState.terminated: {reason: Error, exitCode: 1}`), captured
with `--previous` after the cold boot. The surviving replica's log shows the same startup
succeeding once Keycloak was up, which is what identifies this as a race rather than a config
fault.
* **Deleting the pod is a sufficient workaround** — done on prod 2026-10-17, the replacement came up
clean and both replicas have been healthy since.
* **The chart key is real**, at the pinned version range: `helm search repo` resolves
`>=10.0.0 <11.0.0` to chart `10.7.1` (app `7.15.5`), whose values carry `extraInitContainers: []`,
`extraContainers: []` and the built-in `initContainers.waitForRedis` (which uses `alpine`).
* **`docker.io/library/alpine:3.22` exists** — confirmed against the registry tag list rather than
assumed from memory.
* The edited HelmRelease parses as YAML.
## What I did NOT verify
* **UNAPPLIED. The init container has never run.** I did not reconcile the HelmRelease, so I have not
seen `wait-for-idp` execute, exit 0, or gate the proxy. Applying it rolls both oauth2-proxy
replicas, which is a mutating action (CLAUDE.md §5) — and it rolls the component that gates SSO for
seven apps, so **do it when you can watch it**, not unattended.
* **I did not re-create the cold-start race to prove the fix.** That needs Keycloak down while
oauth2-proxy starts; the honest test is the next whole-cluster boot. Until then this is reasoned
from the measured failure, not demonstrated against it.
* **busybox `wget`'s TLS behaviour here is assumed, not tested.** The claim is that alpine's
`ca-certificates-bundle` lets it validate the Gateway's LE-prod certificate, so a genuine TLS
failure exits non-zero rather than passing falsely. If that is wrong in either direction the gate
degrades safely — a never-succeeding probe just burns the 5-minute budget and then starts the proxy
anyway — but the fast path would be silently dead and this would be doing nothing. **Check the
init container's log on first apply**: it should print "Keycloak discovery reachable" within
seconds on a warm cluster, not the give-up message.
* **Whether a pod can reach the Gateway hostname at all from this namespace is inherited, not
re-proven.** The main container does exactly this fetch today and succeeds, so the path is known
good — but note the standing trap that an arbitrary pod gets 403 from every Gateway hostname
(`decisions/2026-10-04-plex-advertise-plexdirect.md`); oauth2-proxy is exempt only because
Keycloak's ingress policy admits it. An init container in the same pod inherits that identity, so
this should hold — unverified.
* **60 attempts x 5s is not tuned.** It is "comfortably longer than Keycloak's observed cold start",
not a measured bound.
* **No runtime conformance.** `vars.CLUSTER_AVAILABLE` is unset, so CI proves the L0/L1 offline
subset only.
* I did not re-measure branch protection or runner state (CLAUDE.md §2b).
+108
View File
@@ -0,0 +1,108 @@
---
date: 2026-10-18
branch: fix/coredns-ha
title: "coredns ran at one replica, so any node drain was a cluster-wide DNS outage"
---
## What changed
**MEASURED on prod 2026-10-18: `kube-system/coredns` had `replicas: 1`.** In-cluster DNS was a
single pod on a single node, so draining or rebooting that node was a **cluster-wide in-cluster DNS
outage** until the pod rescheduled and became ready — every Service lookup, every CNPG replica
finding its primary, every operator addressing the API by name. It was invisible because `1/1
Ready` looks perfectly healthy.
It also became urgent rather than latent: the scheduled reboot automation on
`feat/automated-upgrades` drains and reboots one node at a time, unattended, overnight — turning a
single point of failure into a recurring one.
New `platform/coredns/hpa.yaml`: a `HorizontalPodAutoscaler` on the coredns Deployment with
`minReplicas: 2`, `maxReplicas: 3`, registered in the existing `platform/coredns/kustomization.yaml`
alongside the split-horizon ConfigMap already there.
**Why an HPA and not `replicas: 2` — this is the whole decision.** k3s owns the coredns Deployment
and we cannot durably edit it: it is applied by k3s's own deploy controller from a bundled manifest
(`AddOn/coredns`, source `/data/k3s/server/manifests/coredns.yaml`, `managedFields` managers
`deploy@k3s-prod-1` and `k3s`, plus an `objectset.rio.cattle.io/hash` label). k3s re-stages its
bundled manifests on startup, so a `replicas: 2` written there — or a `kubectl scale` — is reverted
on the next k3s restart or upgrade. Two alternatives were weighed and rejected:
* **`coredns.yaml.skip` + our own templated copy** (the pattern `roles/kube_vip` already uses for a
file in `server/manifests`). Deterministic and never flaps, but it takes ownership of coredns away
from k3s permanently — k3s upgrades would stop updating it and the version would need refreshing
by hand forever. Too high a price for a replica count.
* **A Flux-managed patch of the Deployment.** Rejected outright: Flux and k3s's deploy controller
would both own one object and fight continuously — the exact shape of the `default-deny`
NetworkPolicy drift loop this cluster already suffered (~2500 events/10min).
An HPA avoids both: a separate object k3s does not own, driving the `/scale` subresource rather than
the Deployment spec, so there is no shared-ownership fight. If k3s re-stages `replicas: 1`, the HPA
sees it below `minReplicas` and scales back within one reconcile.
It is a **floor-holder, not an autoscaler**: the CPU metric exists only because an `autoscaling/v2`
HPA needs one to be valid. `scaleDown.stabilizationWindowSeconds: 600` because DNS is the worst
thing in the cluster to flap; `scaleUp` is immediate, since that is the path that restores redundancy
after a k3s restart.
**Immediate action taken outside this PR:** `kubectl -n kube-system scale deploy coredns
--replicas=2` was run at the operator's explicit request, so the cluster has two replicas **now**
rather than after merge. That hand scale is what this PR makes durable.
## What I verified
* **The defect:** `kubectl -n kube-system get deploy coredns` → `replicas=1 available=1`.
* **k3s ownership:** the `AddOn/coredns` object exists with source
`/data/k3s/server/manifests/coredns.yaml`; the Deployment has no `ownerReferences` but carries
`objectset.rio.cattle.io/hash` and `managedFields` managers `deploy@k3s-prod-1` / `k3s`. So it is
k3s-managed, not Flux-managed — independently corroborated by another agent, which confirmed
`platform/coredns/` contains **only** the `coredns-custom` ConfigMap and does not touch the
Deployment or its replica count.
* **The hand scale works and holds:** coredns went to `2/2 Running` on two different nodes, and
in-cluster DNS kept answering throughout (`nslookup kubernetes.default.svc.cluster.local
10.43.0.10` → `10.43.0.1`). Still `spec.replicas=2 ready=2` after ~2 minutes, with the AddOn
checksum **unchanged** — so k3s's deploy controller does not revert on a drift timer; it
re-applies on manifest change and on restart.
* **The HPA's preconditions hold:** coredns ships `requests: {cpu: 100m, memory: 70Mi}` (so a
Resource/Utilization metric is meaningful), and metrics-server is installed as a k3s addon and
verified serving — `kubectl top pod -l k8s-app=kube-dns` returns **5m and 4m** against that 100m
request, i.e. ~5% utilisation, confirming the 80% target will never be approached and the HPA will
sit at `minReplicas`.
* **The kustomization edit is additive:** `git diff --stat` is `1 file changed, 4 insertions(+)`,
and `- coredns` was already registered in `platform/kustomization.yaml`, so no change was needed
there.
* All three touched/added YAML files parse.
## What I did NOT verify
* **The HPA has never been applied.** I did not reconcile it, so I have **not** observed it create,
reach `ScalingActive=True`, resolve its metric against real coredns data, or hold `minReplicas`.
Applying is an operator action (CLAUDE.md §5). The failure mode to watch for is
`ScalingActive=False` with `FailedGetResourceMetric` — the HPA would then be present and inert,
which looks identical to working because the hand scale already left 2 replicas in place. **Check
`kubectl -n kube-system describe hpa coredns` after merge**, not just the replica count.
* **The central claim that k3s reverts `replicas` on restart is REASONED, NOT OBSERVED.** It rests
on k3s re-staging bundled manifests at startup plus the measured ownership evidence above. I did
**not** restart k3s to watch the revert happen — doing so is a rolling control-plane restart, an
operator action. If k3s in fact preserves a modified replica count, this HPA is unnecessary
(though harmless).
* **I did not confirm the HPA wins the race against k3s's deploy controller after a restart.** Both
write the scale; the expectation is the HPA re-asserts within a reconcile, leaving a brief window
at 1 replica. Neither the window's length nor the outcome has been measured.
* **I nearly shipped a destructive change.** My first write of
`platform/coredns/kustomization.yaml` replaced the file wholesale, listing only `hpa.yaml` — which
would have pruned the existing `coredns-custom` ConfigMap and **broken in-cluster split-horizon
DNS (R-DNS-01/03)**. It was caught by a guard assertion before commit and restored from `HEAD`, and
the diff is now additive. Recorded because the near-miss is the useful part: `platform/coredns/`
already existed and I assumed it did not. **No conformance check would obviously have caught the
removal** — it is valid YAML and a legitimate-looking prune.
* **Whether 2 is enough.** Three nodes, and coredns' anti-affinity only *prefers* spreading, so both
replicas could in principle land on one node and a single drain would still take DNS down. I did
not add a `topologySpreadConstraint` or a PodDisruptionBudget — both arguably belong here and
neither is in this change.
* **No alert fires if coredns drops to 1 replica**, so a future silent regression (k3s upgrade
changing the manifest, the HPA going inert) would not be reported. That gap is not closed here.
* **No conformance test asserts this**, so a future edit could remove the HPA with no gate stage
noticing.
* **No runtime conformance.** `vars.CLUSTER_AVAILABLE` is unset, so CI proves the L0/L1 offline
subset only.
* I did not re-measure branch protection or runner state (CLAUDE.md §2b).
@@ -0,0 +1,76 @@
---
date: 2026-10-18
branch: fix/ha-check-catastrophic-regex
title: "One regex made the merge gate take 28 minutes instead of 2"
---
## What changed
The merge gate started taking **~28 minutes** instead of its usual **<2 minutes**, on every change
touching `infra/**`. One regex was responsible for essentially all of it.
`conformance/tests/l0-static/ha-control-plane-as-code/check.sh` contained:
```python
join_when = re.search(r"--server https://.*?\n(?:.*\n){0,10}?\s*when:\s*k3s_server_mode\s*==\s*'secondary'",
K3S_TEXT, re.S)
```
With `re.S`, every `.` also matches newlines, so `.*?` and the nested `(?:.*\n){0,10}?` can each
consume the entire document. That is **nested unbounded repetition** — ten levels of a quantifier
that can each swallow everything — and a failed or late match backtracks exponentially.
Replaced `.` with `[^\n]*` and dropped `re.S`, so the `{0,10}` is a genuine ten-line window with
nothing to backtrack into. Same intent, same verdict, no DOTALL.
**Why it surfaced now, and it was mine.** `K3S_TEXT` is every `*.yml` in `roles/k3s_server`
concatenated. The regex was latent for as long as it existed; **PR #71 added ~100 lines to that role
(the kubelet graceful-shutdown config), which grew the input past the point where exponential
backtracking stopped being survivable.** Nothing about the check changed — my change grew its input.
**Why only `infra/**` changes were slow.** `ci/touched-requirements.sh:268` maps `infra/*` to
`R-ENV-01`, and the resulting L0 run is `--include-scope whole-repo --requirements R-ENV-01`, which
pulls in this whole-repo check. Doc-only and `platform/**` changes never selected it, which is why
#69 (1m55s) and #72 (1m53s) stayed fast while #71 (27m44s), the #73 merge (27m46s) and #75 all ran
~28 minutes.
## What I verified
* **The hot spot, by measurement rather than reading.** `docker top` on the live CI job container for
run #299 showed `python3 -` with **19:54 of CPU time** under
`ha-control-plane-as-code/check.sh`, inside
`conformance/run.sh --layer l0 --include-scope whole-repo --requirements R-ENV-01`.
* **Reproduced locally**: the check did not finish within a 100-second budget on an unmodified
checkout.
* **Localised to the exact line** by extracting the inline Python and running it under
`faulthandler.dump_traceback_later(25)`, which named `re/__init__.py:177 in search` called from
the `join_when` line — not inferred from reading the patterns.
* **The pattern is unique in the repo**: `grep -rlnE "\{0,[0-9]+\}\?" conformance/tests/l0-static/*/check.sh`
returns only this file, so there is no sibling with the same hazard.
* **The fix is fast AND verdict-preserving**, tested against the real `K3S_TEXT` (22,179 chars, 403
lines): the new regex matches (`YES`) in **0.2 ms**. Sanity-checked that the thing it looks for
genuinely exists — one `--server https://` and three `k3s_server_mode == 'secondary'` occurrences.
* **The whole check now runs in 0.199s, rc 0, 115 assertions**, with the same success message as
before.
## What I did NOT verify
* **I did not re-time the full gate in CI after the fix.** The expectation is that `infra/**` changes
return to the ~2-minute range, but that is arithmetic from the 19m54s figure, not a measured
post-fix run. The PR's own CI run is the first real datapoint — read it.
* **I did not audit the other 23 L0 checks for different performance pathologies.** This one was
found because it was actively burning CPU in a live job; a check that is merely slow (not
exponential) would not have stood out, and `ci/gate.sh` has **no per-stage timing** at all
(`grep -nE "SECONDS|date \+|elapsed"` finds nothing), so there is no data on where the remaining
time goes.
* **Nothing prevents this class of bug returning.** No check asserts gate stage runtime, and no lint
forbids nested quantifiers under `re.S`. A timing budget per stage, or a `re` lint, would close it;
neither is written here.
* **I did not verify my claim about *why* only `infra/**` was affected beyond the path mapping and
the observed durations.** The correlation is strong (5 runs, two clear clusters) and the live
process list confirms the `--requirements R-ENV-01` scope, but I did not read
`conformance/run.sh`'s scope-selection logic end to end.
* The regex rewrite assumes the `when:` clause is indented with spaces or tabs only (`[ \t]*`). A
file using some other whitespace before `when:` would now fail to match where the old DOTALL
version would have succeeded. Unlikely in YAML, and the current input matches, but it is a
narrowing.
@@ -0,0 +1,334 @@
---
date: 2026-10-18
branch: feat/keycloak-passkey-reconciler
title: "Make the passkey realm config converge via GitOps — policy, enrolment action and the browser flow, the flow blue/green so the bound login path is never mutated"
---
## What changed
**Why this is a new branch and not an update to PR #72.** The brief for this work said to extend
PR #72 (`feat/keycloak-passkeys`) in place and not open a new PR. That premise was already stale
when I started: **PR #72 merged at 2026-10-18T05:44:04Z** (Gitea API: `state: closed`,
`merged: true`, `merge_commit_sha: 4d489f2`), and `git log origin/main` carries
`4d489f2 Merge pull request '…' (#72)`. A merged PR cannot be updated, and CLAUDE.md §1 leaves
exactly one path to `main`. So this is a fresh branch from `origin/main` in its own worktree, and a
new PR. For the same reason this is a **new** `decisions/` entry rather than an edit to
`decisions/2026-10-17-keycloak-passkeys.md`, which is now merged history describing a different unit
of work; it is the entry to read first for the passkey *design* decisions, which are not repeated
here.
**The problem.** PR #72 declared the WebAuthn passwordless policy and a restructured `browser-mfa`
flow in `platform/keycloak/keycloak-realm-import.yaml`. That declaration **could not take effect**.
A `KeycloakRealmImport` is a one-shot; prod's reads `Done=True / Started=False / HasErrors=False`,
so the operator will never re-run it and every realm-level field or flow declared after the first
import is inert — in git, applied by Flux, reported Ready, reaching the realm never. Merging it
changed nothing about how anyone logs in, and nothing anywhere said so. Same failure class as the
2026-09-06 client-secret incident, third field over.
**What now makes it converge.** A **sibling** reconciler, modelled on
`platform/keycloak/client-secret-reconciler.yaml`:
- `platform/keycloak/passkey-reconciler.yaml` — ConfigMap (Python, stdlib only, **zero dollar
signs**) + CronJob on `*/15 * * * *`, `automountServiceAccountToken: false`, admin credential by
`secretKeyRef` only. Three passes, **all of which converge**:
1. **WebAuthn passwordless policy.** Strict read-modify-write of `GET`/`PUT /admin/realms/homelab`,
changing only the `webAuthnPolicyPasswordless*` keys that differ, readback-verified.
2. **`webauthn-register-passwordless` required action.** Strict read-modify-write of one
**literally-named** alias; converges `enabled: true` / `defaultAction: false` only.
3. **The browser authentication flow — BLUE/GREEN.** Build the declared tree under a new, unused
alias; verify it by readback; flip `browserFlow` in **one atomic PUT**.
- `ci/render-keycloak-passkey-policy.py` + the **generated**
`platform/keycloak/passkey-desired-state.yaml` — the desired state, rendered from the realm
import, `--check`ed by the merge gate.
- `conformance/tests/l0-static/keycloak-passkey-reconciler/` — `check.sh`, `chainsaw-test.yaml`
(`scope: whole-repo`), `mutations.py`.
- `platform-config/kustomization.yaml` — both new resources, in the **same** Kustomization as the
realm import.
- `platform/keycloak/README.md` — the layout table, the section heading, and the "does NOT reach the
live realm" section rewritten: all three declared things now converge, with the blue/green
mechanics, the rollback command and the `--no-bind` fallback written out.
### The flow design was reconsidered, and the first version of it was wrong
**This is worth recording because the superseded reasoning was not silly, it was just wrongly
scoped.** The first design of pass 3 was **audit-only**: compare the live browser flow against git,
report divergence loudly, and never write it — leaving the restructure as a one-time admin-console
action. The reasoning was that converging a five-flow / ten-execution tree needs create-subflow,
create-execution, set-requirement and bind-config as *separate* untransacted admin-API calls, so a
run dying between them leaves a **half-built flow bound as the login path** for every SSO-gated app
*and* kubectl — and this repo has had a total SSO outage from a realm change (2026-09-06), with the
conditional-OTP sub-flow (which the declared structure moves one level deeper) once 403ing every
platform-admin.
**That risk analysis was sound but it assumed the wrong shape.** It is true only if you mutate the
*bound* flow. The operator's instruction was *"if this can be done through GitOps, it should"*, and
audit-only did not meet it — a manual admin-console step is exactly what the one-shot import already
forces. So:
- All the multi-call construction happens on a flow **nothing is using** (the declared alias plus a
hash of the desired shape, applied to *every* alias in the tree — reusing `browser-mfa-forms`
would mean writing *into* the bound tree). A failure at any point leaves an orphaned, unbound flow
and **zero user-visible effect**. The untransacted-calls problem dissolves.
- The built flow is **read back and verified** — nesting levels, ordering, authenticators,
requirements, and the conditional-OTP config *contents* — **before** anything is bound. The
regression the operator feared is now caught *before* it can bite rather than after.
- The flip is **one atomic PUT** of the realm setting `browserFlow`.
- **Rollback is one write.** The previous alias is printed in the run log, and there is **no
`DELETE` anywhere in the allow-list**, so the old flow survives as a working rollback target.
**The new safety property, enforced structurally rather than by intention.** Blue/green needs
flow-create and execution-add endpoints, which widens the allow-list — and the allow-list alone
cannot express "but not the flow that is bound", because its write entries are alias-parameterised.
So a second guard sits at the same choke point in `_http()`: the live `browserFlow` alias and its
**entire subtree** of sub-flow aliases and execution ids are read first, and every write is screened
against those sets before a socket opens. Protecting the whole subtree matters — adding an execution
to `browser-mfa-forms` changes live login behaviour just as surely as touching `browser-mfa`. It is
**fail-closed**: the sets start each run as "not determined", and while they are, *every* flow write
is refused; if the bound flow's executions cannot be read they stay undetermined and the flow pass
writes nothing.
**Idempotence became load-bearing rather than merely nice.** A rebuild-and-reflip every 15 minutes
would churn the login path on a schedule, so the bound flow is compared *structurally* and a match
produces **zero writes and no new flow**. Sub-flow and config *aliases* are deliberately not
compared: a flow this program built carries a hash suffix, so comparing labels would make it
permanently unrecognisable as converged. A crashed run's completed-but-unbound flow is **reused**; a
half-built one is left alone and the next candidate alias is used, with the candidate count
**bounded** so a persistently failing build fails loudly instead of creating unbounded sprawl.
**Where the rollback target is recorded, and why not as an annotation.** The brief suggested
recording the previous `browserFlow` alias as an annotation on the CronJob or in a ConfigMap. The
pod **cannot**: it runs with `automountServiceAccountToken: false` and has no Kubernetes API access
at all, which is the same property that made the mounted-generated-ConfigMap design preferable to
reading the CR (and is why this change adds no `**/rbac.yaml`). Giving it write access to its own
annotations would trade a narrow, gate-verified read for a Kubernetes API writer, which is a worse
bargain than the one it would buy.
So the record is in two places that cost nothing: the **run log** prints
`ROLLBACK is one write: set browserFlow back to <alias>. The old flow was NOT deleted.`, and — more
durably — **the realm itself**, because nothing is ever deleted. Every superseded flow is still
listed under `Authentication -> Flows`, so the rollback target is discoverable from the admin console
even if every Job log has rotated away. The README carries the rollback procedure.
**I achieved full convergence including the bind.** `--no-bind` exists as the deliberate fallback
(build and verify, leave the flip as a one-write human act) but it is not the default and was not
needed.
**No `**/rbac.yaml` was touched.** The brief floated having the reconciler read the
`KeycloakRealmImport` CR from the Kubernetes API, which would need a Role, a RoleBinding and a
ServiceAccount token. I rejected that for the narrower, already-proven pattern: a desired state
**generated** from the import and **mounted**, so the pod keeps `automountServiceAccountToken:
false` and no Kubernetes API access at all. Skew is prevented by the gate running the renderer with
`--check`, not by a second declaration being trusted.
**Sensitive paths touched** — checked against `infra/gitea/approval-policy.json` rather than from
memory, which caught one I had initially missed. **Three**:
| Path | Why it is in the policy | What I changed |
|---|---|---|
| `ci/**` | the pipeline itself | added `ci/render-keycloak-passkey-policy.py` |
| `conformance/**` | changing the tests changes what green *means* | added the L0 test directory |
| `platform-config/kustomization.yaml` | Flux wiring / which manifests are actually applied | added the two new resources |
**Not** touched: `**/rbac.yaml`, `clusters/**`, `platform/kyverno/**`, `.gitea/**`, `bootstrap/**`,
`infra/gitea/**`, `.sops.yaml`, any `*.sops.yaml`, `CLAUDE.md`.
So this PR **requires an approving review from someone who is not its author**, and
`ci/approval-policy.sh` is fail-closed, so it will block until that exists. I did **not** post
`/approve-override` — CLAUDE.md §1 names doing that on your own agent PR as exactly the
self-approval the stage exists to prevent. The operator needs to either review it or post the
override.
## What I verified
**Measured against the live cluster** (read-only `kubectl --context k3s-prod`):
- `keycloakrealmimport homelab`: `Done=True Started=False HasErrors=False` — the import is a
completed one-shot. **This is the premise of the whole change and it is measured, not assumed.**
- Keycloak image actually running: `quay.io/keycloak/keycloak:26.0.6`.
- `kustomization/platform-config` `.status.lastAppliedRevision` ==
`gitrepository/flux-system` artifact revision == `main@sha1:83d3568…`, which contains the PR #72
merge. So the inert declaration is genuinely applied and genuinely inert.
**`PUT /admin/realms/{realm}` does not touch users, groups, clients or roles in 26.0.6** —
established from source at tag `26.0.6`, not from documentation or memory:
- `RealmAdminResource.updateRealm` is the only handler. Its javadoc: *"Any user, roles or client
information in the representation will be ignored. This will only update top-level attributes of
the realm."*
- It delegates to `RepresentationToModel.updateRealm` →
`DefaultExportImportManager.updateRealm` (`model/storage-private/.../datastore/`), **lines
725–894**. I brace-matched that body and enumerated every `rep.getX()` call in it: it contains
**no** `getUsers()`, `getClients()`, `getGroups()`, `getRoles()`, `getRequiredActions()`,
`getAuthenticationFlows()` or `getAuthenticatorConfig()`. Those objects are **unreachable** from
the endpoint, not merely ignored by convention.
- Two findings that changed the design, both from the same read:
- `updateRealm` **does** read `rep.getBrowserFlow()`, so the endpoint can rebind the realm's login
path. The write therefore carries `browserFlow` straight back from the GET and a hard assertion
refuses the PUT if it would differ.
- The passwordless policy is applied **all-or-nothing and only when `RpEntityName` is non-empty**
(line 842), and `getWebAuthnPolicyPasswordless(rep)` substitutes a **Keycloak default** for every
null field (line 1253ff). A partial PUT would therefore silently reset every key it omitted, and
one missing `RpEntityName` would drop the whole policy while returning 204. This is why the write
is a strict read-modify-write of the full live representation.
- `AuthenticationManagementResource.updateRequiredAction` (line 1205) is **per-alias** and likewise
**replaces** the provider from the representation (name, alias, priority, enabled, defaultAction,
config), and 404s when the alias is unregistered. Hence read-modify-write there too.
- `DefaultRequiredActions` registers `webauthn-register-passwordless` with `enabled=true`,
`defaultAction=false`, `priority=80` — confirming the realm import's claim that declaring it would
restate a default while deleting ten siblings.
- **The realm GET really does return what the read-modify-write needs.** This is load-bearing: if
the GET omitted the passwordless keys, the PUT body would be missing them and 26.0.6 would reset
them to defaults. `ModelToRepresentation.toRepresentation` (lines 471–482) sets **all ten**
`webAuthnPolicyPasswordless*` fields plus `ExtraOrigins`, and line 496 sets `browserFlow`. I
checked for an `internal`/`export` guard around that block — there is none in lines 380–500, so
those fields are emitted unconditionally, not only on the export path.
**The five flow-construction endpoints, read from 26.0.6 source before being used:**
- `POST /authentication/flows` — `AuthenticationManagementResource.createFlow`, rejects a duplicate
alias. Carries **no alias in its path** (the alias is in the body), which is why it needed its own
body-based screen; without that it escaped the protected-subtree guard entirely. My selftest
caught that, not my reading.
- `POST /flows/{alias}/executions/flow` — `addExecutionFlow`, body `{alias, type, description}`.
Creates the sub-flow **at requirement DISABLED**, and refuses if the parent `isBuiltIn()`.
- `POST /flows/{alias}/executions/execution` — `addExecutionToFlow`, body `{provider}`. Also
DISABLED unless the factory has exactly one requirement choice. Both assign `getNextPriority()`,
so **insertion order is the resulting order** — which is why the builder inserts depth-first in
declared order and then verifies by readback.
- `PUT /flows/{alias}/executions` — `updateExecutions`. **A trap I would have walked into:** it
compares `rep.getPriority()` against the stored priority, and a JSON body omitting `priority`
deserialises it as the int **0** — which would silently **reshuffle the whole execution order**
while only a requirement change was intended. Every requirement write therefore carries the
execution's current priority back. There is a mutation for it and a selftest assertion on the
resulting order.
- `POST /executions/{id}/config` — `newExecutionConfig`, creates an authenticator config and binds
it. Config aliases are suffixed like flow aliases, because 26.0.6 keeps them unique per realm and
the old flow already uses the declared alias.
**Offline proofs, all green:**
- The reconciler's `--selftest`, **extracted from the shipped ConfigMap** and run: **216 assertions
pass**. Its stub Keycloak models the 26.0.6 behaviours that bite — the RpEntityName gate, the
all-or-nothing policy rebuild, the required-action replace, the `updateExecutions` priority trap,
and flow construction where everything is created DISABLED — so these are tested against the real
semantics rather than a forgiving fake.
- **A build killed at four different points (after 1, 3, 6 and 9 construction writes) never binds
and never touches the live flow.** Each case asserts the realm is still bound to the old alias,
that no write targeted the bound subtree, and that every live flow survives. This is the assertion
that makes blue/green a property rather than a claim.
- **Verify-before-bind is exercised, not assumed.** Two stub sabotages — a server that accepts the
requirement writes and applies the wrong value, and one that writes the conditional-OTP role under
`condition.evaluated.user.role` — both end with the flow **built but NOT bound** and the old flow
still live.
- `mutations.py`: **all 30 injected regressions caught**, including the six that are the blue/green
safety argument itself (build into the bound flow; never call the screen; reset the protected sets
to empty; bind before verify; report a failed build as success; reuse an existing flow without
verifying it).
- Mutation testing again found real gaps in my own work, which I fixed rather than weakening the
mutations. Across the two designs: no readback coverage on the realm write, no coverage of an
undeclared policy key surviving, a mis-indented anchor, and — in this design — **no coverage at
all of the bound flow being unreadable**, which is the fail-closed path. It also showed me that
**two of my proposed mutations were not real regressions**: the module-level initial value of the
protected sets is dead code (`run()` resets them first), and removing the early return after a
build failure is covered by verify-before-bind anyway. I re-anchored both onto the properties that
*are* load-bearing rather than pretending the originals had been caught.
- The new L0 `check.sh`: rc 0, all assertions. It asserts the renderer `--check`; that the realm
import and the desired state are rendered by the **same** Flux Kustomization; that the renderer's
and reconciler's writable key tuples are identical (parsed out of both sources with `ast`); that
`defaultAction` is `false`; that the realm import declares **no** `requiredActions` block
(previously only a comment saying "DO NOT ADD ONE"); that the **sibling** reconciler's allow-list
was not widened; that **nothing is deletable** and the execution-/config-id-scoped write endpoints
stay off the list (they carry no flow alias, so they could not be screened); and — the
load-bearing one — that the **protected-subtree guard exists in the source, is wired into
`_http()`, resets to "undetermined" every run, screens the body-only create-flow endpoint, and
that the policy write still refuses to rebind `browserFlow`**.
- `ci/render-keycloak-passkey-policy.py --write` output round-trips: the generated JSON is accepted
by the reconciler's `declared()`, its 10 policy keys equal the reconciler's `POLICY_FIELDS`, and
it flattens to the exact 11-step tree the realm import declares, with
`conditional-otp-platform-admin` bound at level 4.
- `${BASE_DOMAIN}` is carried into the generated ConfigMap **verbatim**, and both files sit in
`platform-config`, so one substitution pass decides the RP ID.
- Zero dollar signs in the reconciler script (the Flux envsubst trap), verified on the committed
manifest.
**The conditional-OTP regression is detectable — and now also preventable.** The comparison is
key-by-key on the authenticator config contents, and because it runs **before** the bind, a flow
carrying the bug never becomes the live login path. `--selftest` covers it three ways: the historical
`condition.evaluated.user.role` key injected into a *built* flow (built, verified, **not bound**,
old flow still live); the same bug already live in the *bound* flow (reported, then **fixed** by
building and binding a correct flow); and the config disappearing entirely. `mutations.py` deletes
the comparison and the undeclared-key report and requires the selftest to notice both. The suite
also covers a requirement flipped from `ALTERNATIVE` to `REQUIRED`, the un-restructured legacy flow,
a correct-but-unbound flow, and a cyclic declaration.
**Gate:** see the gate section of the PR / the run quoted in my report — rc and per-stage results
are recorded there rather than paraphrased here.
## What I did NOT verify
- **The live realm's actual passkey state is entirely UNVERIFIED.** I never authenticated to the
Keycloak admin API. Doing so needs the `keycloak-admin-credentials` password, which CLAUDE.md §6
forbids decrypting or printing, so I did not read the realm's current
`webAuthnPolicyPasswordless*` values, its required-action list, or its `browser-mfa` executions. I
therefore **do not know** how much drift the first real run will report, nor that the live flow is
in the "legacy shape" my selftest models — only that the import never re-ran, which makes drift
the overwhelmingly likely state. The first run's log is the measurement, and nobody has seen one.
- **Nothing has ever run against a real Keycloak.** Every behavioural proof is against an
in-process stub I wrote, and a stub I wrote cannot falsify my own reading of the source. This
matters more for the blue/green flow builder than for anything else in the change, because it is
**five endpoints I have never exercised**. Specifically unproven in reality:
- That `POST /flows/{alias}/executions/flow` and `.../executions/execution` accept the bodies I
send and assign priorities in insertion order. If they do not, the build produces a tree that
fails verification — which is the safe failure (nothing is bound, login unaffected), but it
means **the flow would never converge** and the Job would fail every 15 minutes.
- That `PUT /admin/realms/homelab` accepts a full round-tripped representation. The analogous
*client* endpoint rejects one with `400 "Cannot parse the JSON"` (measured 2026-09-06), and the
sibling reconciler's whole minimal-PUT rule exists because of it. The source says the realm
endpoint differs; no measurement here confirms it. **If it does not, both the policy pass and
the atomic bind fail closed** — loudly, writing nothing.
- The exact JSON shapes compared: `AcceptableAaguids` as `[]` rather than absent or null,
`CreateTimeout` as a JSON int, `RequireResidentKey` as the literal string `"Yes"`. A shape I did
not anticipate would show up as **perpetual drift** — a write every 15 minutes that never
converges — rather than as an error.
- That `authenticationConfig` on an execution-info response is the config **id** (I read it from
the representation class, but never saw a live response).
- **I did not verify the alias-collision behaviour against a real realm.** The recovery logic
assumes `POST /flows` returns a non-2xx on a duplicate alias (the source checks
`getFlowByAlias != null` and raises `ErrorResponse.exists`), and that a config alias collision
likewise fails. If either silently succeeded, the recovery path would behave differently than
modelled.
- **Flow sprawl is bounded but real.** Every genuinely new desired shape creates a new flow and
**nothing is ever deleted** — by design, because the old flow is the rollback target. Over many
shape changes the realm accumulates unbound flows. There is no cleanup, automated or documented
beyond "an operator deletes them". I consider that the right trade (deletion is the one operation
that could destroy a rollback target) but it is a cost, not a non-issue.
- **No L2 behavioural test was added.** The reconciler has an `--audit` mode built for one and the
sibling has `tests/l2-behavioral/keycloak-client-secrets-match-git`, but I added no equivalent.
And `vars.CLUSTER_AVAILABLE` is unset, so L2/L3 would not run in CI anyway. **CI proves the
offline subset only; it proves nothing about the live realm.**
- **No alerting.** Unlike the first design there is no longer any failure that is "expected by
design", so a PrometheusRule on `kube_job_status_failed` would now be meaningful — and I did not
add one. A silently failing CronJob (image pull failure, rotated admin password, changed Secret
key) is visible only to someone who looks at Jobs. This is a real gap, not a resolved one.
- **That passkey login works.** No passkey has been enrolled and no browser has been pointed at
this. Nothing offline can prove a WebAuthn ceremony completes, that the `ALTERNATIVE` fork
degrades to the password form via `CREDENTIAL_SETUP_REQUIRED`, or that a platform-admin is still
asked for TOTP on the password branch. The README's four-step manual check remains the only thing
that would establish it, and it remains undone.
- **That the RP ID is right for the origin.** `${BASE_DOMAIN}` is a legal registrable-domain suffix
of `keycloak.${BASE_DOMAIN}` on paper, but a wrong RP ID fails **in the browser**, before Keycloak
is involved, with no server-side log line. The reconciler will happily converge the realm on a
wrong RP ID; it checks that git and the realm agree, never that git is correct.
- **The first real run will change how everyone logs in.** Once this merges and Flux applies it, the
next tick builds a flow and — if it verifies — **flips the realm's browser flow**. The UX change
is deliberate and documented (username screen, then password), but it is the first time it will
have happened, unattended, on a schedule. The rollback is one write and the old flow survives;
that is the mitigation, and it has not been rehearsed either.
- **CI's own result.** My gate run is local. `gitea-settings` cannot run locally, and
`ci/approval-policy.sh` reports green N/A offline while this branch touches three sensitive paths,
so **the PR is guaranteed to need a non-author approving review** and I have not seen CI report. I
did not and will not post `/approve-override` on my own PR.
- I re-measured branch protection this session (`protected: true`, `user_can_push: false`,
`enable_status_check: true`, context `gate / gate (pull_request)`, `required_approvals: 0`), but
the branches endpoint does **not** expose `block_admin_merge_override`, so that row of CLAUDE.md
§2 is **not re-confirmed** by me.
+82
View File
@@ -0,0 +1,82 @@
---
date: 2026-10-18
branch: fix/pihole-local-ttl
title: "The split-horizon answer was uncacheable while the wrong answer cached for 5 minutes"
---
## What changed
The operator reported DNS "flapping between the cluster ingress and the router (public IP)".
PiHole was not at fault — both replicas answer correctly and consistently. The bug is an
**asymmetry in cacheability** that we created.
Measured on prod 2026-10-18:
```
dig home.keithjackson.dev @192.168.2.23 (pihole) -> 192.168.2.20 TTL=0
dig home.keithjackson.dev @1.1.1.1 (public) -> wan.home.keithjackson.dev TTL=300
dig home.keithjackson.dev @192.168.2.1 (router) -> wan.... TTL=153 -> 148 -> 143
```
dnsmasq answers `address=` entries with **TTL 0** unless told otherwise. The SOHO router forwards
LAN queries to PiHole and **caches** the result — the decrementing TTL above is the proof it caches
rather than forwarding live. So:
* the **correct** answer is **uncacheable** (TTL 0): the router must re-query PiHole on *every*
lookup, which is maximum exposure to any momentary gap;
* the **wrong** answer is **cacheable for 300s**: the instant the router falls back to its upstream
(standard forwarder behaviour when the forward target does not answer) it latches the public WAN
address and serves it to **every LAN client** for five minutes.
So **one sub-second PiHole gap becomes five minutes of the whole house resolving to the WAN IP** —
then it expires, the router re-queries PiHole, gets TTL 0 again, and is immediately re-exposed.
That asymmetry, not the gap itself, is why this is visible as flapping rather than a blip.
Fix: `local-ttl=60` prepended to `FTLCONF_misc_dnsmasq_lines` in `catalog/pihole.yaml`, so the
private answer is cacheable and a short gap is invisible to clients. `apps/wave-0/pihole.yaml`
regenerated with the canonical render command.
## What I verified
* **PiHole is NOT the fault.** Both replicas, queried directly by pod IP, answer `192.168.2.20`;
the VIP returned it 6/6; this workstation 8/8. DHCP hands out only `192.168.2.23`.
* **The router caches rather than forwards live** — three successive queries returned TTL 153, 148,
143 (decrementing), which a pure forwarder could not do.
* **The TTL asymmetry**, as quoted above: 0 from PiHole, 300 from public DNS.
* **The router does answer DNS and returns the public chain** (`wan.home.keithjackson.dev` → the WAN
address), so a fallback always has a wrong answer available.
* **`local-ttl` is the right directive**: dnsmasq applies it to replies made from configuration,
`/etc/hosts` and DHCP leases, which covers all five `address=` names with one directive. It is a
standalone option, not a sub-field of `address=`, so prepending it to the `;`-separated
`FTLCONF_misc_dnsmasq_lines` list is syntactically correct.
* **The render is clean and scoped**: `catalog/render.py --all catalog -o apps --wave-layout
--no-image-automation` (the canonical command `ci/render-golden.sh` asserts against, and
deliberately **not** `--emit-waves`) changed exactly two files — `catalog/pihole.yaml` and
`apps/wave-0/pihole.yaml` — and the rendered diff is the single `local-ttl=60;` prefix.
## What I did NOT verify
* **UNAPPLIED.** Nothing has been reconciled, so I have not observed PiHole serving a non-zero TTL,
nor the router caching the private answer, nor the flap stopping. **Verify after merge with
`dig home.keithjackson.dev @192.168.2.23` (expect `TTL=60`, not 0) and then the same query at the
router (expect `192.168.2.20` with a decrementing TTL, not `wan.…`).**
* **I did not confirm that PiHole/FTL accepts `local-ttl` via `FTLCONF_misc_dnsmasq_lines`.** The
directive is valid dnsmasq and the field is a passthrough, but `catalog/pihole.yaml`'s own header
records that FTL has previously written a bogus line into `dnsmasq.conf` and reported "0 invalid"
— so a malformed passthrough fails *silently*. If the TTL is still 0 after merge, that is the
cause, not this reasoning. Check the pod's `/etc/pihole/dnsmasq.conf`.
* **60s is chosen, not derived.** Long enough for a pod restart and a lease re-election (both
seconds); it does **nothing** for an outage longer than the TTL, where the cache expires and the
public answer latches again. That is a real availability problem, not a caching one.
* **This does not remove the trigger.** `192.168.2.23` is served by **two** LoadBalancer Services
(`pihole-dns-tcp`, `pihole-dns-udp`) with **two independently-elected L2 announcement leases**, so
two nodes can ARP for the same address at once — they sat on different nodes earlier the same day
(tcp on prod-2, udp on prod-1). The same pattern applies to crafty (`.22`) and unifi (`.21`).
Merging each pair into one mixed-protocol Service would remove the class; the operator has parked
that decision, and it is deliberately **not** in this change.
* **Client-side bypasses are unexamined.** A device with its own resolver, or a browser with
DNS-over-HTTPS, bypasses PiHole entirely and always gets the public answer; this fix cannot help
there. Only this one workstation's resolver chain was inspected.
* **No alert fires if the split-horizon answer regresses to the public address.** Nothing probes
`home.keithjackson.dev` from a LAN vantage point and compares it against `${GATEWAY_VIP}`, so the
next recurrence would again be found by a human noticing. That gap is not closed here.
@@ -0,0 +1,78 @@
---
date: 2026-10-18
branch: fix/renovate-cron-window
title: "The Renovate cron fired outside Renovate's own schedule, so it would never have opened a PR"
---
## What changed
`.gitea/workflows/renovate.yaml` shipped (PR #73) with `cron: "15 3 * * *"`. **That schedule could
never have produced a single PR.**
Gitea Actions cron is **UTC**. `renovate.json` carries `"schedule": ["after 3am and before 6am"]`
with `"timezone": "America/New_York"`, and that inner schedule is **authoritative** — Renovate
re-checks it and declines to open PRs outside it. So:
```
cron 03:15 UTC -> 23:15 EDT, the PREVIOUS EVENING in New York
window -> 03:00-06:00 local
inside? -> NO (3h45m before the window opens)
```
The original comment claimed the cron was "slightly ahead of the repo window ... so a run is always
in flight when the window opens". It is 3h45m *behind*, on the previous day. Every nightly run would
have started, logged "not within schedule", opened **zero** PRs and exited **successfully** — a
green tick forever, indistinguishable from "nothing needed updating". That is the same
"failure-mode-is-a-green-tick" shape this repo keeps hitting, and it would have hidden the ~30
pending updates PR #73 was written to surface.
Changed to `cron: "15 8 * * *"`, chosen to survive DST rather than to be tidy. The window's UTC
position moves:
| | local window | in UTC |
|---|---|---|
| EDT (UTC-4, Mar–Nov) | 03:00–06:00 | 07:00–10:00 |
| EST (UTC-5, Nov–Mar) | 03:00–06:00 | 08:00–11:00 |
`08:15 UTC` is inside **both** (04:15 EDT / 03:15 EST), so no edit is needed at either switchover.
A cron cannot follow DST, so this is the only way to get it right once.
## What I verified
* **The defect, by arithmetic rather than assertion:** `datetime(2026,10,18,03,15, UTC)` converted to
`America/New_York` via `zoneinfo` is `2026-10-17 23:15 EDT`; `3 <= hour < 6` is **False**.
* **The fix, at both DST extremes:** `08:15 UTC` → `04:15 EDT` in July and `03:15 EST` in December,
both inside `03:00–06:00`. Computed, not reasoned.
* **The inner schedule really is authoritative and really is set:** `renovate.json` on `main` carries
`timezone = America/New_York` and `schedule = ['after 3am and before 6am']`.
* **`RENOVATE_TOKEN` now exists** as a repository Actions secret (listed by name via the Gitea API
alongside `SETTINGS_API_TOKEN`), so the workflow's fail-hard preflight will pass. It did not exist
when PR #73 was authored.
* The edited workflow parses as YAML.
## What I did NOT verify
* **Renovate has still never executed.** This fixes *when* it runs, not *whether it works*. No run
has happened, so nothing here proves it extracts dependencies, authenticates to Gitea, or opens a
usable PR.
* **`enabledManagers` may silently disable the custom managers — UNRESOLVED and the likelier
remaining bug.** `renovate.json` lists `enabledManagers: [..., 'regex', ...]` and defines
`customManagers` (correct modern key; `regexManagers` is absent, which is right). But modern
Renovate names the custom manager **`custom.regex`** in `enabledManagers`, not bare `regex`. If
the bare name is invalid or ignored, every inline `# renovate:` marker in the tree does nothing —
including the `external-snapshotter` `newTag` pin PR #73 flagged as probably inert. **I did not
change it**, because guessing at a config key that could fail validation and take the whole run
down is worse than leaving a question that one dry run answers definitively. The `dry_run: true`
dispatch log settles it: look for a per-manager dependency-extraction count, and for any config
validation warning naming `regex`.
* **Whether a dry run inside vs outside the window differs usefully.** Renovate honours its schedule
even in dry-run, so a manual dispatch outside 03:00–06:00 local will also log "not within
schedule". That makes the first dispatch's output less informative than it looks; to see what
Renovate *would* do, either dispatch inside the local window or override the schedule for that
run. Not tested either way.
* **No alert fires if the schedule stops firing.** PR #73 recorded this gap and it is still open: a
Renovate workflow that silently stops running (disabled Actions, expired token, deleted secret)
produces no signal at all. Closing it needs a check on last-successful-run age, which nothing
does.
* No conformance test asserts the cron falls inside `renovate.json`'s window, so this exact bug can
return. A static check comparing the two is feasible and is not written.
@@ -0,0 +1,78 @@
---
date: 2026-10-18
branch: fix/renovate-invalid-config
title: "renovate.json was invalid, so the first manual run errored out"
---
## What changed
The operator ran the Renovate workflow manually and it **failed**. The cause is not the workflow:
**`renovate.json` was never a valid Renovate config.**
It carried five pseudo-comment keys — `_comment`, `_kubernetes_comment`, `_unmanageable_comment`,
`_ignorePaths_comment`, `_docker_comment` — because JSON has no comments. Renovate **rejects unknown
top-level options**, so the entire config failed validation. Proven with the official validator
rather than inferred:
```
$ podman run --rm -v "$PWD/renovate.json:/tmp/renovate.json:ro,Z" \
ghcr.io/renovatebot/renovate renovate-config-validator /tmp/renovate.json
ERROR: Found errors in configuration
Invalid configuration option: _comment
Invalid configuration option: _docker_comment
Invalid configuration option: _ignorePaths_comment
Invalid configuration option: _kubernetes_comment
Invalid configuration option: _unmanageable_comment
```
This is the second independent reason Renovate had never worked, on top of PR #77's cron firing
outside `renovate.json`'s own schedule window. Either alone was sufficient to produce "nothing ever
happens"; #73 shipped both.
**Converted `renovate.json` → `renovate.json5`.** JSON5 permits real `//` comments, and Renovate
discovers `renovate.json5` natively — nothing in `.gitea/` or `ci/` pins the old filename (only
prose inside comments), and no `RENOVATE_CONFIG_FILE` is set. Every word of the original rationale
is preserved as comments positioned next to what it explains, which a flattened `description`
string would have lost.
A header on the new file records the failure, the validator command, and the instruction to run it
before committing — this class of bug is invisible to the repo's own gate.
## What I verified
* **The exact failure**, from the official validator, quoted above. Not read off a log, not inferred
from the workflow.
* **The fix validates**: the same validator against `renovate.json5` returns
`INFO: Config validated successfully against 1 file(s)`.
* **Nothing was dropped in the conversion**, checked programmatically against `git show
HEAD:renovate.json`: 18 non-comment keys before, 18 after, **no missing keys, no extra keys, and
no key whose value changed**. `docker: {enabled: true}` confirmed still present — the validator
prints a normalisation diff that mentions it, which is its own migration suggestion, not a
removal.
* **74 comment lines survive** in the new file, so the rationale is intact.
* **`RENOVATE_TOKEN` exists** as a repository Actions secret (listed by name via the Gitea API), so
the workflow's fail-hard preflight is satisfied.
* **A suspicion was DISPROVED:** `enabledManagers` containing bare `regex` was thought to need to be
`custom.regex`, which would have silently disabled every inline `# renovate:` marker. The
validator accepts `regex`, so that concern was unfounded and nothing here changes it.
* Nothing functionally references `renovate.json` by name — `grep -rn "renovate\.json" .gitea/ ci/
conformance/` returns only comment prose.
## What I did NOT verify
* **Renovate has still never completed a run.** This removes one of two blockers; PR #77 removes the
other. Neither is merged, so **no successful Renovate run has been observed** and I cannot say the
bot works — only that these two specific defects are fixed.
* **I did not re-run the workflow after the fix.** The honest test is a `workflow_dispatch` with
`dry_run: true` once both this and #77 are merged. Expect ~30 dependencies behind, rate-limited to
`prHourlyLimit: 2` / `prConcurrentLimit: 5`.
* **Whether the managers actually extract anything.** A valid config that matches no files is still
a no-op. The dry-run log's per-manager dependency counts are the only thing that proves the
`managerFilePatterns` are right, and I have not seen them.
* **Whether `renovate.json5` is honoured by the pinned Renovate version in the workflow.** JSON5
discovery is long-standing and the validator accepted the file, but the workflow's image tag was
not cross-checked against the version that added it.
* **Nothing in the gate catches an invalid Renovate config.** This bug would have recurred silently.
A one-line L0 check shelling out to `renovate-config-validator` would close it; it is not written
here, and `conformance/**` is a sensitive path.
* I did not re-measure branch protection or runner state (CLAUDE.md §2b).
+374
View File
@@ -0,0 +1,374 @@
---
date: 2026-10-29
branch: feat/image-pipeline
title: "Images are built, verified and published by CI on rootless BuildKit; Renovate carries the digests into git"
---
## What changed
Operator rule (2026-10-29): container image creation, hosting and lifecycle belong to CI/CD and
the codebase, never to a one-off workstation build/push. Before this branch the Home-Network org's
package list was **empty**, `platform/pm-agent/` pinned an all-zero digest (ImagePullBackOff), and
both `build-image.sh` scripts said "publishing is an operator action".
### The builder: rootless BuildKit — DECISION MADE (operator, on PR #81)
**The problem it answers, measured on this PR's first CI run (images run 325, failed in 6 s):** a
CI job on this runner gets **no Docker daemon at all**, for two reasons, both in the deployed
versions' source:
- act_runner v0.2.11 `internal/app/cmd/daemon.go` sets `container.docker_host` to `-` ("never
mount into jobs") whenever `DOCKER_HOST` is `tcp://`, which it is here (the DinD sidecar);
- gitea/act v0.261.2 `pkg/container/docker_run.go` `sanitizeConfig` drops **every** bind,
socket included, when `valid_volumes` is empty, which it is (the runner has no config file).
Giving jobs the DinD daemon would have made every branch push root on the node, because that
daemon is `privileged: true`.
**The decision:** no job may reach a privileged daemon, and images are built by a **rootless
BuildKit** daemon over mTLS. The runner (`apps/wave-1/gitea-actions.yaml`, DOCKER_HOST,
valid_volumes) is **untouched**.
- **`platform/buildkit/`** — a new hand-managed component in its own namespace `buildkit`. The
runner manifest is renderer-generated (`catalog/render.py` EXTRA_UNITS), and the builder has no
business sitting next to a privileged container. Flux reconciles it as the new Kustomization
`buildkit` (`clusters/base/buildkit.yaml`, `dependsOn: platform`).
- **Deployment:** `moby/buildkit:v0.34.1-rootless@sha256:f68752a5…0c4f89`, pinned by index
digest. The securityContext, every line measured (see *What I verified*):
- `privileged: false`;
- uid/gid 1000, `runAsNonRoot`;
- `capabilities: drop ALL, add SETUID, SETGID`;
- `allowPrivilegeEscalation: true`;
- `seccompProfile: Unconfined` and `appArmorProfile: Unconfined`;
- `readOnlyRootFilesystem: true`, with five emptyDirs.
Other settings:
- `--oci-worker-no-process-sandbox`;
- no ServiceAccount token;
- memory limit 4Gi, which covers build steps too;
- cache emptyDir with `sizeLimit: 6Gi`, and GC capped at 4 GB in `buildkitd.toml`
(ConfigMap).
- **mTLS via cert-manager** (`certificates.yaml`): a self-signed CA used only here, the server
cert `buildkitd-tls`, and the client cert `buildkit-client-ci`. buildkitd verifies every
client against that CA.
- **Service:** ClusterIP `buildkitd.buildkit.svc.cluster.local:1234`.
- **NetworkPolicies:**
- default-deny;
- ingress on 1234 **only from the act-runner pod** (`gitea-actions`,
`app.kubernetes.io/name=act-runner`);
- egress: DNS, world :443/:80 for downloads, and the `ingress` entity (the Gateway) for the
registry push.
- `versions.env`: the CI-side client, `buildx` v0.38.1 with the sha256 from its
`checksums.txt`, plus the address and certificate name CI dials.
- **How DinD job containers appear to Cilium (measured, read-only).**
`iptables -t nat -S POSTROUTING` in the dind container shows
`-A POSTROUTING -s 172.18.0.0/16 ! -o br-… -j MASQUERADE` (and the same for docker0). So a job's
connection leaves with the **act-runner pod's IP, and therefore that pod's Cilium identity**.
That makes "only the act-runner pod" expressible and correct, but it cannot tell one job from
another, or a job from act_runner itself. Per-job authentication is the client certificate.
- **Admission: no Kyverno exception needed.**
- Every ClusterPolicy actually applied (`platform-config/kustomization.yaml`; live:
`conformance-trigger-job-shape`, `label-cdi-worker-pods`, `pm-agent-job-shape`,
`require-auth-mode-label`, `require-default-deny-baseline`, `require-egress-class-label`,
`restrict-host-namespaces`, `secret-provenance`, `validate-default-deny-shape`) matches only
namespaces labelled `homelab.keithjackson.dev/app-namespace: "true"`, or only its own
namespace in the job-shape policies' case. `buildkit` carries no such label.
- There are no namespaced Kyverno Policies and no cluster-wide PSA default (both measured).
- The namespace is deliberately left without a PSA label, because PSS `baseline` forbids
`Unconfined` seccomp and AppArmor.
- `validate-default-deny-shape` does apply to the namespace's default-deny NetworkPolicy, and
its shape (both directions) satisfies it.
- **Unprivileged user namespaces on the nodes (measured, read-only, via Ansible `sysctl`).** On
all three Debian 13.7 nodes, kernel `7.1.8+deb13-amd64`:
- `kernel.unprivileged_userns_clone = 1`;
- `user.max_user_namespaces = 127206`;
- no `kernel.apparmor_restrict_unprivileged_userns` (an Ubuntu-only key);
- AppArmor enabled (`Y`).
So this is a satisfied prerequisite, not an open one.
- **The TLS conformance check needed a narrow, self-proving exception** (`conformance/**`, so this
changes what green means; flagged for the reviewer).
- The first gate run failed L0 `tls-lifecycle-automated` clause (5): "every Certificate's
issuer must be ACME". That is broader than the spec it serves:
- R-TLS-01 and docs/08 concern *externally reachable* services;
- ADR-0006 says "mTLS between workloads is a separate concern … not covered here";
- a public CA cannot issue for a `*.svc.cluster.local` name or a client-auth certificate.
- The new clause (5b) exempts a Certificate annotated
`homelab.keithjackson.dev/tls-scope: workload-mtls` **only if it proves it**:
- no Gateway listener serves its Secret;
- every DNS name and the commonName are a bare label or `*.svc[.cluster.local]`;
- it has no IP, URI or email SANs;
- its issuer is a namespaced `selfSigned`/`ca` Issuer in its own namespace;
- a `ca` issuer's Secret is produced by a likewise-exempt tracked Certificate.
- Exemptions are printed, and clause (9), the renewal arithmetic, still applies.
- **Negative-tested:** a public DNS name, and a Gateway serving `buildkitd-tls`, each fail the
check with a specific message.
- **`infra/gitea/buildkit-client-secret.sh`** (operator action): copies `ca.crt`, `tls.crt` and
`tls.key` from the Secret `buildkit/buildkit-client-ci` into the Actions secrets `BUILDKIT_CA`,
`BUILDKIT_CLIENT_CERT` and `BUILDKIT_CLIENT_KEY`. Before writing anything it checks that:
- the cert verifies against the CA;
- the cert is a client-auth cert;
- the key matches the cert.
It never prints a value. `--check` reports whether the stored copy is older than the current
certificate.
### Verification moved into the Containerfiles
`docker run` cannot exist on BuildKit, so each Containerfile now names its shipped stage after the
image (`toolbox`, `agent-runner`, `conformance-runner`) and ends in a **`verify` test stage**,
`FROM <image> AS verify`. That stage runs every previous assertion at build time, as the image's
non-root uid (65532 for agent-runner and conformance-runner), in `RUN --network=none`. It is never
pushed.
- **Every existing assertion is preserved**, and the toolbox gained one it never had: every tool
on PATH, and five tools report their pinned versions.
- `probe-validation.sh` is `COPY`ed into the verify stage only, so it never ships.
- **Bug fixed on the way:** in the old `docker run` block, `cmd | grep -q … && echo ok` and
`[ … ] && echo` could never fail the script under `set -e` + `pipefail`. The "entrypoints refuse
arguments", "not root", "no API key baked in" and "HOME writable" checks asserted nothing. They
are real assertions now.
- **What a build step cannot reproduce, and how it is approximated** (also written in each stage's
header):
- **uid:** exact.
- **`--network none`:** exact, and each stage *proves* it. Its first check fails if DNS or a
TCP connection works. A negative control without `--network=none` fails that check.
- **`--read-only` rootfs:** not reproducible, because a RUN step always has a writable layer.
It is approximated by permission:
- `touch /should-fail` must fail;
- a `find / -xdev -writable ! -type l` inventory, run as the uid, must lie entirely under
`/workspace`, `/tmp` or `/var/tmp`. `! -type l` was needed: Fedora's `/usr/tmp` symlink
to `/var/tmp` tripped it.
The real control remains the pods' `readOnlyRootFilesystem`, which the admission policies
enforce.
- **`--tmpfs /workspace`:** approximated by the image's own `/workspace`, owned by 65532, which
is exactly what the pod's emptyDir replaces.
The opt-in `--verify-clone` still `run`s the image, so it remains local-only.
- **`build-image.sh` (all three):**
- verify is `build --target verify` and the image is `build --target <image>`;
- a new `--print-build-args` prints the pins as `KEY=VALUE`, which is what CI consumes. The
arg list therefore exists once;
- the local podman path still works.
- The L0 check `alert-rules-unit-tested` now looks for `"PROMTOOL_VERSION=$PROMTOOL_VERSION"` in
the toolbox's build-arg list.
### The pipeline (`.gitea/workflows/images.yaml` + `scripts/images.sh`)
- **The builder.** The driver downloads the pinned `buildx` and refuses a checksum mismatch. It
writes the client cert from the `BUILDKIT_*` secrets to a 0600 temp dir that is removed on exit,
creates a `remote` builder, and runs `buildx inspect --bootstrap`.
- **It fails loudly, never skips.** It stops with a clear message if:
- the secrets are absent;
- buildkitd is unreachable. The message lists the causes in order: not deployed yet (Flux
applies it only after merge), stale certificate, NetworkPolicy.
- **Pull request:** `buildx bake <image>-verify` for changed images. Nothing is pushed and no
registry credential is referenced. The `BUILDKIT_*` builder secrets *are* given to PR jobs,
because they must build.
- agent-runner is built FROM the toolbox built **in the same run** through a bake `target:`
context. That toolbox exists in no registry, so the toolbox is built as its base even when
its own inputs did not change.
- **Push to main / dispatch on main:** for each image, the verify target first, and only then the
image itself with `push=true`. Tags are `sha-<12 hex>` (immutable) and `main` (moving).
- The digest is read back from the registry and compared with the client's metadata.
- agent-runner builds FROM the toolbox digest just pushed, or else from the pinned
`AGENT_BASE_IMAGE` (the zero placeholder bootstraps).
- `BUILDX_NO_DEFAULT_ATTESTATIONS=1` keeps one manifest per tag, which is what admission,
Renovate and the retention script assume.
- **Unchanged from the first iteration:** change detection, publish idempotence,
`REGISTRY_PUSH_TOKEN` and its preflight, the Renovate markers, and `registry-retention.sh`. The
DinD prune step is gone, because nothing is built in DinD now.
### Unchanged from the first iteration (still true)
- **Digest references** carry `# renovate-digest: image=<repo> tag=main` markers:
- `platform/pm-agent/deployment.yaml` and `configmap.yaml`;
- `infra/agent/versions.env` `AGENT_BASE_IMAGE`, now the published toolbox by digest, zero
until the first publish;
- `conformance/versions.env` `CONFORMANCE_RUNNER_IMAGE_DIGEST`, which went from `""` to zero.
The admission policies are untouched.
- **`renovate.json5`**:
- the `custom.regex` marker manager;
- `enabledManagers` `regex` → `custom.regex`;
- the `kubernetes` manager disabled for these three repos;
- `ignorePaths` narrowed so `conformance/versions.env` is visible.
`renovate.yaml` pins `renovate-version: 44.167.0` and adds a docker `RENOVATE_HOST_RULES` entry
using `RENOVATE_TOKEN`, which now needs `read:package`.
- **`infra/gitea/registry-retention.sh`** (dry run by default; operator action); the
`conformance-trigger-kubeconfig.sh` precondition fix; and the docs (pm-agent README, runbook,
plan, conformance README, devcontainer README, workflows README). The docs are updated again
for BuildKit, and there is a new `platform/buildkit/README.md`.
### Decisions and pushback
- **The push credential is a PAT secret, `REGISTRY_PUSH_TOKEN`; the Actions token cannot push.**
Checked against Gitea **v1.24.3** source:
- `services/auth/basic.go` `Basic.Verify` turns a task token into the pseudo-user
`gitea-actions` (ID −2) and sets no token scope;
- `routers/api/packages/container/container.go` `Authenticate` (the `/v2/token` exchange every
docker client performs) requires `read:package`/`write:package`/`all` scope, so it answers
401. The Actions token cannot even *pull*;
- `services/context/package.go` `determineAccessMode` has `// TODO: ActionUser permission check`
and no branch for that user. Even past login it would get at most read on a public org;
- `models/repo/repo_unit.go` `ActionsConfig` has no token-permissions setting in 1.24.3.
The workflow fails loudly when the secret is absent, copying the `RENOVATE_TOKEN` preflight
pattern. With BuildKit the credential reaches the daemon through the buildx session (a
0600 `config.json` in the job's temp dir), never through the cluster.
- **`regex` vs `custom.regex`: both work. Switched to the canonical `custom.regex`.** In Renovate:
- `lib/config/migrations/custom/enabled-managers-migration.ts` rewrites `regex` →
`custom.regex`;
- that migration runs on every repository run (`lib/workers/repository/init/merge.ts` →
`migrateAndValidate`), not only in the validator;
- `lib/modules/manager/index.ts` `getEnabledManagersList` accepts either spelling;
- `custom.regex` exists since 37.51.1 (renovatebot/renovate#24079).
- **The open question hid a bigger bug: the Renovate config on main is invalid for the Renovate
the workflow actually runs.**
- `renovatebot/[email protected]` defaults `renovate-version` to `39`
(`action.yml`, `src/docker.ts`), which is 39.264.0.
- The validator used in #79 was `ghcr.io/renovatebot/renovate` with no tag, i.e. `latest`,
44.167.0.
- **Measured:** `renovate:39.264.0 renovate-config-validator` on main's `renovate.json5` gives
`ERROR: Found errors in configuration` with five errors, all `managerFilePatterns`.
`fileMatch` was renamed in 40.2.0.
- So once #80 fixes the filename, every run would still die at `CONFIG_VALIDATION`.
- Fixed by pinning `renovate-version: 44.167.0`. This branch's config validates clean on it.
The only `--strict` complaint is a pending migration of the pre-existing top-level
`"docker": {"enabled": true}` block, which I left alone.
- **Unworkable as briefed: tracking the worker Job template's digest.**
- `infra/agent/worker-job-template.yaml` is `COPY`ed into agent-runner. A Renovate bump there
changes agent-runner's inputs, CI publishes a new digest, Renovate bumps it again, and so on
forever.
- `pm-dispatch` overwrites that field from `PM_WORKER_IMAGE` at runtime
(`pm-dispatch.py:649`), so nothing is lost by leaving it all-zero. It is commented as
deliberately untracked.
- For the same loop reason, `conformance/versions.env` is **not** an input of the
conformance-runner build, and a pipeline-only change does not republish on main.
- **Changed from the brief: retention is a script, not a Gitea cleanup rule.**
- Gitea 1.24.3 has **no API** for cleanup rules (none in `routers/api/v1/api.go`; web
session+CSRF forms only, in `routers/web/web.go`). So a rule cannot be created, or
drift-asserted, as code. **`ci/gitea-settings.sh` was therefore not extended.** A cheaper
proxy (count versions per image) would need `read:package` on `SETTINGS_API_TOKEN`, which
it lacks.
- A built-in rule is also **unsafe here**. Gitea stores a manifest as a file of the TAG
version it was pushed under (`routers/api/packages/container/manifest.go`
`createPackageAndVersion`), and resolves a by-digest pull from those files
(`models/packages/container/search.go`). Deleting the tag a deployed digest was pushed under
breaks the `@sha256:` pull, and the rule's "newest N" knows nothing about what git pins. The
script keeps every digest git pins.
- **Auto-merge.** Digest bumps of these images auto-merge on a green gate (the existing
`digest` rule). The exception is the conformance-runner bump: `conformance/versions.env` is a
sensitive path, so it waits for an administrator's `/approve-override` every time.
## What I verified
**Proved locally, against the exact pinned images:**
- **Rootless buildkitd, configured as the manifest configures it.**
- Setup: the pinned `moby/buildkit:v0.34.1-rootless` digest, under rootless podman, with
`--cap-drop ALL --cap-add SETUID --cap-add SETGID`, seccomp unconfined and `--read-only`;
tmpfs at the same five paths; the ConfigMap's `buildkitd.toml`; mTLS on :1234.
- The image's `/etc/subuid` was overridden with `user:1:999` and `user:1001:64535`, because
rootless podman maps only 65,537 uids. rootlesskit's real `newuidmap` user-namespace path
still ran.
- It started, `buildctl debug workers` listed the OCI worker, and `debug workers -v` showed the
GC config applied (max 4.29 GB, reserved 1.07 GB).
- **The securityContext, item by item.** Each requirement was measured by taking it away:
- **default seccomp:** daemon starts, every RUN step fails → `Unconfined` is required;
- **no `SETUID`/`SETGID`:** rootlesskit dies with
`newuidmap … operation not permitted` → both capabilities are required;
- **`--read-only` rootfs:** works once the five emptyDirs exist. Its only failure was the
probe client's `~/.docker`, which is now an emptyDir;
- **minimal caps + Unconfined seccomp:** works, and a RUN step executes.
- **The full CI path, in the actual CI job image.** Setup: `ghcr.io/catthehacker/ubuntu:act-22.04`
running `.gitea/workflows/scripts/images.sh` in verify mode. It downloaded and sha256-checked
buildx v0.38.1, connected over mTLS, and bake-built all three `verify` targets from an empty
cache. rc 0, 1m32s total:
- `TOOLBOX VERIFIED` (42 s);
- `AGENT-RUNNER VERIFIED` (26 s): selftest OK, probe "20 refusal(s), 0 failure(s)", and built
FROM the in-run toolbox through the `target:` context, with the log showing
`[agent-runner-verify toolbox 1/6]` and `base=toolbox-in-this-run`;
- `CONFORMANCE-RUNNER VERIFIED` (21 s): all seven refusals;
- every stage printed `confirmed: no network`.
- **mTLS rejects what it should.** Tested with a real BuildKit API call (`buildctl debug workers`):
- no client cert → `remote error: tls: certificate required`;
- a client cert from another CA → rejected the same way;
- the valid cert → accepted.
Through the driver:
- another CA's client cert → rc 1;
- the wrong CA for the server → rc 1;
- an unreachable host → rc 1 in about 20 s, with the explanatory message;
- missing secrets → rc 1, naming the three secrets.
- **The local podman path**, `build-image.sh` for all three (rc 0).
- Negative control: a verify stage expecting a different promtool version printed
`DRIFT promtool want 9.9.9` and failed the build.
- Negative control: the `--network=none` check fails when the flag is removed.
- **`buildkit-client-secret.sh`'s sanity checks**, run offline on local certs. They tell apart:
- a good client cert;
- a server cert (not client-auth);
- a mismatched key;
- a cert from another CA.
**Measured live, read-only:**
- node user namespaces, Debian 13.7 and AppArmor (via an Ansible `sysctl`/`cat`);
- DinD's MASQUERADE rules;
- the live ClusterPolicies, namespaced Policies, PSA labels, and cert-manager v1.21.3 with its
ClusterIssuers;
- the act-runner pod labels.
**Renovate (unchanged since the first iteration):**
- the validator with 44.167.0: exit 0;
- `extract` finds all four markers;
- the `kubernetes` duplicates are `skipReason: disabled`;
- 39.264.0 rejects `main`'s config.
**Local gate, CI results:** see the PR body. They are recorded there because this entry is part of
the tree being gated.
## What I did NOT verify
- **platform/buildkit has never run in the cluster.** It can't until it merges: agents may not
`kubectl apply`, and Flux reconciles only `main`. So this PR's own `images` job **cannot build
and is expected to fail at the builder preflight**, which is the designed loud failure. The
following are therefore all unproven in-cluster:
- kubelet admitting the pod;
- containerd honouring `appArmorProfile: Unconfined`;
- the five emptyDirs;
- cert-manager issuing the three certs;
- the CiliumNetworkPolicies.
- **AppArmor `Unconfined` is reasoned, not measured.** My test host runs SELinux, so the AppArmor
requirement comes from upstream's example plus the nodes having AppArmor enabled. If the pod
were to work under `RuntimeDefault` AppArmor, that line could be tightened; I did not test it.
- **The local userns mapping is narrower than the cluster's.** Locally it was 65,535 ids across
two ranges; in the cluster it is the image's own `100000:65536` against real host subuids. That
is the normal case, but it is the cluster's first test.
- **The NetworkPolicies are untested.** In particular:
- that `toEntities: [ingress]` admits the push to the Gateway VIP. The comment explains the
reasoning, but it is unmeasured;
- that world :443/:80 covers every download the builds make. A dnf mirror on another port
would fail a build visibly.
- **No push, anywhere.** The whole publish path is unexercised:
- `REGISTRY_PUSH_TOKEN` reaching buildkitd through the buildx session;
- `push=true`;
- the metadata digest compared with the registry read-back;
- agent-runner FROM `toolbox@sha256:`;
- the immutable-tag guard;
- idempotence.
So are the 15 s Envoy route-timeout risk on blob finalisation, and the Gitea PVC (10Gi, shared
with repositories) absorbing ~2 GB per toolbox publish until retention runs.
- **Node disk under real builds.** The cache emptyDir (6Gi limit, 4 GB GC cap) sits on nodes that
had 7–9 GB free. It is untested under concurrent pressure.
- **Certificate rotation is designed, not exercised.** The server cert renews 30 days early but
buildkitd only reads it at start; the client copy needs the script re-run.
- **`--oci-worker-no-process-sandbox` is an accepted risk, not a mitigated one.** A build step can
ptrace the daemon and read its TLS key and cache. Anyone with the client cert, which means every
PR job, can run build steps.
- **Renovate has still never completed a run.** It is blocked on #80, on `RENOVATE_TOKEN` gaining
`read:package`, and on this branch. The `[email protected]` + 44.x image pairing is
untested.
- **Pods cannot pull these images until PR #61** (the kubelet pull secret) merges.
- **`registry-retention.sh --apply` has never run**; its keep logic has only run against an empty
registry.
@@ -0,0 +1,172 @@
---
date: 2026-10-29
branch: feat/keycloak-passkey-reconcile
title: "Passkey reconciler review fixes: bind only on git's say-so (rollback sticks), protect the real 26.0.6 sub-flows, screen the requirement PUT by body id, alert on failure"
---
## What changed
This branch carries the unmerged passkey reconciler (commit `36fcb7b`, cherry-picked with its
authorship, recorded in `decisions/2026-10-18-keycloak-passkey-reconciler.md`) plus the fixes for a
review of it. Read that entry for the design; this one records what the review found and what
changed.
**1. HIGH: the bind was automatic, and rollback did not stick.** Within one tick of merge the
reconciler would build and BIND `browser-mfa-<hash>`, a login flow no human had tried. Worse, the
README's rollback (rebind `browser-mfa`) was undone every 15 minutes: the next run found the
verified candidate again and rebound it, and a hotfix inside the bound flow was "corrected" by
building and binding an `-r2`.
- New git-declared switch: `bind-browser-flow` in `platform/keycloak/passkey-desired-state.yaml`,
a second key of the same ConfigMap, so it reaches the pod through the existing mount. **Default
`"false"`.** It is the one hand-edited line in a generated file: the renderer carries it through
on `--write`, and `--check` (run by the L0 gate) refuses anything but the quoted strings `"true"`
/ `"false"`. An unquoted `true` is a YAML boolean, which a ConfigMap rejects at apply time and
which would have failed the whole `platform-config` Kustomization, realm import included.
- `"false"`: pass 3 builds and verifies the candidate (or reuses a verified one), logs
`WOULD BIND <alias>`, exits 0 with `KEYCLOAK_PASSKEY_RECONCILED_OK_PENDING_BIND`, and never PUTs
a changed `browserFlow`. That holds after an earlier bind too, so the operator's rebind sticks.
If the bound flow *is* the candidate alias and has been hand-edited, it is left alone (no `-r2`
is built beside it).
- `"true"`: binds as before, and keeps asserting it. That is GitOps working; the README says so,
and says not to hotfix the flow while it is `"true"`.
- The CronJob passes no flag. `--no-bind` still exists for a hand-run Job and can only narrow
git's value. The L0 check asserts the CronJob carries no `--` argument.
- Rollback is now: a PR setting `"false"` (first), then one rebind write. README documents the
order and why it matters, plus how to test the candidate on one client (a per-client
`account-console` browser-flow override) before the go-live PR.
**2. HIGH: the protected-subtree guard read the wrong field for sub-flows.** Read from source at tag
26.0.6 (`services/src/main/java/org/keycloak/services/resources/admin/AuthenticationManagementResource.java`,
fetched from GitHub this session): `recurseExecutions` sets `displayName = flowRef.getAlias()` and
`flowId` on a sub-flow row and never calls `setAlias`. `alias` is set only on authenticator rows,
where it holds the bound **config's** alias. So the protected set was `{bound}` alone, and a write
into `browser-mfa-forms` would not have been refused. The old stub returned `alias` on sub-flow
rows, which is why all 216 assertions passed anyway.
- New `subflow_alias()` reads `displayName` and cites the source. I did not resolve by `flowId`:
`getFlows()` (same file) filters to `isTopLevel()`, so a nested sub-flow's id resolves to nothing
through the flows list. A sub-flow row with no `displayName` makes the listing unreadable, which
leaves the flow pass fail-closed.
- The stub now returns rows shaped like 26.0.6's: sub-flow rows carry `displayName`, `flowId` and no
`alias`/`providerId`. Authenticator rows carry a display *type* as `displayName` (not the
provider id) and the config alias as `alias`. Nulls are omitted.
- New assertions: after a real run, `browser-mfa-forms` and `browser-mfa-conditional-otp` are in
the protected set, and an execution add or requirement write into them is refused. A missing
`displayName` fails closed.
**3. MEDIUM: `PUT /flows/{alias}/executions` was screened on the wrong field.** `updateExecutions`
resolves `realm.getAuthenticationExecutionById(rep.getId())` without checking membership in
`{alias}`. The same handler also renames the flow named by `rep.getFlowId()` when the body carries
`authenticationFlow` + `displayName`. Now:
- `body["id"]` is screened against the protected execution ids.
- It must also be in `_BUILD_EXECUTION_IDS`: the ids read back from the flow this call just
created, open only inside `build_flow`.
- The config `POST /executions/{id}/config` is held to the same build scope.
- The body may carry only `id`, `requirement` and `priority`.
- Selftest cases cover each of these.
**4. Stale docs on sensitive paths** now describe what ships, including the switch:
`platform-config/kustomization.yaml`, the chainsaw test header, the desired-state header and its
renderer template, the renderer's own header and `--check` message, and the README.
**5.**
- `backoffLimit: 0`: every failure is deterministic within a tick, and a retried build burns
another of the 5 candidate aliases.
- New alert `KeycloakPasskeyReconcilerFailing` in `platform/monitoring/alerts.yaml` (warning,
`for: 15m`, `kube_job_status_failed` on `job_name=~"keycloak-passkey-reconciler-.*"`), the same
shape as the sibling's. It has promtool tests in
`platform/monitoring/tests/keycloak-passkey-reconciler.test.yaml`, in both directions plus
"the sibling's failure is not this alert" and "one transient failure stays quiet". The alert
ratchet in `alert-rules-unit-tested` would have refused it untested. A new runbook,
`runbooks/keycloak-passkey-reconciler.md`, is projected by the runbooks generator.
- The stale "Renovate does not manage plain `image:` tags" comment is corrected: `renovate.json5`
enables the `kubernetes` manager over `platform/**`. The image is pinned to
`python:3.13.15-alpine3.23@sha256:6438599575cca0d1df94aeee0d2ae088d4d8846eab554b2ee7784a3a6df0d516`,
the multi-arch index digest read from Docker Hub today. The tag is mutable: the sibling
reconciler's pod on prod reports `imageID ...@sha256:75f27d68...` under the same tag, which is
neither today's index nor today's amd64 manifest.
**6. Hygiene.** The ~160-line header that appeared in both the YAML and the script is now kept once,
in the script, where it ships next to the code and its selftest. A short header above the
ConfigMap points to it. Nothing else was restructured, apart from the build's second half moving
into `_finish_build()` so the build scope can be opened and closed around it.
Sensitive paths touched: `ci/**` (renderer), `conformance/**` (L0 check, mutations, chainsaw),
`platform-config/kustomization.yaml` (comment only). This needs the operator's
`/approve-override` or a non-author review. I did not post one.
## What I verified
- **Local toolbox gate, rc 3** (pinned toolbox via `dev.sh`, `DEV_KUBECONFIG=/dev/null`, ENGINE
shim bind-mounting the main `.git`, run on the committed branch). Stages: `spec-immutability`
pass (the spec repo resolved as a sibling), `decisions-log` pass, `lint` pass, `l0` pass
(including `keycloak-passkey-reconciler`, `alert-rules-unit-tested` and
`alert-runbooks-resolvable`), `render-golden` pass, `l1` pass, `approval-policy` pass (N/A
offline; it is guaranteed to fail in CI). **`gitea-settings` did not run** (no
`SETTINGS_API_TOKEN` locally) and is the only stage that did not run. L2/L3 `conformance` is
skipped by design (no cluster). rc 3 is **not** green.
- `platform/monitoring/tests/run.sh platform/monitoring/tests/keycloak-passkey-reconciler.test.yaml`
in the toolbox: `promtool check rules` SUCCESS (54 rules), `promtool test rules` SUCCESS,
promtool 3.14.0.
- **Selftest: 259 assertions pass** (was 216), extracted from the shipped ConfigMap by the L0
check.
- **`mutations.py`: 40 of 40 injected regressions caught** (was 30). The ten new ones each undo
one fix:
- sub-flows read by `alias` again;
- an unnameable sub-flow tolerated;
- the requirement PUT screened on its path only;
- execution writes not held to the build tree;
- extra body keys allowed;
- the bind switch ignored in `run()`;
- the bind switch ignored in `reconcile_flow()`;
- an absent switch reading as true;
- an invalid switch guessed at;
- `-r2` built beside a hand-edited bound flow.
"Sub-flows by `alias`" is caught less cleanly (the selftest aborts on an unreadable listing
rather than failing an assertion). It is still caught.
- The renderer accepts `"true"` and `"false"` and refuses `true` (unquoted) and `"yes"`. I
checked this by hand-editing the committed file and running `--check`.
- The 26.0.6 source for `recurseExecutions`, `updateExecutions` and `getFlows` was read directly
(lines ~225-231, 658-724 and 747-806 of the file at that tag).
- Read-only `kubectl --context k3s-prod`: Keycloak pods run `quay.io/keycloak/keycloak:26.0.6`
(2 replicas). The only CronJob in `keycloak` is `keycloak-client-secret-reconciler`, so the
passkey reconciler is **not** live.
## What I did NOT verify
- **Nothing has run against a real Keycloak.** The stub's row shapes now follow the 26.0.6
source, but they are still my reading of that source, not a captured response. In particular,
I have never seen a live executions listing to confirm `displayName` on a sub-flow row. If it
differs, the failure is fail-closed (no build), not fail-open.
- **The candidate flow has never been tested in a browser.** That is now a precondition of the
go-live PR (README, "Testing the candidate flow before binding it"). Nobody has run those steps.
I also have not checked that the per-client `account-console` override behaves as described on
26.0.6.
- **"Merging changes nothing users see" is true of login, but passes 1 and 2 still write the
realm.** They set the passwordless policy and enable the enrolment action. That action is
enabled by default in 26.0.6, but I did not read the live realm, because no admin API calls were
allowed. Pass 3 also creates the unbound candidate flow (realm DB writes, invisible to login).
- **Race in pass 1, not fixed:** the policy PUT is a read-modify-write that carries `browserFlow`
back from its own GET. An operator rebind landing between that GET and PUT would be reverted.
The window is one HTTP round trip and only exists when the policy has drifted.
- **The alert has never seen a real series.** The CronJob has never run, so
`kube_job_status_failed{job_name=~"keycloak-passkey-reconciler-.*"}` does not exist yet. Its
shape is copied from the sibling's rule, which does have live series.
- **The digest is of today's index.** I did not pull it or run the image. Renovate's handling of a
`tag@digest` pin under the `kubernetes` manager is from its documentation, not from a Renovate
run here.
- **CI's own result** is in the PR, not here. `approval-policy` is expected to fail until the
operator overrides.
### Follow-ups (out of scope here, deliberately not done)
- A dedicated least-privilege service-account client for the reconciler, instead of the
master-realm break-glass admin it shares with the client-secret reconciler.
- A NetworkPolicy for the `keycloak` namespace.
- Whether a passkey login should skip TOTP for platform-admins. This is the PR #72 design
decision, currently "no TOTP after a passkey".
- A `KeycloakPasskeyReconcilerStale` rule (no successful run in N hours), like the sibling's.
Only the failure alert was in scope.
@@ -0,0 +1,57 @@
---
date: 2026-10-29
branch: fix/renovate-config-filename
title: Renovate has failed every run since #79 — the workflow still named renovate.json
---
## What changed
**`.gitea/workflows/renovate.yaml`** — `configurationFile: renovate.json` → `renovate.json5`.
PR #79 (`fix/renovate-invalid-config`) renamed the config to `renovate.json5` and did not update
the one line in the workflow that names it. Measured 2026-10-29 from the Gitea Actions log of run
322 (task 513, schedule, 08:15 UTC):
Error: ENOENT: no such file or directory, stat '/workspace/Home-Network/home-network-k3s-impl/renovate.json'
Runs 315–322 (2026-10-22 → 10-29, every one listed) are all `failure` on the `schedule` event,
each failing in about one second. Renovate never started. `RENOVATE_TOKEN` **does** exist as a
repository Actions secret (listed by name via the API; value not read), so the token preflight
passes and this filename is the only blocker observed.
**`ci/lint.sh`** — a new assertion after yamllint: every non-comment `configurationFile:` key in
`.gitea/workflows/*.y*ml` must name a path that exists. This is valid YAML, so yamllint cannot see
it, and the gate stayed green through 11 days of failures.
**Second blocker, same PR:** the workflow did not pin a Renovate version, so
`renovatebot/[email protected]` ran its default, Renovate **39** (`action.yml`:
`renovate-version` `default: '39'`, read at that tag). `renovate.json5` uses `managerFilePatterns`,
which needs >= 40.2.0. Under 39.264.0 the validator rejects the config with five errors. This was
found and reproduced by the image-pipeline worker (PR #81). #79 had validated against `latest`. Pinned
`renovate-version: 44.167.0`, the version the config validates clean against per that worker.
## What I verified
- The root cause, from the run log quoted above, and that `origin/main` has `renovate.json5` and
no `renovate.json` (`git ls-tree`).
- The new guard, both ways, run standalone: on this branch it prints
`workflow configurationFile references resolve`, rc 0. With the line reverted to `renovate.json`
it prints `configurationFile 'renovate.json' does not exist in the repo`, rc 1. My first version
also matched its own explanatory comment in the workflow and failed. That is why the grep is
anchored to non-comment lines.
- Toolbox gate: result and rc are reported in the PR, not predicted here.
## What I did NOT verify
- **The 39.x rejection and the 44.167.0 pass were not reproduced by me.** I confirmed only the
action's default (`'39'`). The validation results are the PR #81 worker's.
- **Renovate has still never completed a run.** This removes the observed blocker. Others may
follow it. The test is a `workflow_dispatch` with `dry_run: true` after merge, and its log is
the first evidence that Renovate authenticates, extracts dependencies, and honours `.json5`.
- **The `enabledManagers: ["regex"]` vs `custom.regex` question** (#73, #77) is still open. The
same dry-run log answers it: look for the regex/custom manager's dependency-extraction count.
- **No alert for "Renovate keeps failing".** The guard prevents this specific cause from recurring.
Nothing notices a scheduled workflow that is red every day. That gap stays open.
- **Sensitive path.** `.gitea/**` and `ci/**` are both in `approval-policy.json`. CI will block
on `approval-policy` until the operator posts `/approve-override` as a review. The local gate
reports that stage as N/A.
@@ -0,0 +1,81 @@
---
date: 2026-10-30
branch: fix/gitea-admit-buildkit
title: The registry push 403 is Cilium refusing buildkitd's EGRESS at the Gateway — fixed in platform/buildkit, not in Gitea's ingress
---
## What changed
**`platform/buildkit/networkpolicy.yaml`** — `buildkitd-allow-egress` gains one rule:
`toEndpoints {io.kubernetes.pod.namespace: gitea, app.kubernetes.io/name: gitea}` on TCP 3000,
with a comment carrying the measurement. `platform/buildkit/README.md`'s policy row says the same.
**What did NOT change, deliberately:** `catalog/gitea.yaml` `inClusterClients` and
`apps/wave-0/gitea.yaml`. The brief proposed admitting `buildkit` there. The evidence below says
that is the wrong policy: Gitea's ingress is not what refuses the push, and adding it would only
open the direct ClusterIP path from build code to Gitea, which nothing uses.
### Why: the brief's hypothesis was half right
Images run 337 failed its push with `HEAD .../blobs/sha256:5855... 403 Forbidden: Access denied`.
The PM's hypothesis was that this was Cilium's Envoy denying the request, not Gitea. **That part
holds.** The hypothesis also said the denial came from Gitea's *ingress* policy, which does not
admit the `buildkit` namespace. **That part is wrong.** The denial is enforced on buildkitd's own
*egress*. Since Cilium 1.16.8/1.17.2 (CVE-2025-30162; prod runs `cilium:v1.20.1`), the Gateway's
L7 load balancer re-evaluates the source pod's egress policy against the identity of the backend
it resolves to. buildkitd's egress allowed `world` :443/:80 and the `ingress` entity. That gets
the TCP connection to Envoy, but no rule matches Gitea's backend identity, so Envoy answers
`403 Access denied` without forwarding the request. This is the failure commit `68690e5` fixed for
every SSO app's hop to Keycloak, and the fix has the same shape. Keycloak's ingress names no app
namespaces either.
## What I verified
All measurements are read-only, against `k3s-prod`, on 2026-10-30 around 05:27–05:37 UTC.
- **Gitea never saw the failed push.** Its router log for the previous 12h had 316 `/v2/` lines
and **0** that were `toolbox/blobs`, PUT, POST or PATCH. It also had **0** from buildkitd's pod
IP (`10.0.0.7`). In the same window it logged the CI job's own `HEAD .../toolbox/manifests/
sha-0dd57fbaf002 → 404` from the act-runner pod (`10.0.0.38`). Requests that came through the
Gateway reach Gitea and get logged. This one never arrived.
- **A reproduction with no credentials.** An unauthenticated `GET https://gitea.home.keithjackson.dev/v2/`
(Gateway VIP `192.168.2.20`) gave:
- from the buildkitd pod: **`403 Forbidden`**, with no matching line in Gitea's log;
- from the act-runner pod's `dind` container: **`401 Unauthorized`**, which is Gitea's normal
registry challenge, logged by Gitea as `GET /v2/ for 10.0.0.38:0, 401`.
No token was involved, so this cannot be a package-permission problem. The bot account and the
`REGISTRY_PUSH_TOKEN` were not the cause, and the operator does not need to change them.
- **Hubble names the enforcement point.** On `cilium-55jv8`, buildkitd's node:
`buildkit/buildkitd-859d67c8f8-x44nh (ingress) -> gateway/cilium-gateway-shared:443 http-request
DROPPED (HTTP/1.1 GET https://gitea.home.keithjackson.dev/v2/)`, followed by `http-response
FORWARDED (HTTP/1.1 403 0ms)`. In the JSON form, **`traffic_direction: EGRESS`**. Envoy wrote
the 403 itself in 0 ms.
- **Gitea's ingress does not need the source namespace.** The act-runner pod's namespace
(`gitea-actions`) is **not** in Gitea's ingress allows. Its NetworkPolicy
`default-deny-ingress-allow-egress` has an `{}` egress rule, so its egress is unrestricted. Its
requests through the Gateway still reach Gitea. `cilium-dbg bpf policy get` on Gitea's endpoint
(1876 on `cilium-9rk5x`) shows all Gateway traffic admitted as `reserved:ingress 3000/TCP`
(391 MB, 1.19 M packets) and no row for `gitea-actions`. The source-identity check therefore
happens on the source's egress, not on the destination's ingress.
- **The policy gap is visible in the BPF map.** `bpf policy get 83` (buildkitd) has these egress
rows and nothing else: `reserved:world` 80/443, `reserved:ingress ANY`, and kube-dns 53. There
is no Gitea identity.
- The edited YAML parses (three documents, names unchanged).
## What I did NOT verify
- **That this rule turns the 403 into a 401/200. UNVERIFIED.** That needs the policy applied, which
is a Flux/operator action. My confidence is high but not proven, and it rests on two things. The
Hubble verdict is EGRESS. Commit `68690e5` made the identical change for Keycloak and recorded
"Verified live: headlamp-ns -> keycloak discovery 403 -> 200".
- **The port restriction.** The rule names `3000`, Gitea's container port, which is the upstream
port Envoy connects to. The Keycloak precedent has no port. If the 403 survives, the first thing
to test is whether L7LB enforcement uses the frontend :443 instead. I expect it does not.
- **Run 337's own flows.** The Hubble ring buffer had already rotated past them, and buildkitd had
been restarted since (the pod was 18 minutes old). The reproduction above uses the same path, the
same pod spec and the same policy, but it is not the original request.
- Envoy's access logs in the `cilium-envoy` pods. Hubble's L7 flow already shows the drop, so I
did not read them.
- The push itself, the Renovate digest flow, and whether the bot team's `Packages=Write` is enough
for a first push that creates a new package. These only become testable after this lands.
+108
View File
@@ -0,0 +1,108 @@
---
date: 2026-10-30
branch: fix/renovate-auth
title: Renovate's token needs five scopes, not three; the workflow documented the wrong list, and now probes it
---
## What changed
**The fix is operator-side.** `RENOVATE_TOKEN` lacks `read:organization`, and it will next lack
`write:issue`. The operator must re-mint it with five scopes:
- `read:user`
- `read:organization`
- `write:repository`
- `write:issue`
- `read:package`
The git half fixes the two things that sent the operator round this loop twice.
**`.gitea/workflows/renovate.yaml`**:
- **The header's scope list is corrected**, with the call each scope serves. The old list named
only `write:repository`, `read:user` and `read:package`, and the operator minted exactly that.
The preflight's "TO FIX" text is corrected the same way.
- **A new step, "Preflight - the token has every scope Renovate needs".**
- It probes read-only GETs: `/user` for read:user, `/orgs/Home-Network` for read:organization,
`/repos/{repo}` for read:repository, `/repos/{repo}/labels` for read:issue, and registry
`/v2/token` for read:package.
- It reads the token's own scope list out of Gitea's 403 from `/admin/orgs` (names only), and
requires `write:repository` and `write:issue` in it.
- It says what a failure means: 401 is the value; a Gitea 403 is a scope, and the probe prints
Gitea's message; an "Access denied" 403 is a Cilium/Envoy denial.
- The token is fed to curl on stdin (`--config -`), never in argv.
**`ci/lint.sh`**: a new guard rejects any non-comment `uses:` in `.gitea/workflows` that needs a
Docker daemon: `docker://`, `renovatebot/github-action` and Docker's daemon-bound
`docker/*-action`s. That is the class of bug fixed in #84.
### Root cause, with evidence
Run 350 (task 567, 06:49 UTC) **got further than 348 and 349.** Gitea's router log, filtered to
the act-runner pod `10.0.0.38`:
06:49:31 GET /api/v1/user 200 OK
06:49:31 GET /api/v1/version 200 OK
06:49:31 GET /api/v1/repos/Home-Network/home-network-k3s-impl 200 OK
06:49:31 GET /api/v1/orgs/Home-Network 403 Forbidden
There were no `/v2/` requests: Renovate stopped before extraction. From the job log, verbatim:
ERROR: Repository has unknown error (repository=Home-Network/home-network-k3s-impl)
"url": "https://gitea.home.keithjackson.dev/api/v1/orgs/Home-Network",
"message": "token does not have at least one of required scope(s), required=[read:organization], token scope=read:package,write:repository,read:user"
"x-envoy-upstream-service-time": "27", "server": "envoy"
INFO: Repository finished ... "result": "unknown-error", "exitCode": 8
**This is not Cilium.** The body is Gitea's JSON scope message, Gitea logged the request, and
`x-envoy-upstream-service-time` shows that Envoy forwarded it upstream.
**The secret WAS updated.** Runs 348 and 349 got `GET /user 403` from `tokenRequiresScopes`. Run 350
got `/user 200`, and its token scope list now includes `read:user`.
**Why `read:organization` is needed.** Renovate 44.167.0, `lib/modules/platform/gitea/index.ts`
`initRepo`, calls `helper.isOrg` → `GET /orgs/{owner}` and rethrows on error. Gitea 1.24.3,
`routers/api/v1/api.go`, puts `/orgs/{org}` behind
`tokenRequiresScopes(AccessTokenScopeCategoryOrganization)`.
**Why `write:issue` is needed next.** In the same `api.go`, `/repos/{o}/{r}/issues…`, `/labels`
and `/milestones` sit in a group with `tokenRequiresScopes(AccessTokenScopeCategoryIssue)`
(lines 1466–1582). Renovate's gitea-helper uses `/issues`, `/issues/{n}/labels`, `/labels` and
`/issues/{n}/comments`, for the dependency dashboard (`:dependencyDashboard` is extended) and for
PR labels. A dry run only reads them; a scheduled run writes them.
The other causes the coordinator listed are ruled out:
- **Schedule window and dry-run.** The run never reached branch processing.
- **Repository config.** The run never reached the clone (`"cloned": undefined`).
- **The Summary step.** It is not involved; the failure is Renovate's own exit 8.
## What I verified
- I read the whole run 350 job log (204 lines), and the router lines above. Runs 348 and 349 are
as recorded in `decisions/2026-10-30-renovate-first-run.md`'s follow-up: `/user` got 403 from
`tokenRequiresScopes`.
- **The probe step, in the pinned image.** I extracted the step body from the YAML and ran it in
`ghcr.io/renovatebot/renovate:44.167.0` (`curl` 8.5.0 and `jq` are present) with a deliberately
**bogus** token. Every probe reported 401 with the "token VALUE" diagnosis, the scope-list read
fell back to a warning, and the step exited 1.
- **The scope-list parser.** Against run 350's exact message format it reports `write:issue`
MISSING. Against a five-scope list it reports both OK. Against `all` it reports both OK.
- **The lint guard.** It flags `renovatebot/github-action` in the pre-#84 `renovate.yaml`
(dfc432c, line 152). It reports nothing on the current workflows, and it ignores commented
lines. `bash -n ci/lint.sh` passes.
- **Account facts, read-only.** `renovate-bot` is a collaborator with `write`, and the org is
public.
## What I did NOT verify
- **The probe with a real token. UNVERIFIED.** I never had the token. In particular, I have not
observed that `GET /api/v1/admin/orgs` gives renovate-bot's token a 403 with a `token scope=`
list. The source says the scope check runs before `reqSiteAdmin`. If the message format
differs, the step warns and does not fail.
- **That five scopes are enough for a full non-dry run.** They cover every helper endpoint Renovate
44.167.0 calls, mapped to Gitea 1.24.3's route categories by reading source. Branch pushes go
over git-over-HTTPS with the token; I believe that needs write:repository, but I have not
observed it.
- **Registry reads as renovate-bot.** `read:package` on a public org is expected to work. The new
probe is the first real test of it.
- **Sensitive paths.** `.gitea/**` and `ci/**` are both sensitive. CI's `approval-policy` will block
until the operator posts `/approve-override` as a PR review.
+113
View File
@@ -0,0 +1,113 @@
---
date: 2026-10-30
branch: fix/renovate-first-run
title: Renovate never started because its action needs a Docker daemon jobs here do not get; it now runs as the job container
---
## What changed
**`.gitea/workflows/renovate.yaml`**: the job no longer uses `renovatebot/github-action`. The
pinned Renovate image (`ghcr.io/renovatebot/renovate:44.167.0`, the same version the action was
pinned to) is now the job's `container:`. A `run:` step calls `renovate-entrypoint.sh renovate`
inside it. `actions/checkout` is gone, because Renovate clones the repo itself.
`GITHUB_COM_TOKEN` is wired from a new **optional** secret, `RENOVATE_GITHUB_COM_TOKEN`. The step
unsets it, and `RENOVATE_DRY_RUN`, when they evaluate to the empty string. The preflight also
prints `renovate --version`, which proves the step ran inside the pinned image.
**`renovate.json5`**: one comment now names `container.image` as the version pin, in place of
`renovate-version`. The config itself is unchanged.
### Why: the root cause
Every run of the action died before Renovate started. That includes the operator's dispatch,
run 343 (task 555, 2026-10-30 06:14 UTC), and the 11 scheduled runs before it (312–322). From
run 343's log:
RENOVATE_TOKEN is present (value not printed).
[command]/usr/bin/docker run --env LOG_LEVEL ... --env RENOVATE_TOKEN=*** --env RENOVATE_CONFIG_FILE=/github-action/renovate.json5 ...
failed to connect to the docker API at unix:///var/run/docker.sock; check if the path is correct and if the daemon is running: dial unix /var/run/docker.sock: connect: no such file or directory
Error: The process '/usr/bin/docker' failed with exit code 1
The action is only a wrapper around `docker run`. On this runner a job deliberately gets no
Docker daemon (`decisions/2026-10-29-image-pipeline.md`): act_runner maps `docker_host` to `-` when
`DOCKER_HOST` is `tcp://`, and the DinD daemon is privileged. So this was a
platform-compatibility failure. The run never reached authentication, token scope, config or the
network. **The re-minted token is not implicated, and nothing in this log can say whether its
scopes are right.**
A job `container:` needs no daemon. act creates it through the runner's DinD the same way it
creates the default `ubuntu-latest` image, then `exec`s each step inside it. I checked this
against gitea/act v0.261.2:
- The job container's entrypoint is `/bin/sleep` (`run_context.go`). The Renovate image has
`/usr/bin/sleep`.
- The step command files are written `0o666` (`step.go`), so the image's uid 12021 can write
`$GITHUB_STEP_SUMMARY`.
- `HOME` comes from the image env.
## What I verified
- **The root cause.** I read all of run 343's job log (162 lines, job 0; job 1 is 404). It has one
error, quoted above, and no Renovate output at all. Runs 312–322 are all `failure` from the
`schedule` event.
- **The config validates against the pinned tag.** `podman run ... ghcr.io/renovatebot/renovate:44.167.0
renovate-config-validator /tmp/renovate.json5` printed `Config validated successfully`. It also
printed the known migration diff for the top-level `docker` block.
- **A dry run with the fix extracts and looks up dependencies.** This was a local reproduction. I
made a clean snapshot of this branch's tracked files. I ran it in a podman container of the pinned
image with entrypoint `/bin/sleep`, as the image's own uid 12021 with `HOME=/home/ubuntu`.
Inside it I `exec`ed the workflow step's `run:` body, extracted verbatim from the YAML, with:
- `RENOVATE_PLATFORM=local`, `RENOVATE_DRY_RUN=full` and `GITHUB_COM_TOKEN=` (empty, to
exercise the unset);
- a docker host rule for `gitea.home.keithjackson.dev` using the `gitea-admin` credential from
`~/.git-credentials`, passed through env and never printed.
The run exited 0 and logged `Dependency extraction complete`:
- flux 15 deps / 13 files
- github-actions 24 / 4
- kubernetes 131 / 67
- kustomize 2 / 2
- regex (both custom managers) 7 / 6
- **total 179 deps / 92 files**
It found **42 updates**: 29 kubernetes, 6 flux, 2 kustomize, 1 github-actions and 4 regex
`digest`. 7 of them are `major`.
- **The `# renovate-digest:` lookups against the Gitea registry succeed.** All three
`/v2/token?scope=repository:home-network/<img>:pull` requests succeeded, and so did
`HEAD /v2/home-network/<img>/manifests/main`. They proposed `digest` updates for
`conformance/versions.env`, `infra/agent/versions.env`, `platform/pm-agent/configmap.yaml` and
`platform/pm-agent/deployment.yaml`, all of which carry the zero placeholder digest today. The
`kubernetes` manager's copies of those images report `disabled`, which is the intended effect of
the packageRule.
- **The new pin is still maintained by Renovate.** The github-actions manager extracts
`ghcr.io/renovatebot/renovate:44.167.0` from `renovate.yaml` as depType `container`, and proposes
44.198.0.
- **The `regex` vs `custom.regex` question from #73/#77 is closed.** Both custom managers produced
dependencies.
- **Missing GitHub token.** The local run logged `GitHub token is required for some dependencies`.
That accounts for 17 deps skipped with `github-token-required`, which is why
`RENOVATE_GITHUB_COM_TOKEN` is offered as an optional secret.
## What I did NOT verify
- **The workflow on the real runner. UNVERIFIED.** My reproduction ran with podman, not
act_runner + DinD, and used `--platform=local`. The act behaviour (sleep entrypoint, `exec` as the
image user, 0666 command files) comes from reading the gitea/act v0.261.2 source, not from a run.
The first real evidence will be a `workflow_dispatch` after merge.
- **The Gitea platform path and `RENOVATE_TOKEN`'s scopes. UNVERIFIED.** I have no access token, only
the `gitea-admin` password, which Renovate's Gitea platform cannot use. So these were not
exercised: platform init (`GET /user`, which needs `read:user`), repo clone, and the registry
lookups using the **token** with `read:package` rather than a password.
- **Behaviour inside the schedule window.** A dispatch outside 03:00–06:00 America/New_York
(07:00–10:00 UTC during EDT) will extract and look up, but will log "not within schedule"
instead of `DRY-RUN: Would create ...` lines. I did not change this.
- **Renovate as uid 12021 doing artifact updates** (for example helm lockfiles) on a real,
non-dry run. `--dry-run=full` does not run the tools.
- **The self-bump.** Renovate will propose `ghcr.io/renovatebot/renovate` 44.167.0 → 44.198.0 as a
minor, which the default rule auto-merges. `.gitea/**` is a sensitive path, so it will in fact
wait for an administrator override. Nothing re-runs `renovate-config-validator` on that bump,
and no gate check runs the validator.
- **No alert for "Renovate red every day".** That gap is still open. This failure ran for 12 days
without anyone noticing.
- **Sensitive path.** `.gitea/**` is in `infra/gitea/approval-policy.json`, so CI's
`approval-policy` stage will block until the operator posts `/approve-override` as a PR review.
+222 -116
View File
@@ -1,61 +1,43 @@
# Bulk NFS storage — the media library and the legacy Nextcloud archive
The operator-facing companion to the `class: bulk` storage entries in `catalog/{plex,sonarr,radarr,nextcloud}.yaml`.
Everything here was **measured** on the storage host on 2026-09-07, over read-only SSH; nothing is
assumed. It resolves the long-standing **OQ-2** stub (`platform/longhorn/storageclasses.yaml`) for the
The operator-facing companion to the `class: bulk` storage entries in
`catalog/{plex,sonarr,radarr,qbittorrent,nextcloud}.yaml`.
**Rewritten 2026-10-07 for the move off Proxmox onto a standalone TrueNAS at 192.168.2.30.** The
layout (§2), the mount design (§3, §4) and the uid model (§6) are unchanged by the move and were
measured on the old host on 2026-09-07. Everything about the storage host itself — §1, §5, §10 —
was re-measured on the NAS on 2026-10-07. Nothing here is assumed. It resolves the long-standing
**OQ-2** stub (`platform/longhorn/storageclasses.yaml`) for the
case that actually exists here — attaching datasets that already hold data — and leaves dynamic bulk
provisioning unimplemented, because nothing needs it.
---
## 1. The storage host, and why prod cannot reach it
## 1. The storage host
The bulk data lives on the **Proxmox host's ZFS pool**. That host has exactly one interface:
The bulk data lives on a **standalone TrueNAS** (Community Edition) at **192.168.2.30** — the same
`192.168.2.0/24` as prod's nodes (`192.168.2.10/.11/.12`), so it is reached directly, with no NAT
and no routing decision.
```
vmbr0 192.168.1.9/24 default via 192.168.1.1 (pfSense LAN)
```
| | nodes | reaches 192.168.1.9? |
|---|---|---|
| **dev** | 192.168.1.40 / .41 / .42 | **Yes** — same L2 segment, and it already holds an NFS export from this host (`/mnt/Storage/k8s-volumes`, in `/etc/exports`) |
| **prod** | 192.168.2.10 / .11 / .12 | **No** — 192.168.2.0/24 is pfSense's **WAN**. Ports 22 and 8006 answer only because pfSense port-forwards them via 192.168.2.102. 2049 and 111 do not. |
Opening the storage path for prod is a **pending operator decision**. Its two candidate outcomes give
different values for the `BULK_NFS_SERVER` cluster-var:
| outcome | `BULK_NFS_SERVER` for prod |
|---|---|
| port-forward `192.168.2.102:2049` → `192.168.1.9:2049` | `192.168.2.102` |
| a WAN→LAN route to 192.168.1.0/24 | `192.168.1.9` |
Until it lands, `clusters/prod` holds `TBD-PROD-BULK-NFS-SERVER`. **See §7 for why that placeholder
behaves differently from the repo's other ones.**
**This section used to be the hard part of this page.** The datasets were on the Proxmox host, whose
only interface was `vmbr0 192.168.1.9/24` on pfSense's **LAN**, while prod sits on pfSense's **WAN**.
tcp/2049 did not traverse that, so prod mounted through a DNAT at `192.168.2.102`, and which address
belonged in `BULK_NFS_SERVER` depended on an undecided operator choice. Both pools were physically
moved on 2026-10-07 (`docs/plans/proxmox-to-n5-air-migration.md`, Phase C/D) and all of that is
simply gone. Measured 2026-10-07: **192.168.2.102:2049 no longer answers; 192.168.2.30:2049 and
:111 do.**
### It only needs ONE port
The PVs mount `nfsvers=4.1`. **NFSv4 carries mount and lock in-protocol**, so the only thing that
must be reachable is **tcp/2049** — no rpcbind/111, no mountd, no statd. NFSv3 would have needed
four ports, three of them on ephemeral numbers, which is a materially worse firewall story. The
server advertises `+3 +4 +4.1 +4.2` (`/proc/fs/nfsd/versions`), so v4.1 is available and is the
conservative floor.
four ports, three of them on ephemeral numbers. The NAS advertises `+3 +4 +4.1 +4.2`
(`/proc/fs/nfsd/versions`, measured 2026-10-07), so 4.1 is available and is the conservative floor.
### The export's client list is the SAME under both outcomes — measured, not assumed
**pfSense DNATs the existing port-forward WITHOUT source NAT.** Connecting from this workstation
(192.168.2.132) to `192.168.2.102:22`, the storage host logged the peer as `192.168.2.132`:
```
SSH_CONNECTION=192.168.2.132 39768 192.168.1.9 22
ss -tn -> 192.168.1.9:22 192.168.2.132:39768
```
The original WAN-side source address survives the translation (return traffic finds its way back via
the host's default route, 192.168.1.1). So under **either** outcome the NFS server will see prod's
**real** node addresses — 192.168.2.10/.11/.12 — and the `sharenfs` change in §5 is the same either
way. **It does not have to wait for the routing decision.**
---
**NFSv4 is not on by default and its absence does not look like a storage problem.** TrueNAS ships
NFSv3-only until NFSv4 is added to *Services → NFS → Enabled Protocols*. Without it the mount never
completes and the pods sit in `ContainerCreating` — no permission error, nothing in the app logs,
just no volume.
## 2. The layout, as found
@@ -107,16 +89,18 @@ why it is mounted read-only; see §6.
| plex | `/mnt/Storage/plex` | `/media` | — | **ro** | ReadOnlyMany |
| sonarr | `/mnt/Storage/plex` | `/media` | — | rw | ReadWriteMany |
| radarr | `/mnt/Storage/plex` | `/media` | — | rw | ReadWriteMany |
| qbittorrent | `/mnt/Storage/plex` | `/downloads` | `downloads` | rw | ReadWriteMany |
| nextcloud | `/mnt/Storage/nextcloud` | `/mnt/legacy-nextcloud` | `nextcloud` | **ro** | ReadOnlyMany |
In-app paths that follow: Plex libraries at `/media/movies`, `/media/tv`, `/media/photos`; Sonarr
Root Folder `/media/tv`; Radarr Root Folder `/media/movies`; both importing from
`/media/downloads/complete/{tv-sonarr,radarr}`.
**All three media consumers use the identical mountPath on purpose.** When qbittorrent is eventually
given the same export (it is not in this change), the arrs and the download client will already agree
on absolute paths, which is the second precondition for hardlinked imports — otherwise every import
needs a Remote Path Mapping and quietly falls back to copying when one is missing.
**The three library consumers use the identical mountPath on purpose**, and qbittorrent has since
been given the same export (narrowed by `subPath: downloads`, presented at `/downloads` to match its
existing `Session\DefaultSavePath`). The arrs and the download client therefore agree on absolute
paths, which is the second precondition for hardlinked imports — otherwise every import needs a
Remote Path Mapping and quietly falls back to copying when one is missing.
**`/media` and not `/data`, and that is not cosmetic.** The arrs' Longhorn config PVC renders at the
renderer default `/data/config`. A media mount at `/data` would be mounted **over** it and mask the
@@ -147,62 +131,123 @@ Narrowing that would cost hardlinks, which is the worse trade.
---
## 5. The export change the operator must apply — STRICTLY ADDITIVE
## 5. The exports, as configured on TrueNAS — and the settings that decide permissions
**Do not apply this from the repo.** It is an operator step on the storage host, recorded here so the
exact value is reviewable. The existing consumers **must keep working**: 192.168.1.6, 10.123.1.10 and
192.168.1.18 on the media tree, and 192.168.1.6 and 192.168.1.5 on the Nextcloud dataset, are the
live non-k3s prod stack.
**Do not apply this from the repo.** It is operator configuration on the NAS, recorded here so the
exact values are reviewable. Everything below was **measured on 192.168.2.30 on 2026-10-07**, after
the move.
Current values (`zfs get sharenfs Storage`):
### The exports
```
Storage/plex rw=@192.168.1.6:@10.123.1.10:@192.168.1.18,no_subtree_check,no_root_squash
Storage/nextcloud [email protected]:@192.168.1.5,no_subtree_check,no_root_squash
/mnt/Storage/plex 192.168.2.10 192.168.2.11 192.168.2.12
(sync,wdelay,hide,no_subtree_check,sec=sys,rw,secure,root_squash,no_all_squash)
/mnt/Storage/nextcloud 192.168.2.10 192.168.2.11 192.168.2.12
(sync,wdelay,hide,no_subtree_check,sec=sys,ro,secure,root_squash,no_all_squash)
```
### `Storage/plex` — append the six k3s node addresses to the existing `rw=` list
Per-node addresses rather than `@192.168.2.0/24`, following the precedent of the old host. A `/24`
would work and would survive adding a node; it also grants the whole subnet.
**All three nodes must appear on BOTH shares.** Any media pod can be scheduled on any node, so a
missing node is a latent failure, not an immediate one: it surfaces the first time a pod reschedules
there, as `access denied by server while mounting`, with the cause weeks stale. One was caught
exactly this way during setup — the plex share listed `192.168.2.13`, an address no node holds, and
so was missing `192.168.2.12` (k3s-prod-3). Nothing was failing at the time.
### The option flags ARE the permission contract
| flag | why it matters here |
|---|---|
| **`no_all_squash`** | The one the whole model rests on. Client uids pass through untouched, so the arrs' `PUID=1000` arrives as the owner of a `0755 1000:3000` tree. In the UI this is **Mapall User/Group left EMPTY** — setting either squashes every client to one identity and erases the reader/writer distinction entirely. |
| **`sec=sys`** | AUTH_SYS. Numeric uid/gid, not Kerberos principals. §6 is only true because of this. |
| **`root_squash`** | Maproot left empty. The Proxmox export carried `no_root_squash`; **nothing here needs it** — every write to the tree happens after the LSIO drop to uid 1000, and the images only chown their Longhorn `/config`. This is a deliberate tightening over the old export, not parity. |
| **`rw` / `ro`** | The archive's read-only posture is **server-enforced**, as it was before. The media tree cannot get the same treatment — see below. |
| **`secure`** | Reserved source ports required; "Allow non-root mount" off. The kubelet mounts as root from a reserved port and does not need the exception. |
**Plex's read-only posture is still a CLIENT-side guarantee.** Plex is ro and the arrs are rw, but
all four can land on any node, so the server sees the same three source addresses for all of them
and cannot distinguish the reader from the writers. `spec.nfs.readOnly: true` on `plex-media` plus
the volumeMount flag is the whole enforcement. Unchanged by the move; an honest limitation, not an
oversight. If it ever needs to be server-enforced, the lever is a dedicated node pool for Plex.
### Service settings that are NOT obvious from the export list
- **Enabled Protocols must include NFSv4** (above).
- **Require Kerberos for NFSv4: off.** The numeric AUTH_SYS pass-through is the model.
- **Manage Groups Server-side: off.** This is `rpc.mountd --manage-gids`: the server *discards the
client's supplied group list* and looks that uid's groups up in TrueNAS's own user database
instead. With no local uid 1000 defined, the arrs' `PGID=3000` would simply vanish. Writes would
survive anyway — uid 1000 **owns** the tree, so the owner bits grant access without the group —
but it is a free way to lose group semantics.
- **NFSv4 DNS Domain: blank.** It only feeds `idmapd.conf`, which numeric mapping bypasses.
- **Bind IP 192.168.2.30** means loopback does not answer: check with `showmount -e 192.168.2.30`,
not `localhost`.
There is **no** "NFSv3 ownership model for NFSv4" control on current TrueNAS — it was an older
TrueNAS/CORE option, and no replacement is needed: on a Linux client against a Linux server over
AUTH_SYS, both the `nfs` and `nfsd` modules default to `nfs4_disable_idmapping=Y`, so NFSv4 carries
raw numeric ids rather than `user@domain` principals. §9's `ls -n` probe is how you confirm it.
### The ZFS `sharenfs` properties must be CLEARED, not edited
The pools arrived with the old host's exports still set as dataset properties:
```
Storage/plex [email protected]:@10.123.1.10:@192.168.1.18:@192.168.2.10:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash
Storage/nextcloud [email protected]:@192.168.1.5,[email protected]:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash
Storage/docker-volumes [email protected]:@192.168.1.5:@192.168.1.10:@192.168.1.18,no_subtree_check,no_root_squash
```
TrueNAS generates `/etc/exports` from its own middleware database and will fight a property it did
not set — the failure mode is two differently-configured exports of one path, with no indication
which is in force. Cleared 2026-10-07:
```sh
zfs set sharenfs='[email protected]:@10.123.1.10:@192.168.1.18:@192.168.1.40:@192.168.1.41:@192.168.1.42:@192.168.2.10:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash' Storage/plex
zfs inherit sharenfs Storage/plex
zfs inherit sharenfs Storage/nextcloud
zfs inherit sharenfs Storage/docker-volumes
```
Every pre-existing host is preserved verbatim and no option changes; only clients are added.
`Storage` is `off`, so each resolves to `off`.
### `Storage/nextcloud` — add the k3s nodes to a **`ro=`** list, not to `rw=`
### Do NOT touch the dataset's permissions or ACLs
```sh
zfs set sharenfs='[email protected]:@192.168.1.5,[email protected]:@192.168.1.41:@192.168.1.42:@192.168.2.10:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash' Storage/nextcloud
```
The tree's ownership **is** the access story (§6), and TrueNAS offers two ways to destroy it
irreversibly across 8.33 T.
This is the one place read-only can be enforced **server-side**, and it is worth taking: only
Nextcloud consumes this dataset from k3s, so the export itself can refuse writes. Even a
misconfigured client cannot then damage the archive.
`Storage/plex` carries `acltype=nfsv4` (SCALE's built-in default, `SOURCE: default`) and
`aclinherit=passthrough` (inherited). Measured 2026-10-07: the directories carry **trivial ACLs
only** — `nfs4xdr_getfacl` shows just `owner@`/`group@`/`everyone@` matching the mode — so `0755
1000:3000` governs exactly as it did under Proxmox.
**The media tree cannot get the same treatment.** Plex is read-only and the arrs are read-write, but
all three may be scheduled on any node, so the server sees the same source addresses for all of
them and cannot distinguish the reader from the writers. **Plex's read-only posture is therefore a
client-side guarantee** (`spec.nfs.readOnly: true` on `plex-media`, plus the volumeMount flag) — an
honest limitation, not an oversight. If it ever needs to be server-enforced, the lever is a
dedicated node pool for Plex, which is a much larger change.
- **Stay out of the dataset's Edit Permissions / Edit ACL screen.** On an `acltype=nfsv4` dataset
that is a full ACL editor, and its presets write *real* NFSv4 ACLs, recursively if permitted.
There is no undo.
- `aclinherit=passthrough` means a single ACL written at `/mnt/Storage/plex` would be **inherited by
everything created under it afterwards** — the blast radius is the whole future library, not one
directory.
- Note GNU `ls`'s `+` marker is **not** a reliable ACL indicator here: it looks for a POSIX ACL
xattr, and this dataset stores NFSv4 ACLs elsewhere. Use `nfs4xdr_getfacl`.
Per-node addresses rather than `@192.168.2.0/24` follows the precedent already on this host
(`/etc/exports` lists the dev nodes individually, as `/32`). A `/24` would work and would survive
adding a node; it also grants the whole subnet. Per-node is the tighter default.
`xattr=on` rather than `sa` is a performance detail, not a permissions one, and changing it would
not affect existing files.
### Verifying, without changing anything
```sh
exportfs -v # the new client lines appear, existing ones unchanged
zfs get sharenfs Storage/plex Storage/nextcloud
cat /proc/fs/nfsd/versions # +4.1 must be present
exportfs -v # the two shares, three nodes each, flags as tabulated above
showmount -e 192.168.2.30 # NOT localhost -- the service is bound to one address
zfs get -r -o name,property,value,source sharenfs Storage # every row off or -
```
---
## 6. uid / gid — the failure this design exists to avoid
The export uses `sec=sys` and `no_root_squash`, so **the client's numeric uid is passed through
verbatim** and the server's on-disk ownership is the entire access story. The media tree is
The export uses `sec=sys` and `no_all_squash`, so **the client's numeric uid is passed through
verbatim** and the server's on-disk ownership is the entire access story. (It no longer carries
`no_root_squash`, which the Proxmox export did — see §5. Nothing here needs it, and the uid that
matters is 1000, not 0.) The media tree is
`0755 1000:3000` throughout (some directories `1000:1000`), so **only uid 1000 can write it**.
**`fsGroup` cannot fix this, and reaching for it is how this gets misdiagnosed.** The in-tree NFS
@@ -233,24 +278,27 @@ Nextcloud needs no uid work at all, which is one more reason its mount is read-o
---
## 7. The prod placeholder does NOT fail at apply time — read this before merging
## 7. Why this knob is unvalidated at create AND immutable at update
**The `TBD-PROD-BULK-NFS-SERVER` placeholder is gone** (2026-10-07) — prod now carries a real
address, because the NAS is on prod's own subnet and there is no longer a decision to defer. The
hazard that made it load-bearing is worth keeping on the page, because it is the half that explains
why §10 exists.
Every other `TBD-PROD-*` in `clusters/prod/kustomization.yaml` is rejected loudly by CRD validation
(Cilium, LB-IPAM, CNPG, Velero) because it is not a valid IP or URL. **This one is not.**
(Cilium, LB-IPAM, CNPG, Velero) because it is not a valid IP or URL. **This one was not.**
A PersistentVolume's `spec.nfs.server` is an **unvalidated string**. The API server accepts
`TBD-PROD-BULK-NFS-SERVER`, the PV is created, and the PVC **binds** to it — binding is pure
control-plane and never touches the server. The failure appears only when a pod mounts:
A PersistentVolume's `spec.nfs.server` is an **unvalidated string**. The API server accepted
`TBD-PROD-BULK-NFS-SERVER`, the PV was created, and the PVC **bound** to it — binding is pure
control-plane and never touches the server. The failure appeared only when a pod mounted:
```
MountVolume.SetUp failed ... Failed to resolve server TBD-PROD-BULK-NFS-SERVER
```
so **plex, sonarr, radarr and nextcloud would sit in ContainerCreating on prod.**
**Consequently: set `BULK_NFS_SERVER` in `clusters/prod/kustomization.yaml` BEFORE prod reconciles a
revision that carries these bulk volumes.** Dev renders a working value and is where these manifests
are meant to be proven first.
**The same field is immutable on UPDATE** (§10), which is the other half of the same awkwardness: a
wrong value is accepted silently at create, and then cannot be corrected in place. Together they are
why repointing this knob is a delete-and-recreate rather than a reconcile.
### The static guard this convention leans on is missing from `main` — but the gate now refuses the value anyway
@@ -287,10 +335,9 @@ Without the arm, HALF A is what holds the line, by design.
EPHEMERAL layer"). Plex was fixed; sonarr, radarr, qbittorrent and jackett were not. Fixing it
means those apps start from an empty config volume, so it belongs in its own change with the
operator present. **This change does not make it worse** — that is why the media mount is `/media`.
2. **qbittorrent has no bulk mount.** Today the arrs' import source is written by the **legacy
non-k3s** download client, which is coherent while the legacy stack stays live. Giving the k3s
qbittorrent the same export at the same `/media` path is the natural follow-up, and the paths were
chosen so it needs no Remote Path Mapping.
2. **RESOLVED — qbittorrent now has the bulk mount** (`subPath: downloads` at `/downloads`), so the
import source is written by the k3s download client rather than the legacy non-k3s one. See
`catalog/qbittorrent.yaml`.
3. **`/mnt/Storage/plex/downloads/downloads` is a symlink to `/shared/downloads`**, which is outside
the export. Over NFS it is a dangling symlink on every client. Harmless, but expect Sonarr to log
it while scanning `downloads/`.
@@ -300,10 +347,10 @@ Without the arm, HALF A is what holds the line, by design.
5. **`nconnect` was not set.** It would likely help throughput on the media tree, but it is a tuning
change that should be measured, not guessed, and it changes client behaviour under server
failure.
6. **Nothing was verified against a live cluster.** No `kubectl` context is usable from this
workstation (browser-OIDC only), and this change is manifest-authoring under an explicit
do-not-apply constraint. Every claim about the *host* is measured; every claim about *binding and
mounting* is reasoned from the manifests.
6. **Every claim about the NAS in §5 is measured (2026-10-07); the post-move MOUNT is not.** The PVs
still have to be recreated (§10) before any pod mounts 192.168.2.30, so the §9 probes are the
outstanding verification, not a formality. The pre-move equivalents did pass against the old host
on 2026-09-12.
7. **The bulk-tier requirement ID is deliberately not typed on any non-`.md` line of this change.**
`ci/touched-requirements.sh` attributes requirements from R-IDs appearing in added lines, and the
in-cluster conformance test for the bulk tier is still a gap, so typing it would select a
@@ -312,36 +359,95 @@ Without the arm, HALF A is what holds the line, by design.
---
## 9. Validating on dev, once the export is extended
## 9. Validating, after the PVs are recreated
Dev can prove all of this before prod's network path exists. Read-only checks first:
These run against **prod**; dev is decommissioned. They are the outstanding verification for the
move — nothing below has been run against 192.168.2.30. Read-only checks first:
```sh
infra/devcontainer/dev.sh run kubectl --context k3s-dev get pv
infra/devcontainer/dev.sh run kubectl --context k3s-dev get pvc -A | grep -E 'media|legacy-archive'
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n sonarr get pod -o wide
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get pv -o custom-columns=\
NAME:.metadata.name,SERVER:.spec.nfs.server,PATH:.spec.nfs.path,STATUS:.status.phase'
```
Expect **four** PVs — `plex-media`, `sonarr-media`, `radarr-media`, `nextcloud-legacy-archive` (three
of them naming the *same* export, one per namespace, which is the cross-namespace model) — each
`Bound` to the same-named PVC, with `RECLAIM POLICY Retain` and an empty `STORAGECLASS`.
Expect **five** NFS PVs — `plex-media`, `sonarr-media`, `radarr-media`, `qbittorrent-downloads`,
`nextcloud-legacy-archive` (four of them naming the *same* export, one per namespace, which is the
cross-namespace model) — each `Bound`, `RECLAIM POLICY Retain`, empty `STORAGECLASS`, and
**`SERVER 192.168.2.30`**. A PV still showing `192.168.2.102` means the recreate in §10 did not
happen; Flux will NOT have updated it in place.
Then the two things that actually matter, from inside a pod:
Then the four things that actually matter, from inside a pod:
```sh
# (a) the writer can write — this is the uid check, and it is the one that fails in practice
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n sonarr exec deploy/sonarr -- \
sh -c 'id; touch /media/tv/.k3s-write-probe && rm /media/tv/.k3s-write-probe && echo WRITE-OK'
# (a) numeric ids survived the wire -- the idmapping check, new since the TrueNAS move
kubectl --context k3s-prod -n sonarr exec deploy/sonarr -c sonarr -- sh -c 'ls -n /media | head -3'
# expect uid 1000 / gid 3000 on the entries. 65534 / nobody means ids are being mapped, not
# passed through, and every import will fail EACCES while the pod still reports Ready.
# (b) hardlinks work BETWEEN the library and the download path — the root-mount check
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n sonarr exec deploy/sonarr -- \
# (b) the writer can write -- the uid check, and the one that fails in practice
kubectl --context k3s-prod -n sonarr exec deploy/sonarr -c sonarr -- \
sh -c 'touch /media/tv/.k3s-write-probe && rm /media/tv/.k3s-write-probe && echo WRITE-OK'
# (c) hardlinks work BETWEEN the library and the download path -- the root-mount check
kubectl --context k3s-prod -n sonarr exec deploy/sonarr -c sonarr -- \
sh -c 'set -e; d=/media/downloads/complete/tv-sonarr/.probe; t=/media/tv/.probe-link;
echo x > "$d"; ln "$d" "$t"; ls -i "$d" "$t"; rm -f "$d" "$t"; echo HARDLINK-OK'
# (c) the reader really is read-only
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n plex exec deploy/plex -- \
sh -c 'touch /media/.probe 2>&1 | head -1' # expect "Read-only file system"
# (d) the reader really is read-only
kubectl --context k3s-prod -n plex exec deploy/plex -- sh -c 'touch /media/.probe 2>&1 | head -1'
# expect "Read-only file system"
```
`(b)` printing two identical inode numbers is the proof that §4's argument holds in practice. If it
`(c)` printing two identical inode numbers is the proof that §4's argument holds in practice. If it
prints `EXDEV`, something has split the single mount back into two.
Reading the failures together is what localises the fault: **(b) EACCES with (a) showing `nobody`**
is idmapping (§5 service settings). **(b) EACCES with (a) showing `1000`** is on-disk ownership —
stop and check `ls -ldn` on the NAS before changing anything else.
---
## 10. Repointing BULK_NFS_SERVER means DELETING AND RECREATING THE PVs
**A bound PersistentVolume's NFS source is immutable.** Measured by server-side dry-run on
2026-10-07:
```
$ kubectl patch pv sonarr-media --type=json --dry-run=server \
-p '[{"op":"replace","path":"/spec/nfs/server","value":"192.168.2.30"}]'
The PersistentVolume "sonarr-media" is invalid: spec.persistentvolumesource: Forbidden:
spec.persistentvolumesource is immutable after creation
```
Note the pairing with §7: `spec.nfs.server` is **unvalidated at create** (any string is accepted and
the PVC binds) and **immutable at update**. So editing `BULK_NFS_SERVER` alone does not fail loudly
and does not work either — Flux's apply errors on the five PVs and the pods keep their previous,
now-dead mount.
This is safe to do because `persistentVolumeReclaimPolicy: Retain` means deleting a PV never touches
the data, and `catalog/render.py` emits a **pre-set `claimRef`** on each one, so a recreated PV
rebinds its own PVC with no manual step.
**Operator sequence** (mutating `kubectl` is an operator action, not an agent action):
```sh
# 1. Stop the consumers. This also releases the hung hard-mounts against the old server:
# the pods report Ready, but any real I/O on /media blocks indefinitely once 2049 stops
# answering, because the PVs mount `hard`.
for ns in plex sonarr radarr qbittorrent nextcloud; do
kubectl --context k3s-prod -n $ns scale deploy --all --replicas=0
done
# 2. Delete the five PVs. Retain + no live claim means nothing is reclaimed or erased.
kubectl --context k3s-prod delete pv plex-media sonarr-media radarr-media \
qbittorrent-downloads nextcloud-legacy-archive
# 3. Let Flux reconcile the revision carrying BULK_NFS_SERVER=192.168.2.30, which recreates them.
# Confirm .status.lastAppliedRevision matches the GitRepository artifact before continuing.
# 4. Bring the consumers back, then run every probe in §9.
```
The PVCs pass through `Lost` between steps 2 and 3; that is expected and resolves when the PV with
the matching `claimRef` reappears. If a PVC is still `Lost` after Flux reconciles, the PV name or
its `claimRef` namespace does not match — do **not** delete the PVC to "fix" it.
+17 -3
View File
@@ -233,9 +233,23 @@ about runtime behaviour in this document is a design claim, not a measurement.
## Follow-ups, in the order they are worth doing
1. **Build, push, pin.** Operator runs `infra/agent/build-image.sh`, pushes, and one PR
replaces both placeholder digests. Expect the build to need iteration: the nodejs module
stream and the npm install have never run against this base.
1. **Make the registry pullable, THEN build, push, pin.** Measured after the merge, and
neither half is declared anywhere in this repository
(`decisions/2026-10-05-pm-agent-registry-pull-prereq.md`):
(i) the nodes resolved the registry hostname to the WAN IP and failed TLS against the
router's `CN=%D` placeholder certificate — **diagnosed and fixed** by pinning
`gitea.{{ k3s_base_domain }}` to the Gateway VIP in `node_baseline_hosts_pins`, the same
pin the Keycloak issuer already carries, applied by
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`;
(ii) the pull is still unauthenticated and no `imagePullSecret` exists in the tree — with
the credential decision now being local Gitea accounts with PATs, that means a
`dockerconfigjson` for an account whose token carries `read:package`. Only then does the
digest matter. **Superseded 2026-10-29:** the build-and-push is no longer an operator step.
`.gitea/workflows/images.yaml` builds and verifies the image on rootless BuildKit
(`platform/buildkit/`), publishes it on a push to `main`, and Renovate opens the one
PR that replaces both placeholder digests (`decisions/2026-10-29-image-pipeline.md`). The
same two blockers sit in front of `conformance-runner`, which shares the registry path and
has never been pulled.
2. **Create the credentials** per `pm-agent-credentials.sops.yaml.example`, and **verify the
bot's permission is `write`, not `admin`** — that measurement is the control that keeps
the agent out of its own approval gate.
+81 -1
View File
@@ -50,7 +50,10 @@
# default is deliberately EMPTY so a build that forgets it fails loudly instead of
# resolving some other image named `agent-base`.
ARG AGENT_BASE_IMAGE=
FROM ${AGENT_BASE_IMAGE}
# Stage `agent-runner` is the image that ships; stage `verify` (end of file) is a TEST stage built
# FROM it that only runs assertions and is never pushed. build-image.sh and the CI pipeline
# build `--target verify` to prove the image and `--target agent-runner` for the image itself.
FROM ${AGENT_BASE_IMAGE} AS agent-runner
# Re-declared AFTER the FROM: an ARG from before the first FROM is not in scope in the
# build stage, so the provenance layer below would otherwise record an empty base.
@@ -208,3 +211,80 @@ WORKDIR /workspace
# paths above; an ENTRYPOINT here would either be ignored or prepend something the policy
# cannot see. `bash` is the interactive fallback for a human debugging the image locally.
CMD ["/bin/bash"]
# =============================================================================
# verify — a TEST stage, never shipped. Every assertion infra/agent/build-image.sh used to make
# with `run --user 65532 --read-only --tmpfs /workspace --tmpfs /tmp --network none` now runs
# here, at build time, because CI builds on rootless BuildKit where no container runtime exists.
#
# WHAT A BUILD STEP CAN AND CANNOT REPRODUCE, stated so nobody over-reads a green verify:
# * uid 65532 ............ exact: USER is inherited from the agent-runner stage.
# * --network none ....... exact: RUN --network=none, and the first check proves it.
# * --read-only rootfs ... NOT reproducible: a RUN step always has a writable layer. It is
# APPROXIMATED by permission: as uid 65532, every path the process can
# write is inventoried and must lie under /workspace, /tmp or /var/tmp
# (the pod mounts emptyDirs on the first two; /var/tmp is read-only
# there, and TMPDIR points into /workspace so nothing should use it).
# The pod's readOnlyRootFilesystem is enforced by admission
# (platform/kyverno/admission/pm-agent-job-shape.yaml), not by this.
# * --tmpfs /workspace ... approximated by the image's own /workspace (owned 65532), which is
# what the pod's emptyDir replaces.
# The probe script is COPYed into THIS stage only, so it never ships in the image.
# =============================================================================
FROM agent-runner AS verify
COPY probe-validation.sh /opt/verify/probe-validation.sh
RUN --network=none /bin/bash -euo pipefail <<'VERIFY'
echo "--- no network (the build-time stand-in for run --network none) ---"
if getent hosts registry.npmjs.org >/dev/null 2>&1 || curl -sS --max-time 3 -o /dev/null https://1.1.1.1 2>/dev/null; then
echo "UNEXPECTED: network reachable in the verify stage"; exit 1
fi
echo " confirmed: no network"
echo "--- id ---"; id
echo "--- HOME ---"; echo "HOME=${HOME} CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}"
echo "--- baked versions ---"; cat /etc/agent-runner-versions
echo "--- the gate toolchain the worker needs (CLAUDE.md §3) ---"
for t in git python3 bash yamllint check-jsonschema kyverno promtool chainsaw flux sops age kubectl jq diff; do
if command -v "$t" >/dev/null 2>&1; then printf " ok %s\n" "$t";
else printf " MISSING %s\n" "$t"; missing=1; fi
done
[ -z "${missing:-}" ] || { echo "the gate cannot run in this image"; exit 1; }
echo "--- python can import the renderer dependency ---"
python3 -c "import yaml; print('PyYAML', yaml.__version__)"
echo "--- claude ---"; claude --version
echo "--- the two entrypoints refuse arguments ---"
# Each must REFUSE. (The old docker-run version wrote `cmd | grep -q ... && echo`, which under
# `set -e` + pipefail could never fail the script; these are real assertions.)
for e in pm-worker pm-dispatch; do
out="$(/usr/local/bin/$e --anything 2>&1 || true)"
grep -q "takes no arguments" <<<"$out" || { echo "UNEXPECTED: $e accepted an argument: $out"; exit 1; }
echo " $e: rejected"
done
echo "--- the dispatcher selftest (branch vocabulary, result envelopes, Job template, persona) ---"
PM_SELFTEST=1 /usr/local/bin/pm-dispatch
echo "--- the entrypoints are root-owned and NOT writable by the runtime uid ---"
for f in /usr/local/bin/pm-worker /usr/local/bin/pm-dispatch /etc/pm-agent/worker-job-template.yaml; do
stat -c " %n %U:%G %a" "$f"
if [ -w "$f" ]; then echo "UNEXPECTED: uid $(id -u) can write $f"; exit 1; fi
done
echo " confirmed: not writable by uid $(id -u)"
echo "--- the root filesystem is not writable by the runtime uid (expected: a failure) ---"
if touch /should-fail 2>/dev/null; then echo "UNEXPECTED: / is writable by uid $(id -u)"; exit 1; fi
echo " confirmed: / is not writable"
echo "--- everything uid $(id -u) CAN write lies under /workspace, /tmp or /var/tmp ---"
stray="$(find / -xdev \( -path /proc -o -path /sys -o -path /dev \) -prune -o -writable ! -type l -print 2>/dev/null \
| grep -vE '^/(workspace|tmp|var/tmp)(/|$)' || true)"
if [ -n "${stray}" ]; then echo "UNEXPECTED: writable outside the allowed paths:"; printf ' %s\n' ${stray} | head -20; exit 1; fi
echo " confirmed: no writable path outside /workspace, /tmp, /var/tmp"
echo "--- the two writable paths really are writable ---"
touch "${HOME}/.probe" /tmp/.probe || { echo "UNEXPECTED: ${HOME} or /tmp not writable"; exit 1; }
echo " confirmed: ${HOME} and /tmp are writable"
echo "--- uid is non-zero ---"
[ "$(id -u)" -ne 0 ] || { echo "UNEXPECTED: running as root"; exit 1; }
echo " confirmed: uid $(id -u), not root"
echo "--- no Anthropic API key is baked into the image (it would outrank the subscription token) ---"
[ -z "${ANTHROPIC_API_KEY:-}${ANTHROPIC_AUTH_TOKEN:-}" ] || { echo "UNEXPECTED: an Anthropic key is baked in"; exit 1; }
echo " confirmed: neither is set"
echo "--- pm-worker's input validation, with the interpreter and script that run in the cluster ---"
PM_WORKER_PATH=/usr/local/bin/pm-worker /bin/bash /opt/verify/probe-validation.sh
echo "AGENT-RUNNER VERIFIED"
VERIFY
+86 -140
View File
@@ -1,26 +1,36 @@
#!/usr/bin/env bash
###############################################################################
# infra/agent/build-image.sh — build the pinned `agent-runner` image and print its digest.
# infra/agent/build-image.sh — build the pinned `agent-runner` image and prove it works.
#
# ./build-image.sh # build + verify + print the digest and push command
# ./build-image.sh # build the verify target, then the image
# ./build-image.sh --print-build-args # print KEY=VALUE build args (what CI consumes)
# ./build-image.sh --no-verify # build only (skip the in-image proof)
# ENGINE=docker ./build-image.sh # build with docker instead of podman
# AGENT_BASE_IMAGE=home-k3s-dev:local ./build-image.sh
# # build on a LOCAL toolbox instead of the pinned one
# # (`infra/devcontainer/dev.sh build` makes that tag)
# IMAGE_TAG=foo ./build-image.sh # override the local tag (CI uses this)
#
# THIS SCRIPT NEVER PUSHES. Publishing is an operator action: the registry credential is not
# an agent's to hold, and the pushed digest is what a fail-closed admission policy then
# trusts. So this builds locally, proves the toolchain and both entrypoints work INSIDE the
# image as the non-root uid with the read-only root filesystem the policy forces, and prints
# the exact push line for a human.
# THIS SCRIPT NEVER PUSHES — and nobody pushes this image from a workstation either.
# Publishing is done by CI: .gitea/workflows/images.yaml builds the SAME Containerfile on
# rootless BuildKit (platform/buildkit/), taking its build args from `--print-build-args`, and
# on a push to `main` pushes it to the Gitea registry and records the registry-returned digest
# in the job summary. Renovate then opens the pull request that moves platform/pm-agent/ onto
# that digest. This script is the LOCAL path (podman by default); its proof — the
# Containerfile's `verify` stage — is the same one CI runs.
#
# VERSIONS ARE NOT DEFINED HERE. infra/agent/versions.env holds the four pins this image
# adds (base image, node, Claude Code CLI, model) and infra/devcontainer/versions.env holds
# the platform toolchain the base already carries. Nothing in this file invents a version.
# VERSIONS ARE NOT DEFINED HERE. infra/agent/versions.env holds the pins this image adds
# (base image, node, Claude Code CLI, model) and infra/devcontainer/versions.env holds the
# platform toolchain the base already carries. Nothing in this file invents a version.
#
# THE DIGEST CAVEAT — read it before pasting a digest into anything. The digest printed here
# is the LOCAL manifest digest. It is normally identical to the registry digest, but that is
# a property of the push rather than a guarantee, so the push command below uses
# `--digestfile`: the digest the REGISTRY returns is the authoritative one, and that is the
# one that goes into platform/pm-agent/configmap.yaml.
# THE BASE. AGENT_BASE_IMAGE in versions.env is the PUBLISHED toolbox, pinned by digest and
# kept current by Renovate. An AGENT_BASE_IMAGE in the environment overrides it — CI passes
# the toolbox it built in the same run, and a developer can pass `home-k3s-dev:local`.
#
# NO `run` STEP. Verification used to `run` the image as uid 65532 with a read-only rootfs.
# CI has no container runtime (rootless BuildKit only), so the assertions moved into the
# Containerfile's `verify` stage and this script builds that target. `--print-build-args`
# prints the pins as KEY=VALUE for the CI pipeline; AGENT_BASE_IMAGE is the caller's choice.
###############################################################################
set -euo pipefail
@@ -29,9 +39,11 @@ REPO_ROOT="$(cd "${HERE}/../.." && pwd)"
ENGINE="${ENGINE:-podman}"
VERIFY=1
PRINT_ARGS=0
for arg in "$@"; do
case "$arg" in
--no-verify) VERIFY=0 ;;
--print-build-args) PRINT_ARGS=1 ;;
-h|--help) sed -n '2,/^####/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
@@ -41,13 +53,14 @@ log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGINE=docker to use docker)"
# --- pins: sourced, never invented -------------------------------------------
AGENT_ENV="${HERE}/versions.env"
[ -f "${AGENT_ENV}" ] || die "missing ${AGENT_ENV} — the pins live there, not here"
BASE_OVERRIDE="${AGENT_BASE_IMAGE:-}"
TAG_OVERRIDE="${IMAGE_TAG:-}"
# shellcheck disable=SC1090
source "${AGENT_ENV}"
[ -z "${BASE_OVERRIDE}" ] || AGENT_BASE_IMAGE="${BASE_OVERRIDE}"
: "${AGENT_BASE_IMAGE:?AGENT_BASE_IMAGE not set in ${AGENT_ENV}}"
: "${NODEJS_MAJOR:?NODEJS_MAJOR not set in ${AGENT_ENV}}"
: "${CLAUDE_CODE_VERSION:?CLAUDE_CODE_VERSION not set in ${AGENT_ENV}}"
@@ -55,16 +68,39 @@ source "${AGENT_ENV}"
: "${AGENT_MODEL:?AGENT_MODEL not set in ${AGENT_ENV}}"
: "${AGENT_IMAGE_REPO:?AGENT_IMAGE_REPO not set in ${AGENT_ENV}}"
# --- the base must already exist ---------------------------------------------
# Deliberately NOT built on demand. The toolbox build is its own reviewed artefact with its
# own pins, and silently building it here would mean this script could change the toolchain
# a worker gates with as a side effect of rebuilding the agent image.
if ! "${ENGINE}" image exists "${AGENT_BASE_IMAGE}" 2>/dev/null; then
die "base image '${AGENT_BASE_IMAGE}' is not present. Build the pinned toolbox first:
${REPO_ROOT}/infra/devcontainer/dev.sh build"
# THE build args, in ONE place; `--print-build-args` hands them to CI (which builds on rootless
# BuildKit and supplies its own base reference). AGENT_BASE_IMAGE is deliberately NOT in this
# list: the caller decides the base.
BUILD_ARGS=(
"NODEJS_MAJOR=${NODEJS_MAJOR}" "CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}"
"CLAUDE_CODE_MIN_VERSION=${CLAUDE_CODE_MIN_VERSION}" "AGENT_MODEL=${AGENT_MODEL}"
)
if [ "${PRINT_ARGS}" -eq 1 ]; then printf '%s\n' "${BUILD_ARGS[@]}"; exit 0; fi
command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGINE=docker to use docker)"
# --- the base must exist, locally or in the registry ---------------------------
# Deliberately NOT built on demand. The toolbox is its own reviewed artefact with its own
# pins, and silently building it here would mean this script could change the toolchain a
# worker gates with as a side effect of rebuilding the agent image.
case "${AGENT_BASE_IMAGE}" in
*@sha256:0000000000000000000000000000000000000000000000000000000000000000)
die "AGENT_BASE_IMAGE is still the all-zero placeholder in ${AGENT_ENV}: the toolbox has
not been published yet. Either let CI publish it (.gitea/workflows/images.yaml) and
merge Renovate's digest bump, or build on a local toolbox:
${REPO_ROOT}/infra/devcontainer/dev.sh build
AGENT_BASE_IMAGE=home-k3s-dev:local $0" ;;
esac
if ! "${ENGINE}" image inspect "${AGENT_BASE_IMAGE}" >/dev/null 2>&1; then
info "base ${AGENT_BASE_IMAGE} is not local; pulling it"
"${ENGINE}" pull "${AGENT_BASE_IMAGE}" >/dev/null \
|| die "base image '${AGENT_BASE_IMAGE}' is neither local nor pullable (registry login?).
To build on a local toolbox instead:
${REPO_ROOT}/infra/devcontainer/dev.sh build
AGENT_BASE_IMAGE=home-k3s-dev:local $0"
fi
IMAGE_TAG="claude${CLAUDE_CODE_VERSION}-node${NODEJS_MAJOR}"
IMAGE_TAG="${TAG_OVERRIDE:-claude${CLAUDE_CODE_VERSION}-node${NODEJS_MAJOR}}"
IMAGE_REF="${AGENT_IMAGE_REPO}:${IMAGE_TAG}"
log "Building ${IMAGE_REF}"
@@ -75,126 +111,36 @@ info " node ${NODEJS_MAJOR}"
info " claude ${CLAUDE_CODE_VERSION} (floor ${CLAUDE_CODE_MIN_VERSION})"
info " model ${AGENT_MODEL}"
"${ENGINE}" build \
--file "${HERE}/Containerfile" \
--tag "${IMAGE_REF}" \
--build-arg "AGENT_BASE_IMAGE=${AGENT_BASE_IMAGE}" \
--build-arg "NODEJS_MAJOR=${NODEJS_MAJOR}" \
--build-arg "CLAUDE_CODE_VERSION=${CLAUDE_CODE_VERSION}" \
--build-arg "CLAUDE_CODE_MIN_VERSION=${CLAUDE_CODE_MIN_VERSION}" \
--build-arg "AGENT_MODEL=${AGENT_MODEL}" \
"${HERE}"
# Provenance: CI passes the commit it is building, so a pulled image names its source tree.
LABEL_ARGS=()
[ -z "${BUILD_REVISION:-}" ] || LABEL_ARGS+=(--label "org.opencontainers.image.revision=${BUILD_REVISION}")
ARG_FLAGS=(--build-arg "AGENT_BASE_IMAGE=${AGENT_BASE_IMAGE}")
for a in "${BUILD_ARGS[@]}"; do ARG_FLAGS+=(--build-arg "$a"); done
# --- verify: the point of the whole exercise ---------------------------------
# A build that merely succeeded is not evidence this image is usable. Prove the tools
# EXECUTE, as the NON-ROOT uid, with a READ-ONLY root filesystem and only the two volumes
# the admission policy admits — the exact shape a worker Job runs in. Anything that works
# here but not there is a 3am discovery.
# A build that merely succeeded is not evidence this image is usable. The proof is the
# Containerfile's `verify` stage: as the NON-ROOT uid, with no network, the toolchain executes,
# both entrypoints refuse arguments, the dispatcher selftest passes, the entrypoints are not
# writable, nothing outside /workspace and /tmp is writable, and pm-worker's input guards
# (probe-validation.sh) refuse every hostile input. It is a build target rather than a
# `run` so the SAME proof runs here and in CI, where no container runtime exists — see the
# stage's header for what a build step can and cannot reproduce of the pod's shape.
if [ "${VERIFY}" -eq 1 ]; then
log "Verifying inside the image (uid 65532, read-only rootfs, workspace+tmp only)"
"${ENGINE}" run --rm \
--user 65532:65532 \
--read-only \
--tmpfs /workspace:rw,mode=1777 \
--tmpfs /tmp:rw,mode=1777 \
--network none \
--entrypoint "" \
"${IMAGE_REF}" \
/bin/bash -euo pipefail -c '
echo "--- id ---"; id
echo "--- HOME ---"; echo "HOME=${HOME} CLAUDE_CONFIG_DIR=${CLAUDE_CONFIG_DIR}"
echo "--- baked versions ---"; cat /etc/agent-runner-versions
echo "--- the gate toolchain the worker needs (CLAUDE.md §3) ---"
for t in git python3 bash yamllint check-jsonschema kyverno promtool chainsaw flux sops age kubectl jq diff; do
if command -v "$t" >/dev/null 2>&1; then printf " ok %s\n" "$t";
else printf " MISSING %s\n" "$t"; missing=1; fi
done
[ -z "${missing:-}" ] || { echo "the gate cannot run in this image"; exit 1; }
echo "--- python can import the renderer dependency ---"
python3 -c "import yaml; print(\"PyYAML\", yaml.__version__)"
echo "--- claude ---"; claude --version
echo "--- the two entrypoints refuse arguments ---"
/usr/local/bin/pm-worker --anything 2>&1 | grep -q "takes no arguments" && echo " pm-worker: rejected"
/usr/local/bin/pm-dispatch --anything 2>&1 | grep -q "takes no arguments" && echo " pm-dispatch: rejected"
echo "--- the dispatcher selftest (branch vocabulary, result envelopes, Job template, persona) ---"
PM_SELFTEST=1 /usr/local/bin/pm-dispatch
echo "--- the entrypoints are root-owned and NOT writable by the runtime uid ---"
for f in /usr/local/bin/pm-worker /usr/local/bin/pm-dispatch /etc/pm-agent/worker-job-template.yaml; do
stat -c " %n %U:%G %a" "$f"
if [ -w "$f" ]; then echo "UNEXPECTED: uid $(id -u) can write $f"; exit 1; fi
done
echo " confirmed: not writable by uid $(id -u)"
echo "--- rootfs is read-only (expected: a failure) ---"
if touch /should-fail 2>/dev/null; then echo "UNEXPECTED: rootfs is writable"; exit 1; fi
echo " confirmed: / is read-only"
echo "--- the two writable paths really are writable ---"
touch "${HOME}/.probe" /tmp/.probe && echo " confirmed: ${HOME} and /tmp are writable"
echo "--- uid is non-zero ---"
[ "$(id -u)" -ne 0 ] && echo " confirmed: uid $(id -u), not root"
echo "--- no Anthropic API key is baked into the image (it would outrank the subscription token) ---"
[ -z "${ANTHROPIC_API_KEY:-}${ANTHROPIC_AUTH_TOKEN:-}" ] && echo " confirmed: neither is set"
'
# The worker's input guards, exercised in the image, with the interpreter and the script
# that will actually run in the cluster. The probe is mounted read-only rather than baked
# in: it is a build-time check, not something a worker Job has any business carrying.
log "Probing pm-worker's input validation inside the image"
"${ENGINE}" run --rm \
--user 65532:65532 \
--read-only \
--tmpfs /workspace:rw,mode=1777 \
--tmpfs /tmp:rw,mode=1777 \
--network none \
--volume "${HERE}/probe-validation.sh:/probe-validation.sh:ro,Z" \
--entrypoint "" \
"${IMAGE_REF}" \
/bin/bash /probe-validation.sh
log "Verifying (Containerfile target: verify)"
"${ENGINE}" build "${ARG_FLAGS[@]}" --target verify --file "${HERE}/Containerfile" "${HERE}"
log "Image verified"
fi
# --- digest ------------------------------------------------------------------
DIGEST="$("${ENGINE}" image inspect --format '{{.Digest}}' "${IMAGE_REF}")"
"${ENGINE}" build "${LABEL_ARGS[@]}" "${ARG_FLAGS[@]}" --target agent-runner \
--file "${HERE}/Containerfile" --tag "${IMAGE_REF}" "${HERE}"
# --- result ------------------------------------------------------------------
# No digest is printed: a manifest digest does not exist until a registry has the manifest,
# and the only digest anything may pin is the one the REGISTRY returns. CI captures that at
# push time and prints it in the job summary (.gitea/workflows/scripts/images.sh).
SIZE="$("${ENGINE}" image inspect --format '{{.Size}}' "${IMAGE_REF}")"
log "Built ${IMAGE_REF}"
info "local manifest digest ${DIGEST}"
info "size $(( SIZE / 1024 / 1024 )) MiB"
cat <<EOF
================================================================================
NOT PUSHED. Publishing is an operator action — this script holds no registry
credential and will never acquire one.
OPERATOR: run these, then put the digest from the digestfile into
platform/pm-agent/configmap.yaml (PM_WORKER_IMAGE) via a pull request:
${ENGINE} login gitea.home.keithjackson.dev
${ENGINE} push --digestfile /tmp/agent-runner.digest ${IMAGE_REF}
cat /tmp/agent-runner.digest
# -> sha256:.... THIS is the authoritative digest, not the local one above.
# platform/pm-agent/configmap.yaml
PM_WORKER_IMAGE: "${AGENT_IMAGE_REPO}@\$(cat /tmp/agent-runner.digest)"
WHY THE DIGEST AND NOT THE TAG: platform/kyverno/admission/pm-agent-job-shape.yaml is a
fail-closed Enforce policy. It DENIES any worker Job whose image is not
${AGENT_IMAGE_REPO}@sha256:<digest> — a tag reference is denied,
and so is any other repository path. The dispatcher also refuses to start if
PM_WORKER_IMAGE carries no digest, so the failure names its own cause.
THE SAME IMAGE RUNS THE DISPATCHER. platform/pm-agent/deployment.yaml pins it too, so a
new digest is a two-line change in one pull request: the Deployment's image and
PM_WORKER_IMAGE. They must be the same digest — the dispatcher and its workers share the
committed Job template and the env-name contract, and a mismatched pair would disagree
about it.
TIGHTEN THE BOUNDARY AFTERWARDS: the policy admits the wildcard
\`${AGENT_IMAGE_REPO}@sha256:*\`, so anyone who can push to that registry path can publish
a different digest. Replacing the wildcard with the LITERAL digest closes that gap; it is a
one-line change, reviewed in a pull request, which is exactly where a change to the
security boundary belongs.
================================================================================
EOF
info "image id $("${ENGINE}" image inspect --format '{{.Id}}' "${IMAGE_REF}")"
info "size $(( SIZE / 1024 / 1024 )) MiB"
info "Not pushed. CI publishes this image from main (.gitea/workflows/images.yaml); see"
info "platform/pm-agent/README.md for how a new digest reaches the cluster."
+12 -2
View File
@@ -90,9 +90,19 @@ esac
# pointing at an external host would be REACHABLE: it could clone a repository that is not
# this one and push this repository's work somewhere else. The allowlist is baked in here,
# where changing it requires rebuilding the image whose digest admission pins.
#
# ONE ENTRY, AND IT IS THE CLUSTER-LOCAL SERVICE NAME. This agent runs INSIDE the cluster, so
# it reaches Gitea the way every other in-cluster consumer does — `*.svc.cluster.local` —
# which is by design a different path from how a client outside the cluster reaches the same
# service. The external hostname `gitea.home.keithjackson.dev` used to be a second accepted
# value here; it is removed deliberately. It was never needed (nothing sets it), it routes
# in-cluster traffic out through the Gateway and back for no reason, and as an ACCEPTED value
# it widened this allowlist to a host reachable over the `world:443` egress rule — which is
# the exact shape of injection the allowlist exists to refuse. The cluster-local name also
# depends on none of public DNS, Cloudflare, the Gateway or the certificate.
case "${PM_GITEA_URL}" in
http://gitea.gitea.svc.cluster.local:3000|https://gitea.home.keithjackson.dev) ;;
*) die "PM_GITEA_URL '${PM_GITEA_URL}' is not an allowed git host (see this script's header)" ;;
http://gitea.gitea.svc.cluster.local:3000) ;;
*) die "PM_GITEA_URL '${PM_GITEA_URL}' is not the allowed in-cluster git host (see this script's header)" ;;
esac
[[ "${PM_GITEA_OWNER}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ ]] || die "PM_GITEA_OWNER is malformed"
[[ "${PM_GITEA_REPO}" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$ ]] || die "PM_GITEA_REPO is malformed"
+19 -7
View File
@@ -16,13 +16,15 @@
# scrubbed environment and fake credentials, and if a guard ever stopped firing, the case
# would fail here rather than by really cloning something.
#
# Run offline, no cluster, no Gitea, no model. infra/agent/build-image.sh runs it inside the
# built image as well, where the interpreter and the script are the ones that will actually
# run in the cluster.
# Run offline, no cluster, no Gitea, no model. The Containerfile's `verify` stage runs it inside
# the built image as well (as uid 65532, no network), where the interpreter and the script are
# the ones that will actually run in the cluster — locally via build-image.sh and in CI.
###############################################################################
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# `:-$0` keeps this safe if it is ever fed over stdin (`bash -s`), where BASH_SOURCE is empty
# and `set -u` would abort on it.
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
WORKER="${PM_WORKER_PATH:-${HERE}/pm-worker}"
[ -r "$WORKER" ] || { echo "cannot read ${WORKER}" >&2; exit 2; }
@@ -86,12 +88,22 @@ refuses "branch equal to the base branch" "must not be the base branch" \
# --- where code comes FROM and goes TO -------------------------------------
# The namespace's egress policy permits tcp/443 to the world (the model needs it), so an
# injected host would be REACHABLE. These are the cases that matter most.
refuses "a redirected git host" "is not an allowed git host" \
refuses "a redirected git host" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://evil.invalid
refuses "a real, reachable, WRONG git host" "is not an allowed git host" \
refuses "a real, reachable, WRONG git host" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://github.com
refuses "the right host on a smuggled path" "is not an allowed git host" \
refuses "the right host on a smuggled path" "is not the allowed in-cluster git host" \
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:3000/../..
# This agent is IN the cluster, so it reaches Gitea by its cluster-local service name. The
# EXTERNAL hostname is refused too — it used to be a second accepted value, and as an
# accepted value it widened this allowlist to a host reachable over the `world:443` egress
# rule, which is the injection shape the allowlist exists to refuse.
refuses "this Gitea's EXTERNAL hostname" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://gitea.home.keithjackson.dev
refuses "the cluster-local name over https" "is not the allowed in-cluster git host" \
PM_GITEA_URL=https://gitea.gitea.svc.cluster.local:3000
refuses "the cluster-local name, wrong port" "is not the allowed in-cluster git host" \
PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:8080
refuses "a malformed owner" "PM_GITEA_OWNER is malformed" \
'PM_GITEA_OWNER=../other-org'
refuses "a malformed repository" "PM_GITEA_REPO is malformed" \
+10 -4
View File
@@ -9,10 +9,16 @@
# truth and, the first time the two drifted, the worker's "green" would stop meaning the
# gate's green. So this file pins ONLY what the toolbox does not carry.
# The toolbox image the runner is layered on. Built by `infra/devcontainer/dev.sh build`
# (tag `home-k3s-dev:local`); infra/agent/build-image.sh asserts it exists rather than
# silently building a different base.
AGENT_BASE_IMAGE=home-k3s-dev:local
# The toolbox image the runner is layered on: the PUBLISHED toolbox, by digest. CI builds and
# publishes it (.gitea/workflows/images.yaml) and Renovate moves this line onto each new digest
# of its `main` tag via the marker below, in a pull request — nobody edits it by hand.
#
# ALL-ZERO UNTIL THE FIRST PUBLISH. The first run of the image pipeline on main sees this
# placeholder, builds and publishes the toolbox first, and builds agent-runner FROM that fresh
# digest. For a LOCAL build on a local toolbox, override it:
# infra/devcontainer/dev.sh build && AGENT_BASE_IMAGE=home-k3s-dev:local infra/agent/build-image.sh
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main
AGENT_BASE_IMAGE=gitea.home.keithjackson.dev/home-network/toolbox@sha256:0000000000000000000000000000000000000000000000000000000000000000
# Node.js — the runtime for the Claude Code CLI, installed from the Fedora base's module
# stream. A major version, not a patch: the CLI supports the active LTS line and the
+6
View File
@@ -80,6 +80,12 @@ spec:
# Replaced by the dispatcher with the digest-pinned image. A tag here would be
# DENIED by admission; the dispatcher refuses a non-digest PM_WORKER_IMAGE at
# startup so the error names the real cause.
#
# DELIBERATELY ALL-ZERO, AND DELIBERATELY NOT TRACKED BY RENOVATE. This file is
# baked INTO agent-runner, so it cannot carry agent-runner's own digest: bumping it
# would change the image's inputs, CI would publish a new digest, Renovate would bump
# it again — forever. pm-dispatch overwrites this field from PM_WORKER_IMAGE
# (platform/pm-agent/configmap.yaml, which IS tracked) on every Job it creates.
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000
imagePullPolicy: IfNotPresent
# The pinned entrypoint, with NO args. Args on a pinned command are
@@ -327,6 +327,29 @@ node_baseline_hosts_pins:
- ip: "192.168.2.20"
names:
- "keycloak.{{ k3s_base_domain }}"
# gitea: the SECOND node process to need this pin, for the identical reason, found
# 2026-10-06. containerd pulls images as a NODE process, so it resolves through the
# public upstreams above and gets the CNAME to wan.home.keithjackson.dev — the WAN IP,
# which this router does not hairpin. The pull then fails on the router's own
# placeholder certificate rather than on anything to do with the image:
#
# failed to resolve reference "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:…":
# tls: failed to verify certificate: x509: certificate is not valid for any names,
# but wanted to match gitea.home.keithjackson.dev
#
# Measured from the workstation the same day, against the WAN address with that SNI:
# subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
# No extensions in certificate <- no SANs at all, hence "not valid for ANY names"
# while the same name via PiHole answers 192.168.2.20 and serves the real
# Let's Encrypt *.home.keithjackson.dev. So the certificate and the Gateway are fine;
# only the node's ANSWER is wrong. Same root cause, same fix, as the issuer pin above.
#
# WHO NEEDS IT: any workload whose image lives in the in-cluster Gitea registry —
# today platform/pm-agent (agent-runner) and conformance/incluster
# (conformance-runner), neither of which has ever been pulled. A pull is still
# UNAUTHENTICATED after this pin, which is a separate thing to settle; this only makes
# the node reach the right endpoint.
- "gitea.{{ k3s_base_domain }}"
# --- platform hostname suffix, ANSIBLE SIDE (R-ENV-02, R-DNS-01) --------------------
# PROD SERVES ITS OWN SUBDOMAIN. [parity] MUST equal BASE_DOMAIN in clusters/prod/
@@ -397,3 +420,34 @@ flux_bootstrap_env_path: "clusters/prod"
# rather than leaving an inert drop-in behind, so this group-wide `true` is still correct
# for it.
etcd_io_priority_enabled: true
# --- automatic security patching (roles/unattended_upgrades) -------------------------
# TURNS ON roles/unattended_upgrades FOR PROD ONLY. Role default is false. Same seam as
# node_baseline_backports_kernel and etcd_io_priority_enabled above.
#
# WHAT WAS THERE BEFORE: nothing at all. Measured on all three prod nodes 2026-10-17 —
# `unattended-upgrades`, `update-notifier-common` and `needrestart` were ALL absent,
# `apt-mark showhold` was empty on every node, /etc/apt/preferences.d/ was empty, and
# nothing on the node could create /var/run/reboot-required. These boxes have had ZERO
# automatic patching since provisioning, and no way to signal that a reboot was needed.
#
# WHAT ENABLING THIS DOES *NOT* DO: nothing, until the operator runs
# playbooks/unattended-upgrades.yml. It is not applied by site.yml (deliberately — see
# that playbook's header). And once run, it still never upgrades the KERNEL, never
# restarts k3s, and never reboots:
# * kernel — blacklisted `^linux-`, AND the trixie-backports archive the running
# kernel comes from (o=Debian Backports, measured) is not an allowed origin. Two
# independent exclusions, because this is the EFI-memattr constraint: these HP minis
# need >= 6.13 or they can boot with a fraction of their RAM
# (PROD-PROVISIONING.md §0b), and 6.12 kernels are still installed and bootable here.
# The kernel continues to move ONLY under a deliberate
# `node-baseline.yml --tags kernel` run, exactly as before.
# * k3s — needrestart blacklist. k3s is not even an apt package on these nodes
# (measured: no `k3s`, no `containerd` in dpkg), so the control plane and runtime are
# outside apt's reach regardless.
# * reboots — Automatic-Reboot "false", asserted by the role against apt's own
# effective config. Reboots belong to kured (platform/kured): one node at a time,
# under a cluster-wide lock, inside 03:00-06:00, after cordon + drain. Three
# control-plane nodes with etcd quorum 2/3 must never reboot themselves
# independently, which is what Automatic-Reboot "true" would mean.
unattended_upgrades_enabled: true
@@ -0,0 +1,36 @@
---
# Automatic Debian SECURITY patching for the k3s nodes — the package half of the automated upgrade
# process. The reboot half is kured (platform/kured), reconciled by Flux; the two are designed
# together and neither is useful alone. Full rationale for every decision:
# roles/unattended_upgrades/defaults/main.yml and that role's README.md.
#
# TARGET (R-ENV-02): `k3s_hosts` defaults to dev's `k3s_servers`; prod is
# ansible-playbook playbooks/unattended-upgrades.yml -e k3s_hosts=k3s_prod
# Same pattern node-baseline.yml and node-health.yml established — `hosts:` is templated before
# group_vars exist, so the target is an extra var, never a group_vars value.
#
# The role is a NO-OP unless `unattended_upgrades_enabled` is true, which is set for prod only in
# inventory/group_vars/k3s_prod/vars.yml. Running this against dev changes nothing.
#
# DELIBERATELY NOT IN site.yml. site.yml is the bring-up chain (node-baseline -> k3s ->
# etcd-io-priority -> kube-vip -> cilium -> verify -> flux-bootstrap) and is "not run against prod
# casually". Automatic patching is not a prerequisite for anything in that chain and nothing in the
# chain depends on it, so adding it there would only widen what a cluster rebuild touches. It is
# applied on its own, once, and re-applied when its configuration changes.
#
# SAFE TO RUN ON A LIVE CLUSTER, AT ANY TIME. This playbook installs and configures the upgrader. It
# never runs `apt upgrade`, never restarts k3s, and never reboots — the role asserts the last of
# those against apt's own effective config rather than trusting the file it just wrote. The first
# unattended upgrade happens at the next timer firing (04:00 +/-60m), and any reboot that upgrade
# implies is kured's to schedule, one node at a time, inside 03:00-06:00.
#
# TAGS. `--tags verify` re-runs only the read-only assertions and the per-node summary, which is the
# way to answer "is automatic patching actually working on these boxes, and is anything waiting on a
# reboot" without rewriting any configuration:
# ansible-playbook playbooks/unattended-upgrades.yml -e k3s_hosts=k3s_prod --tags verify
- name: Automatic security upgrades on the k3s nodes (kernel excluded; reboots delegated to kured)
hosts: "{{ k3s_hosts | default('k3s_servers') }}"
become: true
gather_facts: true
roles:
- unattended_upgrades
@@ -107,3 +107,31 @@ k3s_base_domain: "home.keithjackson.dev"
# Derived, so the realm path is stated once. Override k3s_base_domain, not this.
k3s_oidc_issuer_url: "https://keycloak.{{ k3s_base_domain }}/realms/homelab"
# --- Graceful node shutdown (kubelet) ----------------------------------------------------------
# MEASURED 2026-10-17, during the UPS move: graceful node shutdown was NOT configured on any prod
# node, so `shutdown -h` did NOT drain pods. systemd stops k3s, containerd goes with it, and every
# pod is SIGKILLed where it stands -- including 9 CloudNativePG instances mid-checkpoint, the UniFi
# VM, and 25 attached Longhorn volumes. The whole-cluster shutdown that day had to quiesce
# everything BY HAND first (runbooks/cluster-maintenance-shutdown.md), and the one volume that did
# not finish detaching came back `degraded` and had to rebuild.
#
# With these set, the kubelet takes a systemd inhibitor lock and, on shutdown, evicts ordinary pods
# first and critical ones last, so a plain `shutdown -h`/reboot drains the node. That makes the
# manual scale-down in the runbook a belt-and-braces step for a PLANNED whole-cluster shutdown
# rather than the only thing standing between a reboot and a hard kill.
#
# 90s/20s, not larger: the budget is spent on every reboot and node upgrade, and the kubelet waits
# the FULL ordinary period before it starts on critical pods. Postgres does a fast shutdown in
# well under 90s here, and Longhorn needs only enough time to flush and detach.
k3s_kubelet_shutdown_grace_period: "90s"
k3s_kubelet_shutdown_grace_period_critical_pods: "20s"
# *** THE HALF THAT IS EASY TO MISS, AND SILENTLY BREAKS THE OTHER HALF. ***
# Graceful node shutdown works by the kubelet holding a logind INHIBITOR DELAY lock. systemd caps
# how long any inhibitor may delay shutdown at `InhibitDelayMaxSec`, which DEFAULTS TO 5 SECONDS.
# If that cap is below the grace period above, systemd proceeds anyway and the drain is truncated
# to ~5s -- i.e. the feature appears configured, reports no error, and still hard-kills pods. So
# this MUST be >= k3s_kubelet_shutdown_grace_period; the role asserts it rather than trusting it.
# Set a little above the kubelet's total (90s + 20s) to leave headroom.
k3s_logind_inhibit_delay_max_sec: 120
@@ -15,3 +15,12 @@
port: "{{ k3s_api_port }}"
timeout: 180
listen: Restart k3s
# logind re-reads InhibitDelayMaxSec only on restart. Restarting logind does NOT log users out or
# disturb k3s -- it manages login sessions and inhibitor locks, not workloads. The kubelet picks up
# the new cap when it next takes its lock, so no k3s restart is needed for THIS half.
- name: Restart logind
ansible.builtin.systemd_service:
name: systemd-logind
state: restarted
listen: Restart logind
@@ -86,6 +86,16 @@
# k3s_base_domain (defaults/main.yml), whose [parity] contract with the cluster's
# BASE_DOMAIN is documented there. Dev's default renders the value it already runs, so
# config.yaml is unchanged and the Restart handler does not fire.
# ── Graceful node shutdown ──────────────────────────────────────────────────────────────
# Without these the kubelet takes no systemd inhibitor lock, so `shutdown -h` stops
# containerd and SIGKILLs every pod where it stands -- MEASURED on all three prod nodes
# 2026-10-17 (defaults/main.yml has the detail). The companion logind drop-in below raises
# InhibitDelayMaxSec, WITHOUT WHICH systemd caps the drain at ~5s and these values are
# quietly ignored.
kubelet-arg:
- "shutdown-grace-period={{ k3s_kubelet_shutdown_grace_period }}"
- "shutdown-grace-period-critical-pods={{ k3s_kubelet_shutdown_grace_period_critical_pods }}"
kube-apiserver-arg:
- "oidc-issuer-url={{ k3s_oidc_issuer_url }}"
- "oidc-client-id=kubernetes"
@@ -183,3 +193,46 @@
host: "127.0.0.1"
port: "{{ k3s_api_port }}"
timeout: 180
# ── Graceful node shutdown: the systemd half — tag `shutdown` ──────────────────────────────────
# The kubelet's shutdown-grace-period is ENFORCED BY logind, not by the kubelet: the kubelet holds
# an inhibitor DELAY lock, and systemd will wait at most `InhibitDelayMaxSec` for it (default 5s)
# before proceeding regardless. A grace period above that cap is therefore silently truncated --
# the feature looks configured, logs no error, and still hard-kills pods. Assert the relationship
# instead of documenting it, because the failure is invisible.
- name: Assert the logind inhibitor cap can actually cover the kubelet grace period
ansible.builtin.assert:
that:
- (k3s_logind_inhibit_delay_max_sec | int) >= (k3s_kubelet_shutdown_grace_period | regex_replace('[^0-9]', '') | int)
fail_msg: >-
k3s_logind_inhibit_delay_max_sec ({{ k3s_logind_inhibit_delay_max_sec }}s) is below
k3s_kubelet_shutdown_grace_period ({{ k3s_kubelet_shutdown_grace_period }}). systemd would
stop waiting for the kubelet's inhibitor lock before the drain finished, so pods would be
SIGKILLed anyway and graceful node shutdown would be configured but ineffective.
quiet: true
tags: [shutdown]
- name: Ensure /etc/systemd/logind.conf.d exists
ansible.builtin.file:
path: /etc/systemd/logind.conf.d
state: directory
owner: root
group: root
mode: "0755"
tags: [shutdown]
# A drop-in, not an edit of logind.conf: the distro file stays pristine and `state: absent` here
# would fully revert the change.
- name: Raise the logind inhibitor delay cap so the kubelet can finish draining
ansible.builtin.copy:
dest: /etc/systemd/logind.conf.d/10-k3s-graceful-shutdown.conf
owner: root
group: root
mode: "0644"
content: |
# Managed by Ansible (roles/k3s_server). See defaults/main.yml.
# MUST stay >= kubelet shutdown-grace-period, or the drain is truncated to this value.
[Login]
InhibitDelayMaxSec={{ k3s_logind_inhibit_delay_max_sec }}
notify: Restart logind
tags: [shutdown]
@@ -0,0 +1,195 @@
# roles/unattended_upgrades
Automatic Debian **security** patching on the k3s nodes. The kernel is excluded, k3s is never
restarted, and reboots are delegated to **kured** (`platform/kured`).
- **Playbook:** `playbooks/unattended-upgrades.yml`
- **Enabled by:** `unattended_upgrades_enabled` — role default `false`, set `true` for prod in
`inventory/group_vars/k3s_prod/vars.yml`
- **Companion component:** `platform/kured` (the reboot half). **Neither is useful alone** — see
"The two halves" below.
- **Every decision and its reasoning:** `defaults/main.yml`. That file is the argument; this one is
the orientation.
## The two halves, and why they ship together
| | what it does | what it must NOT do |
|---|---|---|
| **this role** (Ansible, host) | installs security updates continuously; leaves `/var/run/reboot-required` behind when one needs a reboot | upgrade the kernel; restart k3s; reboot |
| **kured** (Flux, cluster) | cordons, drains, reboots and uncordons **one node at a time** when that file exists | decide *whether* a reboot is needed |
Deploying either alone produces a mechanism that looks healthy and does nothing:
- **kured alone** watches a file that **nothing on Debian ever creates**. Measured 2026-10-18,
after a first attempt to install Ubuntu's `update-notifier-common` failed on all three nodes
with *"No package matching 'update-notifier-common' is available"*:
- `apt-cache policy update-notifier-common` → `Candidate: (none)` — the package does not exist
in Debian; it is Ubuntu-only.
- `/usr/share/update-notifier/notify-reboot-required` → absent.
- **No `linux-image-*` postinst references `notify-reboot-required`** — calling it is an Ubuntu
patch, so even dropping a script at that path would never be invoked.
- No hook under `/etc/apt/apt.conf.d` or `/etc/kernel/` mentions `reboot-required`.
So **this role ships the writer itself** (`/usr/local/sbin/k3s-notify-reboot-required` plus a
`DPkg::Post-Invoke` hook) rather than installing a package. Without it kured would sit at 1/1
Running reporting `kured_reboot_required=0` forever.
- **this role alone** installs patches that a long-running process never picks up, and when a
reboot *is* needed there is nothing safe to take it. `Automatic-Reboot "true"` is not the
answer: three control-plane nodes with etcd quorum 2/3, each rebooting at its own time, is how
the cluster is lost.
## What it was replacing: nothing
Measured on all three prod nodes, 2026-10-17:
| | |
|---|---|
| `unattended-upgrades` | **not installed** |
| `update-notifier-common` | **not installable** — Ubuntu-only, `Candidate: (none)` on trixie |
| `needrestart` | **not installed** |
| `apt-mark showhold` | **empty** — no package held on any node |
| `/etc/apt/preferences.d/` | **empty** — no apt pin declares the backports-kernel intent |
| `/var/run/reboot-required` | absent, and uncreatable |
These nodes had received **no automatic patching since provisioning**.
## The three decisions
### 1. Security only
Allowed origins are `label=Debian-Security` and nothing else. `trixie-updates` is excluded
deliberately (not overlooked) — it carries non-security fixes that are useful but are the archive
through which an unreviewed change would most plausibly arrive on a control-plane node. The knob to
add it is `unattended_upgrades_include_updates`.
### 2. The kernel is excluded twice over
**This is the highest-risk part of the change.** These HP EliteDesk 800 G5 minis publish a corrupt
EFI Memory Attributes Table on R21 firmware; Linux `< 6.13` does not validate it before reserving
memory from its contents, and `k3s-prod-2` twice booted with **2.6 GiB of 16 GB**. Upstream
`8fbe4c49c0cc` (first in v6.13) is the only fix — there is no kernel cmdline workaround. Full
write-up: `infra/PROD-PROVISIONING.md` §0b and `roles/node_baseline/defaults/main.yml:21-51`.
Measured 2026-10-17: all three nodes run `7.1.8+deb13-amd64` (`7.1.8-1~bpo13+1`, i.e. the backports
metapackage), comfortably above the floor — **but `linux-image-6.12.107+deb13-amd64` and
`linux-image-6.12.94+deb13-amd64` are also still installed, below the floor, bootable, and held by
nothing.**
So an unattended process must not be able to touch the kernel at all:
1. **Origin exclusion (structural).** The running kernel comes from `o=Debian Backports` (measured
via `apt-cache policy`, pin priority 100 — the `NotAutomatic` + `ButAutomaticUpgrades`
signature). That origin is not in `Origins-Pattern`, so unattended-upgrades cannot see a kernel
upgrade at all.
2. **Package blacklist (belt and braces).** `^linux-` is blacklisted, so even if the origin list is
later widened — the most likely future edit to this role, and the one that would silently re-arm
the hazard — the kernel is still untouchable.
The role **asserts both** against `apt-config dump` (apt's own parse of the whole
`/etc/apt/apt.conf.d/` directory) rather than trusting the file it just wrote.
**This does not stop the kernel moving.** The operator still advances it deliberately with
```
ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags kernel --limit <host>
```
which is the only thing that maintains the backports source, the metapackage and the on-node
provenance stamp. `node_baseline`'s own note that `apt upgrade` keeps the backports kernel current
still holds for an operator-run apt. What changed is that a **new unattended actor** is forbidden
from it.
Also blacklisted, for the same "operator-only" reason: `intel-microcode`, `amd64-microcode`,
`grub-*`. Microcode and bootloader changes take effect on reboot and alter boot behaviour on the
very firmware that is already misbehaving here.
`Remove-Unused-Kernel-Packages` is **false**, against the Debian default: those older kernels are
the documented rollback path, and an unattended process pruning them could remove the fallback an
operator needs in front of a box that will not boot.
### 3. needrestart restarts services — but never k3s
A security-only policy that restarts nothing patches bytes on disk that no running process reads.
`needrestart` in mode `a` restarts services still mapped to a deleted library, which is what makes
the policy actually effective.
`k3s.service` is blacklisted. Restarting the Kubernetes control plane unattended at 04:00 is a
worse outcome than a delayed library fix; that fix lands at the next kured reboot instead. Measured
2026-10-17, `k3s.service` is the **only** matching unit — k3s bundles its own containerd and
kubelet, and neither `containerd` nor `containerd.io` is an apt package on these nodes — so this
one entry is complete today.
## Schedule
`apt-daily` at 03:30 and `apt-daily-upgrade` at 04:00, both `±60m` randomised, via systemd drop-ins.
Chosen to land **inside** kured's 03:00–06:00 reboot window so that a sentinel written by an upgrade
can be acted on the same night rather than ~24h later.
Two traps the role handles explicitly:
- `/etc/apt/apt.conf.d/20auto-upgrades` must set the four `APT::Periodic::*` counters. Without
them the apt-daily timers fire and **do nothing** — installed, configured, green, and inert.
- The timer drop-ins clear `OnCalendar=` before setting it. systemd **accumulates** `OnCalendar`
entries across drop-ins, so omitting the reset keeps the shipped schedule *in addition* to ours.
## Why a new role rather than extending `node_baseline`
`node_baseline` is the repo's only other apt surface and would have been a defensible home. Two
reasons against:
1. **Single responsibility.** `node_baseline` is the *first-boot floor* — it runs before k3s exists
and is the thing that makes a node installable. Continuous patching policy is a different
lifecycle with a different blast radius, and it wants to be runnable (and re-runnable) without
going anywhere near that role's data-disk tasks.
2. **Concurrent work.** Other open branches touch `roles/node_baseline/`. A new role has nothing to
merge.
Nothing here duplicates `node_baseline`: it does not add apt sources, does not touch the kernel, and
does not manage disks.
## Post-merge verification
Running the role is safe on a live cluster at any time — it installs and configures the upgrader, and
never runs `apt upgrade`, restarts k3s, or reboots.
```bash
# Apply (prod). Idempotent; re-runnable.
cd infra/ansible && ansible-playbook playbooks/unattended-upgrades.yml -e k3s_hosts=k3s_prod
# Re-run ONLY the read-only assertions + the per-node pending-reboot summary.
cd infra/ansible && ansible-playbook playbooks/unattended-upgrades.yml -e k3s_hosts=k3s_prod --tags verify
```
Then, on a node, confirm the three properties that matter (all read-only, no sudo needed for the
first two):
```bash
ssh [email protected] 'apt-config dump | grep -iE "unattended-upgrade::(automatic-reboot|package-blacklist|origins)"'
ssh [email protected] 'systemctl list-timers apt-daily-upgrade.timer --no-pager'
ssh [email protected] 'ls -la /var/run/reboot-required* 2>&1 | head -2
ls -l /usr/local/sbin/k3s-notify-reboot-required /etc/apt/apt.conf.d/99-k3s-reboot-required 2>&1
dpkg -l needrestart unattended-upgrades | tail -3'
```
Expect: `Automatic-Reboot "false"`, a blacklist containing `^linux-`, an `Origins-Pattern`
containing `Debian-Security` and **not** `Backports`, a next-elapse around 04:00, and all three
packages `ii`.
A dry run of what the upgrader would actually do (needs root, so this one is an operator step):
```bash
sudo unattended-upgrade --dry-run --debug
```
The kernel must appear in its blacklist output, not its upgrade list.
## What this role cannot tell you
- **That a security upgrade has ever succeeded.** That is in
`/var/log/unattended-upgrades/unattended-upgrades.log` on each node, and nothing in this repo or
in Prometheus reads it. There is no alert for "automatic patching has silently stopped working".
That is a real, known gap — see the handoff entry
`decisions/2026-10-17-automated-upgrades.md`.
- **That a reboot actually happened.** That is kured's half, and it *is* alerted on
(`KuredRebootPending`, `KuredNodeCoverageGap`, `KuredNotReporting` in
`platform/kured/alerts.yaml`).
@@ -0,0 +1,199 @@
---
# roles/unattended_upgrades — automatic Debian SECURITY patching on the k3s nodes, with the kernel
# deliberately excluded and with reboots delegated to kured (platform/kured).
#
# OFF BY DEFAULT, ON FOR PROD. Same seam as node_baseline_backports_kernel / etcd_io_priority_enabled:
# a plain boolean, default false, turned on in inventory/group_vars/k3s_prod/vars.yml. Not a hostname
# test — that would encode the environment into the role (see node_baseline's defaults for the full
# argument, which this role follows rather than restates).
#
# ═════════════════════════════════════════════════════════════════════════════════════════════════
# STATE BEFORE THIS ROLE (measured on all three prod nodes, 2026-10-17, via `dpkg-query -W`)
# ═════════════════════════════════════════════════════════════════════════════════════════════════
# unattended-upgrades NOT INSTALLED
# update-notifier-common NOT INSTALLED
# needrestart NOT INSTALLED
# apt-mark showhold EMPTY (no package is held on any node)
# /var/run/reboot-required ABSENT, and nothing on the node can create it
# /etc/apt/preferences.d/ EMPTY (no apt pin declares the backports-kernel intent)
# So: zero automatic patching, and no mechanism by which a reboot could ever be signalled. That last
# point is why this role and platform/kured must land together — kured watches
# /var/run/reboot-required, and on these nodes today NOTHING WRITES IT. Deploying kured alone would
# produce a daemon that is permanently, silently idle while looking perfectly healthy.
#
# ═════════════════════════════════════════════════════════════════════════════════════════════════
# DECISION 1 — SECURITY ONLY, NOT FULL UPGRADES
# ═════════════════════════════════════════════════════════════════════════════════════════════════
# Measured `apt-cache policy` on k3s-prod-1, 2026-10-17, gives the four archives and their pins:
# trixie o=Debian, a=stable, l=Debian pin 500
# trixie-updates o=Debian, a=stable-updates, l=Debian pin 500
# trixie-security o=Debian, a=stable-security, l=Debian-Security pin 500
# trixie-backports o=Debian Backports, a=stable-backports, l=Debian Backports pin 100
#
# Only `Debian-Security` is allowed below. The reasoning, in order of weight:
#
# * Security updates are the class where the cost of NOT applying them is unbounded and the
# change itself is minimal by Debian policy (targeted fixes against the frozen stable version,
# no feature churn). It is the one class worth applying with nobody watching.
# * A full `apt upgrade` on these nodes would also pull trixie point-release changes, which is a
# larger surface than anyone has reviewed for a 3-node etcd cluster, for no urgency.
# * `trixie-updates` is DELIBERATELY EXCLUDED, not overlooked. It carries non-security fixes that
# are too urgent to wait for a point release — useful, but it is the archive through which an
# unreviewed change would most plausibly arrive, and it is not what "unattended" should mean on
# a control-plane node. Add it consciously later if the operator wants it; the knob is below.
# * `trixie-backports` is EXCLUDED, AND THAT IS THE KERNEL ARGUMENT — see DECISION 2.
#
# ═════════════════════════════════════════════════════════════════════════════════════════════════
# DECISION 2 — THE KERNEL IS EXCLUDED TWICE OVER. THIS IS THE HIGHEST-RISK PART OF THE CHANGE.
# ═════════════════════════════════════════════════════════════════════════════════════════════════
# The constraint: HP's R21 firmware on these EliteDesk 800 G5 minis publishes a corrupt EFI Memory
# Attributes Table, and Linux < 6.13 does not validate it before reserving memory from its contents
# — k3s-prod-2 twice booted with 2.6 GiB of 16 GB. Upstream 8fbe4c49c0cc (first in v6.13) is the
# ONLY fix; there is no kernel cmdline workaround. So these nodes run the backports metapackage
# `linux-image-amd64` (measured 2026-10-17: version 7.1.8-1~bpo13+1 installed and running on all
# three, well above the floor). Full write-up: infra/PROD-PROVISIONING.md §0b, and the rationale
# block in roles/node_baseline/defaults/main.yml:21-51.
#
# A NODE THAT COMES BACK FROM AN UNATTENDED UPGRADE ON A 6.12 KERNEL IS A NODE THAT MAY COME BACK
# WITH A FRACTION OF ITS RAM. Note that 6.12 kernels are not hypothetical here — measured
# 2026-10-17, `linux-image-6.12.107+deb13-amd64` and `linux-image-6.12.94+deb13-amd64` are BOTH
# still installed on all three nodes, below the floor, bootable, and nothing holds them.
#
# Two independent mechanisms keep unattended-upgrades away from the kernel, and the role asserts
# both rather than trusting either:
#
# 1. ORIGIN EXCLUSION (structural). The running kernel comes from `o=Debian Backports`, which is
# not in Allowed-Origins. u-u only ever considers packages from allowed origins, so it cannot
# see a backports kernel upgrade at all. This also means the backports kernel receives no
# automatic patching — accepted, and in any case backports carries no security guarantees
# (node_baseline's defaults state that cost plainly: "no security-tracker commitment, no DSAs").
# 2. PACKAGE BLACKLIST (belt and braces). `linux-*` is blacklisted below, so even if the origin
# list is later widened — the most likely future edit to this file, and the one that would
# silently re-arm the hazard — the kernel is still untouchable. Without this, widening origins
# to include backports would hand an unattended process the EFI-memattr-critical package.
#
# WHAT THIS DOES *NOT* DO, AND MUST NOT BE READ AS DOING: it does not stop the kernel moving. The
# operator still advances it deliberately with
# ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags kernel --limit <host>
# which is the only thing that maintains the backports source, the metapackage and the on-node
# provenance stamp. node_baseline's own comment ("`ButAutomaticUpgrades: yes` ... means `apt upgrade`
# keeps it current once installed") still holds for an operator-run apt; the measured pin priority of
# 100 is exactly the NotAutomatic + ButAutomaticUpgrades signature that produces that behaviour.
# Nothing here changes it. What changes is that a NEW unattended actor is forbidden from the kernel.
#
# NOT BLACKLISTED, BECAUSE THEY ARE NOT APT PACKAGES AT ALL — measured 2026-10-17: `dpkg-query` finds
# no `k3s`, no `containerd` and no `containerd.io`. k3s is a single static binary at
# /usr/local/bin/k3s installed by roles/k3s_server, and it bundles its own containerd and kubelet.
# So the entire Kubernetes control plane and container runtime are OUTSIDE apt's reach and cannot be
# upgraded by this role even in principle. That is worth knowing before worrying about it.
unattended_upgrades_enabled: false
# The packages this role installs. Each one is load-bearing; see the notes.
unattended_upgrades_packages:
# The upgrader itself.
- unattended-upgrades
# NOTE: `update-notifier-common` was listed here and REMOVED (2026-10-18). It is an UBUNTU
# package and does not exist in Debian at all -- `apt-cache policy update-notifier-common` on
# trixie returns `Candidate: (none)`, so the play failed outright with "No package matching
# 'update-notifier-common' is available" on all three nodes. The sentinel writer is now provided
# by this role itself; see unattended_upgrades_sentinel_* below and tasks/main.yml.
# Restarts services still mapped to a deleted library after a security upgrade. Without it a
# security-only policy patches bytes on disk that no running process reads — see DECISION 3.
- needrestart
# --- Allowed origins -----------------------------------------------------------------------------
# Verbatim apt origin expressions, substituted into 50unattended-upgrades. The ${distro_*} tokens
# here are UNATTENDED-UPGRADES' OWN placeholders, expanded by it at runtime — they are not Ansible
# or Flux substitutions, which is why they are single-quoted and why this role's files never travel
# through Flux envsubst.
#
# `l=Debian-Security` plus the `-security` codename is the form Debian's own shipped config uses, and
# both halves were confirmed against the measured archive metadata above (l=Debian-Security,
# n=trixie-security). Matching on the LABEL rather than the suite name is what makes this survive the
# next Debian release.
unattended_upgrades_allowed_origins:
- 'origin=Debian,codename=${distro_codename},label=Debian-Security'
- 'origin=Debian,codename=${distro_codename}-security,label=Debian-Security'
# Set this to add 'origin=Debian,codename=${distro_codename}-updates,label=Debian' — a deliberate
# widening, kept as a knob so that turning it on is a reviewable one-line change rather than an edit
# to a heredoc. See DECISION 1 for why it is off.
unattended_upgrades_include_updates: false
# --- Package blacklist ---------------------------------------------------------------------------
# Regular expressions matched against package names. See DECISION 2: `linux-` is the EFI-memattr
# guard and must not be removed without re-reading PROD-PROVISIONING.md §0b.
unattended_upgrades_blacklist:
# Every kernel package: linux-image-*, linux-headers-*, linux-image-amd64 (the metapackage that
# actually tracks backports), plus linux-kbuild / linux-libc-dev. Anchored at the start so it
# cannot accidentally match an unrelated package containing the substring.
- '^linux-'
# Microcode changes take effect only on reboot and can alter boot behaviour on the very firmware
# that is already misbehaving here. Operator-only, same argument as the kernel.
- '^intel-microcode$'
- '^amd64-microcode$'
# The bootloader. A grub upgrade rewrites boot configuration on a machine with a documented
# warm-reboot hang (k3s-prod-2, 2026-09-03). Never unattended.
- '^grub-'
- '^grub2-common$'
# --- Reboot policy -------------------------------------------------------------------------------
# FALSE, AND THIS IS THE DIVISION OF LABOUR WITH kured, NOT AN OVERSIGHT.
# unattended-upgrades can reboot a node itself (Automatic-Reboot "true"). It must not here: it has
# no notion of a cluster, so it would not cordon, would not drain, would not evict the 25 attached
# Longhorn volumes cleanly, would not check whether another node is already down, and — on three
# control-plane nodes where etcd quorum is 2 of 3 — three nodes each deciding independently to
# reboot at their own `Automatic-Reboot-Time` is precisely how you lose the cluster.
# u-u therefore only ever leaves the sentinel behind; kured owns every reboot, one node at a time,
# under a cluster-wide lock. See platform/kured/helmrelease.yaml.
unattended_upgrades_automatic_reboot: false
# --- needrestart (DECISION 3) --------------------------------------------------------------------
# See the role README for the full argument. Summary: 'a' (restart services automatically) is what
# makes a security-only policy actually effective — a patched libssl on disk does nothing for a
# process still mapping the old one — but k3s.service is EXCLUDED, because restarting the
# Kubernetes control plane unattended at 06:00 is a worse outcome than a delayed library fix.
# Measured 2026-10-17: the only matching unit on these nodes is `k3s.service` (no separate
# containerd or kubelet unit exists; k3s bundles both), so this blacklist is complete today.
unattended_upgrades_needrestart_mode: a
unattended_upgrades_needrestart_blacklist:
- '^k3s'
# --- Schedule ------------------------------------------------------------------------------------
# apt's own systemd timers (apt-daily, apt-daily-upgrade) drive u-u; these set their windows.
# 04:00 with a 1h randomised delay, so the upgrade lands INSIDE kured's 03:00-06:00 reboot window
# (platform/kured/helmrelease.yaml) rather than hours away from it: if an upgrade ever does set the
# sentinel, the reboot can be taken the same night instead of waiting ~24h. The randomised delay
# keeps the three nodes from hitting the mirror simultaneously; it does NOT serialise anything, and
# it does not need to — u-u never reboots, so three concurrent upgrades are harmless.
unattended_upgrades_download_time: "03:30"
unattended_upgrades_upgrade_time: "04:00"
unattended_upgrades_randomised_delay: 60m
# ── THE REBOOT SENTINEL, PROVIDED BY THIS ROLE ──────────────────────────────────────────────────
# /var/run/reboot-required is the ONLY thing kured watches, and on Debian trixie **nothing creates
# it**. Measured on all three nodes 2026-10-18:
# * `update-notifier-common` (which owns that hook on Ubuntu) has `Candidate: (none)` — the
# package does not exist in Debian. Installing it is what broke the first run of this play.
# * `/usr/share/update-notifier/notify-reboot-required` is absent.
# * NO `linux-image-*` postinst references `notify-reboot-required` — calling it is an Ubuntu
# patch, not Debian behaviour, so merely dropping a script at that path would never be invoked.
# * No hook under /etc/apt/apt.conf.d or /etc/kernel/ mentions reboot-required.
# So the writer has to be ours. Without it kured is a permanent, INVISIBLE no-op: it stays 1/1
# Running and reports kured_reboot_required=0 forever, which is the "failure mode is a green tick"
# shape this repo keeps getting burned by.
#
# MECHANISM: a dpkg Post-Invoke hook runs a script that compares the RUNNING kernel against the
# newest INSTALLED one and touches the sentinel when they differ. Deliberately kernel-only, which
# matches the design: unattended-upgrades is security-only with `^linux-` blacklisted, so it will
# essentially never set this — the sentinel appears when the OPERATOR deliberately runs the
# node_baseline kernel play, and kured then automates only the mechanical reboot.
#
# It uses `linux-version` (from `linux-base`, a dependency of every linux-image package, so
# guaranteed present — measured `linux-base 4.12.1` installed) rather than parsing `dpkg -l`
# output, because kernel version ordering is not string ordering.
#
# /run is a tmpfs (measured), so the sentinel self-clears on boot: after a reboot the running
# kernel IS the newest and the script writes nothing. No cleanup task is needed, and none exists.
unattended_upgrades_sentinel_script: /usr/local/sbin/k3s-notify-reboot-required
unattended_upgrades_sentinel_hook: /etc/apt/apt.conf.d/99-k3s-reboot-required
@@ -0,0 +1,29 @@
---
# Re-arm apt's periodic timers after a config or drop-in change.
#
# WHY A daemon_reload IS REQUIRED AND NOT OPTIONAL. The timer windows are set by drop-ins under
# /etc/systemd/system/apt-daily{,-upgrade}.timer.d/. systemd does not re-read unit files on its own,
# so until it is told to, the RUNNING manager keeps the shipped schedule and the new window is
# ignored — the files look right in git and on disk, `systemctl cat` shows them, and the upgrade
# still fires at the old time. That is a silent config no-op, which is the failure mode this whole
# role is written against.
#
# `daemon_reload` re-parses unit files and re-runs generators. It starts and stops nothing, so it
# cannot disturb k3s, containerd or Longhorn — the same reasoning node_baseline's "Reload systemd
# unit files" handler records.
#
# RESTARTING THE TIMERS IS ALSO SAFE, and is not the same as running the upgrade. `*.timer` units
# only hold the schedule; restarting one recomputes its next elapse and does NOT trigger its
# `*.service`. With `Persistent=true` set in the drop-in, a window missed while the node was down is
# caught up on the next boot rather than skipped.
#
# Both fire only when a task actually changed, so a re-run of the role is silent.
- name: Restart unattended-upgrades timers
ansible.builtin.systemd_service:
name: "{{ item }}"
state: restarted
enabled: true
daemon_reload: true
loop:
- apt-daily.timer
- apt-daily-upgrade.timer
@@ -0,0 +1,369 @@
---
# roles/unattended_upgrades — automatic Debian security patching, kernel excluded, reboots delegated
# to kured. Every decision and its reasoning lives in defaults/main.yml; this file is the mechanism.
#
# TAGS — following roles/node_baseline, every task carries one of `packages`, `config` or `verify`.
# An untagged run does all three in that order. The split exists because `verify` is the half an
# operator will want to re-run on its own to answer "is automatic patching actually working on this
# box", without re-writing any configuration:
# ansible-playbook playbooks/unattended-upgrades.yml -e k3s_hosts=k3s_prod --tags verify
#
# IT DOES NOT UPGRADE ANYTHING, AND IT DOES NOT REBOOT. This role installs and CONFIGURES the
# upgrader; it never runs `apt upgrade` itself and never takes a reboot. Both of those are deliberate
# and both follow roles/node_baseline/tasks/main.yml:61-67 ("IT DOES NOT REBOOT", after k3s-prod-2
# hung on a warm reboot on 2026-09-03 and needed a physical power button). Applying this role is
# therefore a SAFE operation on a live cluster at any time of day: the first unattended upgrade
# happens at the next timer firing, and any reboot it implies is kured's to schedule inside the
# 03:00-06:00 window.
- name: Automatic security upgrades (unattended-upgrades; kernel excluded, reboots via kured)
when: unattended_upgrades_enabled | bool
tags: [packages, config, verify]
block:
# ── PREFLIGHT ───────────────────────────────────────────────────────────────────────────────
- name: Assert this is a Debian-family host
# The config file layout, the origin expressions and the package names below are all
# Debian-specific. On anything else this role would write files that are silently ignored,
# which is the failure mode this whole PR exists to avoid.
ansible.builtin.assert:
that:
- ansible_facts['os_family'] == 'Debian'
fail_msg: >-
REFUSING TO CONFIGURE unattended-upgrades on {{ inventory_hostname }}: os_family is
'{{ ansible_facts["os_family"] }}', not 'Debian'. The allowed-origin expressions and
config paths in this role are Debian-specific.
tags: [packages, config, verify]
# ── PACKAGES ────────────────────────────────────────────────────────────────────────────────
- name: Install unattended-upgrades and needrestart
# The reboot-sentinel writer is NOT a package here. On Debian nothing ships one -- see
# defaults/main.yml -- so this role installs its own script and dpkg hook, two tasks below.
ansible.builtin.apt:
name: "{{ unattended_upgrades_packages }}"
state: present
update_cache: true
cache_valid_time: 3600
tags: [packages]
# ── THE REBOOT SENTINEL WRITER (ours, because Debian ships none) ────────────────────────────
# Full rationale and the four measurements behind it: defaults/main.yml. Short version: kured
# watches /var/run/reboot-required and nothing on Debian creates it, so without these two files
# the entire reboot half is an invisible no-op.
- name: Install the reboot-sentinel writer
ansible.builtin.copy:
dest: "{{ unattended_upgrades_sentinel_script }}"
owner: root
group: root
mode: "0755"
content: |
#!/bin/sh
# MANAGED BY ANSIBLE -- infra/ansible/roles/unattended_upgrades. Local edits are reverted.
#
# Touch /var/run/reboot-required when the RUNNING kernel is not the newest INSTALLED one.
# kured watches that path; see the role's defaults/main.yml for why this is ours to ship.
#
# `linux-version` comes from linux-base (a dependency of every linux-image package), and
# is used rather than sorting `dpkg -l` output because kernel version ordering is not
# string ordering. If it is somehow missing, exit 0 and write nothing: a dpkg hook must
# never fail an apt transaction, and a missing sentinel is caught by the verify assert.
set -eu
command -v linux-version >/dev/null 2>&1 || exit 0
running="$(uname -r)"
newest="$(linux-version list 2>/dev/null | linux-version sort --reverse 2>/dev/null | head -n1)"
# No newest (nothing installed we can read) -> nothing to say.
[ -n "$newest" ] || exit 0
if [ "$running" != "$newest" ]; then
touch /var/run/reboot-required 2>/dev/null || exit 0
# kured only needs the file, but the .pkgs companion is what an operator reads to find
# out WHY a node is waiting, so write a reason rather than an empty marker.
printf 'linux-image-%s\n' "$newest" > /var/run/reboot-required.pkgs 2>/dev/null || true
fi
exit 0
tags: [packages, config]
- name: Install the dpkg hook that runs the sentinel writer
# Post-Invoke, so it runs after dpkg has finished and a newly installed kernel is already
# visible to `linux-version list`. `|| true` so a failure here can never abort an apt
# transaction -- an unwritten sentinel is a missed reboot, but a broken apt is a broken node.
ansible.builtin.copy:
dest: "{{ unattended_upgrades_sentinel_hook }}"
owner: root
group: root
mode: "0644"
content: |
// MANAGED BY ANSIBLE -- infra/ansible/roles/unattended_upgrades. Local edits reverted.
// Writes /var/run/reboot-required when the running kernel is not the newest installed.
DPkg::Post-Invoke { "{{ unattended_upgrades_sentinel_script }} || true"; };
tags: [packages, config]
# ── CONFIG ──────────────────────────────────────────────────────────────────────────────────
# Written as a whole file rather than patched in place, for the reason node_baseline gives for
# its own apt source: an idempotent full-file write with a managed-by header is reviewable and
# re-runnable, whereas line-editing a conffile fights dpkg on every package upgrade.
#
# 52- RATHER THAN 50-: Debian ships /etc/apt/apt.conf.d/50unattended-upgrades as a CONFFILE of
# the unattended-upgrades package. Overwriting it would make every future apt upgrade of that
# package prompt about a locally-modified conffile (or, under the non-interactive defaults a
# scripted run uses, silently keep one side and discard the other). apt reads the directory in
# lexical order and later files win, so a separate 52- file overrides the shipped defaults
# without ever touching them, and `apt-config dump` in the verify block below proves which value
# actually won rather than assuming this ordering holds.
- name: Configure unattended-upgrades (52-k3s-unattended-upgrades)
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/52-k3s-unattended-upgrades
owner: root
group: root
mode: "0644"
content: |
// MANAGED BY ANSIBLE — infra/ansible/roles/unattended_upgrades. Local edits are reverted
// on the next run. Rationale for every value: that role's defaults/main.yml.
//
// Overrides the shipped 50unattended-upgrades by lexical ordering (52 > 50). The shipped
// file is a dpkg conffile and is deliberately left untouched.
// SECURITY ONLY. Deliberately excludes trixie-updates (non-security fixes) and
// trixie-backports (o=Debian Backports) -- and excluding backports is what makes it
// structurally impossible for this upgrader to move the EFI-memattr-critical kernel,
// which is installed from there. See defaults/main.yml DECISION 2.
Unattended-Upgrade::Origins-Pattern {
{% for origin in unattended_upgrades_allowed_origins %}
"{{ origin }}";
{% endfor %}
{% if unattended_upgrades_include_updates | bool %}
"origin=Debian,codename=${distro_codename}-updates,label=Debian";
{% endif %}
};
// NEVER AUTOMATICALLY UPGRADED, whatever the origin list later says. `^linux-` is the
// EFI-memattr guard: these HP minis need kernel >= 6.13 or they can boot with a fraction
// of their RAM, and 6.12 kernels are still installed and bootable on all three nodes.
// Do not remove without re-reading infra/PROD-PROVISIONING.md §0b.
Unattended-Upgrade::Package-Blacklist {
{% for pkg in unattended_upgrades_blacklist %}
"{{ pkg }}";
{% endfor %}
};
// REBOOTS BELONG TO kured (platform/kured), NOT TO apt. All three nodes are
// control-plane + etcd with quorum 2 of 3, so three hosts independently deciding to
// reboot at their own Automatic-Reboot-Time is how the cluster is lost. u-u leaves
// /var/run/reboot-required behind; kured takes a cluster-wide lock and reboots exactly
// one node at a time, after cordon + drain, inside 03:00-06:00, gated on Longhorn volume
// health.
Unattended-Upgrade::Automatic-Reboot "{{ unattended_upgrades_automatic_reboot | bool | lower }}";
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
// Keep the node's disk from filling with superseded .debs, and keep a log an operator can
// read after the fact (/var/log/unattended-upgrades/). MinAge guards against removing a
// package still needed by a rollback.
Unattended-Upgrade::Remove-Unused-Kernel-Packages "false";
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
Unattended-Upgrade::Remove-Unused-Dependencies "false";
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::SyslogEnable "true";
// Remove-Unused-Kernel-Packages is FALSE on purpose, against the Debian default. These
// nodes deliberately carry more than one kernel: the backports kernel they boot and the
// distro kernels that are the documented rollback path (node_baseline writes the rollback
// procedure onto the node itself). An unattended process pruning kernels could remove the
// fallback an operator needs at 3am in front of a box that will not boot.
notify: Restart unattended-upgrades timers
tags: [config]
- name: Enable the apt periodic timers that actually run unattended-upgrades
# Installing the package is NOT sufficient to make it run on a schedule: without these
# counters apt's daily units do nothing, and the whole mechanism is inert while looking
# installed. `1` means "every day".
ansible.builtin.copy:
dest: /etc/apt/apt.conf.d/20auto-upgrades
owner: root
group: root
mode: "0644"
content: |
// MANAGED BY ANSIBLE — infra/ansible/roles/unattended_upgrades.
// Without these four counters the apt-daily timers fire and do nothing, so
// unattended-upgrades would be installed, configured, and never once invoked.
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
tags: [config]
- name: Ensure the systemd timer drop-in directories exist
# `copy` does not create parent directories, and these two .timer.d/ dirs do not exist on a
# stock Debian node — without this task the next task fails with "Destination directory does
# not exist" on a first run.
ansible.builtin.file:
path: "/etc/systemd/system/{{ item }}.timer.d"
state: directory
owner: root
group: root
mode: "0755"
loop:
- apt-daily
- apt-daily-upgrade
tags: [config]
- name: Pin the apt timer windows into kured's reboot window
# Overriding the shipped timers rather than editing them: a drop-in survives package upgrades.
# OnCalendar is cleared first (`OnCalendar=` with no value) because systemd ACCUMULATES
# OnCalendar entries across drop-ins — omit the reset and the unit keeps its shipped schedule
# IN ADDITION to ours, which would run the upgrade at both times and look like it worked.
ansible.builtin.copy:
dest: "/etc/systemd/system/{{ item.unit }}.timer.d/override.conf"
owner: root
group: root
mode: "0644"
content: |
# MANAGED BY ANSIBLE — infra/ansible/roles/unattended_upgrades.
# Lands the upgrade inside kured's 03:00-06:00 reboot window so a sentinel written by an
# upgrade can be acted on the same night rather than ~24h later.
[Timer]
OnCalendar=
OnCalendar={{ item.time }}
RandomizedDelaySec={{ unattended_upgrades_randomised_delay }}
Persistent=true
force: true
loop:
- { unit: apt-daily, time: "{{ unattended_upgrades_download_time }}" }
- { unit: apt-daily-upgrade, time: "{{ unattended_upgrades_upgrade_time }}" }
loop_control:
label: "{{ item.unit }} at {{ item.time }}"
notify: Restart unattended-upgrades timers
tags: [config]
- name: Configure needrestart (restart services, but never k3s)
# See defaults/main.yml DECISION 3. Auto-restart is what makes a security-only policy real --
# a patched library on disk does nothing for a process still mapping the deleted one -- but
# k3s.service is excluded because restarting the control plane unattended is worse than a
# delayed library fix. `$nrconf{restart}` is Perl config read by needrestart itself.
ansible.builtin.copy:
dest: /etc/needrestart/conf.d/50-k3s.conf
owner: root
group: root
mode: "0644"
content: |
# MANAGED BY ANSIBLE — infra/ansible/roles/unattended_upgrades.
# Restart mode: '{{ unattended_upgrades_needrestart_mode }}' (a = automatically, l = list only, i = interactive)
$nrconf{restart} = '{{ unattended_upgrades_needrestart_mode }}';
# Units matching these patterns are NEVER restarted automatically. k3s bundles containerd
# and the kubelet, so this one entry covers the entire control plane and runtime on these
# nodes (measured 2026-10-17: k3s.service is the only matching unit). A library fix that
# needs k3s restarted therefore waits for the reboot kured will take.
$nrconf{blacklist_rc} = [
{% for pat in unattended_upgrades_needrestart_blacklist %}
qr({{ pat }}),
{% endfor %}
];
tags: [config]
# ── VERIFY ──────────────────────────────────────────────────────────────────────────────────
# These tasks exist because every component of this mechanism fails SILENTLY. An installed
# package, a written config file and a green play are all compatible with nothing ever being
# upgraded and no reboot ever being signalled.
- name: Ask apt-config what the effective settings actually are
# `apt-config dump` is apt's OWN parse of the whole /etc/apt/apt.conf.d/ directory, so it
# proves what WON after lexical ordering rather than what this role wrote. If the shipped
# 50unattended-upgrades were ever renamed above 52-, this is the task that would catch it.
ansible.builtin.command: apt-config dump
register: unattended_upgrades_aptconfig
changed_when: false
check_mode: false
tags: [verify]
- name: Assert the kernel is blacklisted and automatic reboots are off
# The two properties that must never silently regress. Asserted against apt's own view.
ansible.builtin.assert:
that:
# apt-config renders each list entry as `Unattended-Upgrade::Package-Blacklist:: "^linux-";`
# so the anchored pattern itself is the thing to look for. Checking for the bare string
# `linux-` would pass on almost any dump and prove nothing.
- "'\"^linux-\"' in unattended_upgrades_aptconfig.stdout"
- "'Unattended-Upgrade::Automatic-Reboot \"false\"' in unattended_upgrades_aptconfig.stdout"
# The origin list must be non-empty, or u-u runs nightly and upgrades nothing — installed,
# scheduled, green, and inert.
- "'Debian-Security' in unattended_upgrades_aptconfig.stdout"
fail_msg: >-
EFFECTIVE apt CONFIG ON {{ inventory_hostname }} IS NOT WHAT THIS ROLE INTENDED.
Either the `^linux-` kernel blacklist or `Automatic-Reboot "false"` is not winning.
Both are safety properties, not preferences: without the blacklist an unattended process
can move a kernel these HP minis must keep >= 6.13 (PROD-PROVISIONING.md §0b), and
without Automatic-Reboot=false three control-plane nodes can each reboot themselves
independently and take etcd below quorum. Inspect with `apt-config dump | grep -i
unattended` and check for a file sorting after 52- in /etc/apt/apt.conf.d/.
success_msg: >-
Effective apt config confirmed: kernel blacklisted, automatic reboot disabled.
tags: [verify]
# THE SENTINEL IS THE SINGLE POINT OF FAILURE FOR THE ENTIRE REBOOT HALF OF THIS DESIGN.
# This was originally a `find` over /etc/kernel/postinst.d and /etc/apt/apt.conf.d, on the
# theory that the writer came from a package and the role could only discover whatever that
# package happened to ship. That theory was wrong: no such package exists on Debian (see
# defaults/main.yml), so the writer is now OURS and lives at two known paths. Checking two
# exact paths is strictly stronger than a glob -- a glob would have matched Debian's own
# unattended-upgrades apt conffile and passed while no sentinel writer existed at all.
- name: Stat the sentinel writer script
ansible.builtin.stat:
path: "{{ unattended_upgrades_sentinel_script }}"
register: unattended_upgrades_sentinel_script_stat
changed_when: false
tags: [verify]
- name: Stat the sentinel dpkg hook
ansible.builtin.stat:
path: "{{ unattended_upgrades_sentinel_hook }}"
register: unattended_upgrades_sentinel_hook_stat
changed_when: false
tags: [verify]
- name: Assert something on this node can write the reboot sentinel
# If this fails, kured is a no-op: it watches /var/run/reboot-required forever, the file is
# never created, no reboot is ever taken, and NOTHING GOES RED -- the DaemonSet stays 1/1
# Running and `kured_reboot_required` reports a steady 0 on every node. That is the exact
# shape of failure (a check whose failure mode is a green tick) this repo has been burned by
# before, so it is an assert and not a debug message.
ansible.builtin.assert:
that:
- unattended_upgrades_sentinel_script_stat.stat.exists
- unattended_upgrades_sentinel_script_stat.stat.executable | default(false)
- unattended_upgrades_sentinel_hook_stat.stat.exists
fail_msg: >-
NOTHING ON {{ inventory_hostname }} CAN CREATE /var/run/reboot-required.
{{ unattended_upgrades_sentinel_script }} (executable) and
{{ unattended_upgrades_sentinel_hook }} must both exist -- this role ships both, because
Debian provides no sentinel writer of its own (update-notifier-common is an UBUNTU
package and is not installable here). kured watches that path and reboots only when it
exists, so with no writer the reboot automation is permanently and INVISIBLY idle --
kured stays 1/1 Running and reports kured_reboot_required=0 on every node forever.
Re-run this role without `--tags verify` to install them.
success_msg: >-
Reboot sentinel writer present ({{ unattended_upgrades_sentinel_script }} + dpkg hook)
-- a kernel change will signal kured on the next apt transaction.
tags: [verify]
- name: Report the pending-reboot state of this node
# Read-only. Tells the operator whether this node is currently waiting on kured for a reboot,
# which is the question `--tags verify` is usually being run to answer.
ansible.builtin.stat:
path: /var/run/reboot-required
register: unattended_upgrades_reboot_required
changed_when: false
tags: [verify]
- name: Summary
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }}: security-only unattended upgrades ACTIVE
(upgrade timer {{ unattended_upgrades_upgrade_time }} +/-{{ unattended_upgrades_randomised_delay }}),
kernel and microcode blacklisted, automatic reboot DISABLED.
Reboot currently pending: {{ unattended_upgrades_reboot_required.stat.exists }}.
{% if unattended_upgrades_reboot_required.stat.exists %}
This node is waiting for kured to reboot it in the next 03:00-06:00 window; it will be
cordoned and drained first, and no other node will be rebooted at the same time.
{% endif %}
tags: [verify]
+63 -1
View File
@@ -8,7 +8,10 @@
# packages are retained as harmless disk-image utilities. Fedora base is pinned for
# reproducibility.
ARG FEDORA_VERSION=44
FROM registry.fedoraproject.org/fedora:${FEDORA_VERSION}
# Stage `toolbox` is the image that ships. Stage `verify` (end of file) is a TEST stage: it is
# built FROM the finished image and only runs assertions, and it is never pushed. Build with
# `--target toolbox` for the image and `--target verify` to prove it (build-image.sh does both).
FROM registry.fedoraproject.org/fedora:${FEDORA_VERSION} AS toolbox
ARG OPENTOFU_VERSION
ARG AGE_VERSION
@@ -32,9 +35,23 @@ ARG TARGETARCH=amd64
# Distro packages: libvirt client libs (harmless disk-image utilities), ansible, ssh,
# disk-image + cloud-init seed tooling, python3-pip (for the pip-installed test tools),
# diffutils (provides `diff`, used by L1 golden render checks), and common utilities.
#
# wireguard-tools + qrencode serve the REMOTE-ACCESS VPN (platform/vpn-ingress):
# * wireguard-tools gives `wg genkey` / `wg pubkey`, needed to mint the server key and to derive
# every client profile's `[Peer] PublicKey` (infra/vpn-ingress/new-peer.sh).
# * qrencode renders a client profile as a QR code, which is how a PHONE gets enrolled.
# They are here rather than on the operator's host because this image is THE toolchain
# (CLAUDE.md §5; README "Don't install the toolchain on the host"). That rule was briefly broken
# by a runbook that told the operator to run `wg` on the host and `sops` in here, and then split
# every step across the boundary to work around it — a procedure nobody should have to follow, and
# one that cannot be reproduced in CI. Both tools in one image means `sops -d … | wg pubkey` is
# just a pipeline again.
# Unpinned on purpose, like every other dnf package here: the Fedora base is what is pinned
# (FEDORA_VERSION), and these are not gate tools whose version changes what green means.
RUN dnf -y install --setopt=install_weak_deps=False \
libvirt-client qemu-img cloud-utils genisoimage openssh-clients \
ansible-core python3-libvirt python3-pip diffutils \
wireguard-tools qrencode \
git make jq unzip tar gzip xz which ca-certificates curl \
&& dnf clean all
@@ -81,3 +98,48 @@ RUN { echo "opentofu=${OPENTOFU_VERSION}"; echo "age=${AGE_VERSION}"; echo "sops
WORKDIR /work
CMD ["/bin/bash"]
# =============================================================================
# verify — a TEST stage, never shipped. It runs every assertion against the finished image AT
# BUILD TIME, so CI needs no container runtime to prove the image: the image pipeline builds
# this target on rootless BuildKit (platform/buildkit/), where `docker run` does not exist.
# `RUN --network=none` reproduces the old `run --network none` exactly, and the first check
# below proves it (it fails if any network is reachable).
# =============================================================================
FROM toolbox AS verify
ARG PROMTOOL_VERSION
ARG KUBECTL_VERSION
ARG CHAINSAW_VERSION
ARG KYVERNO_VERSION
ARG SOPS_VERSION
RUN --network=none /bin/bash -euo pipefail <<'VERIFY'
echo "--- no network (the build-time stand-in for run --network none) ---"
if getent hosts registry.fedoraproject.org >/dev/null 2>&1 || curl -sS --max-time 3 -o /dev/null https://1.1.1.1 2>/dev/null; then
echo "UNEXPECTED: network reachable in the verify stage"; exit 1
fi
echo " confirmed: no network"
echo "--- baked versions ---"; cat /etc/dev-toolchain-versions
echo "--- every tool is on PATH ---"
for t in tofu age age-keygen sops flux kubectl helm kyverno kyverno-json chainsaw yq \
yamllint check-jsonschema promtool ansible-playbook git make jq diff python3 \
wg qrencode; do
if command -v "$t" >/dev/null 2>&1; then printf " ok %s\n" "$t";
else printf " MISSING %s\n" "$t"; missing=1; fi
done
[ -z "${missing:-}" ] || { echo "toolbox is missing tools"; exit 1; }
echo "--- version-sensitive tools report their pins ---"
: "${PROMTOOL_VERSION:?}" "${KUBECTL_VERSION:?}" "${CHAINSAW_VERSION:?}" "${KYVERNO_VERSION:?}" "${SOPS_VERSION:?}"
check() { # name pin actual-output
if printf "%s" "$3" | grep -qF "$2"; then printf " ok %-10s %s\n" "$1" "$2";
else printf " DRIFT %-10s want %s, got: %s\n" "$1" "$2" "$(printf "%s" "$3" | head -1)"; drift=1; fi
}
check promtool "${PROMTOOL_VERSION}" "$(promtool --version 2>&1)"
check kubectl "${KUBECTL_VERSION}" "$(kubectl version --client 2>&1)"
check chainsaw "${CHAINSAW_VERSION}" "$(chainsaw version 2>&1)"
check kyverno "${KYVERNO_VERSION}" "$(kyverno version 2>&1)"
check sops "${SOPS_VERSION}" "$(sops --version --disable-version-check 2>&1 || sops --version 2>&1)"
[ -z "${drift:-}" ] || { echo "a tool does not report its pinned version"; exit 1; }
echo "--- python can import what the renderer and the gate need ---"
python3 -c "import yaml; print(' PyYAML', yaml.__version__)"
echo "TOOLBOX VERIFIED"
VERIFY
+19
View File
@@ -14,6 +14,7 @@ baseline, and the *same* binaries CI uses.
| Provisioning | OpenTofu (`bpg/proxmox` provider), `ansible-core`, `openssh-clients` |
| GitOps / cluster | `flux`, `kubectl`, `helm` |
| Secrets | `sops`, `age` + `age-keygen` |
| VPN (`platform/vpn-ingress`) | `wg` (wireguard-tools) — mint the server key, derive client public keys; `qrencode` — render a client profile as a phone-scannable QR |
| **CI gate** | `yamllint`, `check-jsonschema`, `kyverno` CLI, `kyverno-json`, `chainsaw`, `yq`, `diffutils`, `make` |
| Misc | `qemu-img`, `cloud-utils`/`genisoimage`, `libvirt-client` (disk-image utilities only — leftovers from the libvirt era, harmless) |
@@ -33,6 +34,24 @@ cd infra/devcontainer
```
`ENGINE=docker ./dev.sh` to use docker instead of rootless podman.
### The published toolbox
`./dev.sh build` delegates to [`build-image.sh`](build-image.sh). The recipe is the
`Containerfile` plus that script's build-arg list, and the proof is the Containerfile's `verify`
stage (every tool on `PATH`, the version-sensitive ones report their `versions.env` pins, no
network). CI builds the same Containerfile on rootless BuildKit with the args from
`build-image.sh --print-build-args`, and, on a push to `main` that changes
`Containerfile`, `versions.env` or `build-image.sh`, **publishes** the result as
`gitea.home.keithjackson.dev/home-network/toolbox` (tags `sha-<commit>` and `main`) —
see [`.gitea/workflows/images.yaml`](../../.gitea/workflows/images.yaml). `agent-runner` is
built `FROM` that published digest (`infra/agent/versions.env`, kept current by Renovate).
Nobody pushes this image from a workstation. To use the published one locally instead of
building, after `podman login gitea.home.keithjackson.dev` (a token with `read:package`):
```bash
IMAGE=gitea.home.keithjackson.dev/home-network/toolbox:main ./dev.sh run make -C ci gate
```
## Choosing a cluster — `CLUSTER=dev|prod`
```bash
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env bash
###############################################################################
# infra/devcontainer/build-image.sh — build the pinned dev `toolbox` image and prove it works.
#
# ./build-image.sh # build + verify (tag: home-k3s-dev:local)
# ./build-image.sh --no-verify # build only
# IMAGE=foo:bar ./build-image.sh # build under another local name
# ENGINE=docker ./build-image.sh # build with docker instead of podman
# ./build-image.sh --print-build-args # print KEY=VALUE build args (what CI consumes)
#
# ONE BUILD DEFINITION, TWO CALLERS. `dev.sh build` calls this for local development. The CI
# image pipeline (.gitea/workflows/images.yaml -> .gitea/workflows/scripts/images.sh) builds the
# SAME Containerfile on rootless BuildKit (platform/buildkit/) — no container engine exists in a
# CI job — taking its build args from `--print-build-args`, and PUBLISHES the result as
# gitea.home.keithjackson.dev/home-network/toolbox. The Containerfile (including its `verify`
# test stage) and the arg list here are the one recipe both use.
#
# THIS SCRIPT NEVER PUSHES. Publishing is CI's job (images.yaml, on push to main), not a
# workstation's: an image that exists only because someone ran `podman push` from a laptop has
# no build log, no recorded inputs and no verification anyone else can read.
#
# VERSIONS ARE NOT DEFINED HERE. infra/devcontainer/versions.env is the single source of truth.
###############################################################################
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENGINE="${ENGINE:-podman}"
IMAGE="${IMAGE:-home-k3s-dev:local}"
VERIFY=1
PRINT_ARGS=0
for arg in "$@"; do
case "$arg" in
--no-verify) VERIFY=0 ;;
--print-build-args) PRINT_ARGS=1 ;;
-h|--help) sed -n '2,/^####/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
# shellcheck disable=SC1091
source "${HERE}/versions.env"
# THE build args, in ONE place. `--print-build-args` hands exactly this list to the CI pipeline
# (.gitea/workflows/scripts/images.sh), which builds on rootless BuildKit rather than calling
# "$ENGINE build" — so CI and a local build cannot drift apart on a pin.
BUILD_ARGS=(
"FEDORA_VERSION=$FEDORA_VERSION" "OPENTOFU_VERSION=$OPENTOFU_VERSION" "AGE_VERSION=$AGE_VERSION"
"SOPS_VERSION=$SOPS_VERSION" "FLUX_VERSION=$FLUX_VERSION" "KUBECTL_VERSION=$KUBECTL_VERSION"
"HELM_VERSION=$HELM_VERSION" "KYVERNO_VERSION=$KYVERNO_VERSION"
"KYVERNO_JSON_VERSION=$KYVERNO_JSON_VERSION" "CHAINSAW_VERSION=$CHAINSAW_VERSION"
"YAMLLINT_VERSION=$YAMLLINT_VERSION" "YQ_VERSION=$YQ_VERSION"
"CHECK_JSONSCHEMA_VERSION=$CHECK_JSONSCHEMA_VERSION" "PROMTOOL_VERSION=$PROMTOOL_VERSION"
)
if [ "${PRINT_ARGS}" -eq 1 ]; then printf '%s\n' "${BUILD_ARGS[@]}"; exit 0; fi
command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGINE=docker to use docker)"
ARG_FLAGS=()
for a in "${BUILD_ARGS[@]}"; do ARG_FLAGS+=(--build-arg "$a"); done
# Provenance: CI passes the commit it is building, so a pulled image names its source tree.
LABEL_ARGS=()
[ -z "${BUILD_REVISION:-}" ] || LABEL_ARGS+=(--label "org.opencontainers.image.revision=${BUILD_REVISION}")
# --- verify --------------------------------------------------------------------
# The assertions live in the Containerfile's `verify` stage (every tool on PATH, the
# version-sensitive ones report their versions.env pins — promtool above all, because
# ci/versions.env asserts a promtool/Prometheus lockstep — and no network), so the SAME proof
# runs here under podman and in CI under rootless BuildKit. A failing assertion fails the build.
if [ "${VERIFY}" -eq 1 ]; then
log "Verifying (Containerfile target: verify)"
"$ENGINE" build "${ARG_FLAGS[@]}" --target verify -f "${HERE}/Containerfile" "${HERE}"
log "Toolbox verified"
fi
log "Building ${IMAGE}"
"$ENGINE" build "${LABEL_ARGS[@]}" "${ARG_FLAGS[@]}" --target toolbox \
-t "$IMAGE" -f "${HERE}/Containerfile" "${HERE}"
log "Built ${IMAGE}"
printf ' image id %s\n' "$("$ENGINE" image inspect --format '{{.Id}}' "$IMAGE")"
+4 -16
View File
@@ -72,22 +72,10 @@ esac
CLUSTER_KUBECONFIG="${REPO_ROOT}/infra/ansible/.cache/${CLUSTER}.kubeconfig"
build() {
"$ENGINE" build \
--build-arg FEDORA_VERSION="$FEDORA_VERSION" \
--build-arg OPENTOFU_VERSION="$OPENTOFU_VERSION" \
--build-arg AGE_VERSION="$AGE_VERSION" \
--build-arg SOPS_VERSION="$SOPS_VERSION" \
--build-arg FLUX_VERSION="$FLUX_VERSION" \
--build-arg KUBECTL_VERSION="$KUBECTL_VERSION" \
--build-arg HELM_VERSION="$HELM_VERSION" \
--build-arg KYVERNO_VERSION="$KYVERNO_VERSION" \
--build-arg KYVERNO_JSON_VERSION="$KYVERNO_JSON_VERSION" \
--build-arg CHAINSAW_VERSION="$CHAINSAW_VERSION" \
--build-arg YAMLLINT_VERSION="$YAMLLINT_VERSION" \
--build-arg YQ_VERSION="$YQ_VERSION" \
--build-arg CHECK_JSONSCHEMA_VERSION="$CHECK_JSONSCHEMA_VERSION" \
--build-arg PROMTOOL_VERSION="$PROMTOOL_VERSION" \
-t "$IMAGE" -f "${HERE}/Containerfile" "${HERE}"
# The build recipe lives in build-image.sh so that CI (.gitea/workflows/images.yaml), which
# builds and PUBLISHES this image, and a local `dev.sh build` cannot drift apart. --no-verify
# keeps the local loop as fast as it always was; CI runs the verification.
IMAGE="$IMAGE" ENGINE="$ENGINE" "${HERE}/build-image.sh" --no-verify
}
run() {
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Copy the image builder's CLIENT certificate from the cluster into the three repository Actions
# secrets the image pipeline reads: BUILDKIT_CA, BUILDKIT_CLIENT_CERT, BUILDKIT_CLIENT_KEY.
#
# ./infra/gitea/buildkit-client-secret.sh # copy and verify
# ./infra/gitea/buildkit-client-secret.sh --check # compare only; changes nothing
#
# RUNNING IT IS AN OPERATOR ACTION. It writes repository secrets.
#
# WHERE THE CERTIFICATE COMES FROM. platform/buildkit/certificates.yaml has cert-manager issue
# `buildkit-client-ci` from a CA that exists only for the builder; buildkitd admits a connection
# only with a certificate signed by that CA. CI jobs hold NO cluster credential (R-SEC-02), so they
# cannot read the Secret themselves: this script is the one bridge, run by a human who has both
# a kube context and the Gitea credential.
#
# WHEN TO RUN IT
# 1. once, after Flux has applied platform/buildkit (Kustomization `buildkit` Ready, and
# `kubectl -n buildkit get certificate buildkit-client-ci` READY=True);
# 2. after every renewal of that certificate (duration 1y, renewed 30 days early). Until then
# the image pipeline fails LOUDLY at its builder preflight — it never skips.
# `--check` tells you whether the copy is stale.
#
# NOTHING SECRET IS PRINTED. The key goes from `kubectl get secret` into the Gitea secrets API
# through shell variables and nowhere else. The script prints only names, sizes and the
# certificate's subject and expiry (public data).
#
# NEEDS: kubectl with a context for the cluster (default k3s-prod), jq, curl, openssl, base64,
# and the Gitea credential `git` already has stored for the host — the same requirements as
# infra/gitea/conformance-trigger-kubeconfig.sh, run from the same place.
set -euo pipefail
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
NS=buildkit; SECRET=buildkit-client-ci; KCTX="${KCTX:-k3s-prod}"
CHECK=0
for a in "$@"; do
case "$a" in
--check) CHECK=1 ;;
-h|--help) sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown argument: $a" >&2; exit 2 ;;
esac
done
die() { echo "ERROR: $*" >&2; exit 1; }
for t in kubectl jq curl openssl base64 git; do command -v "$t" >/dev/null || die "missing required tool: $t"; done
echo "==> reading Secret ${NS}/${SECRET} (context ${KCTX}; values not shown)"
js="$(kubectl --context "$KCTX" -n "$NS" get secret "$SECRET" -o json)" \
|| die "cannot read ${NS}/${SECRET} — is platform/buildkit deployed and the Certificate Ready?"
CA="$(jq -r '.data["ca.crt"] // empty' <<<"$js" | base64 -d)"
CRT="$(jq -r '.data["tls.crt"] // empty' <<<"$js" | base64 -d)"
KEY="$(jq -r '.data["tls.key"] // empty' <<<"$js" | base64 -d)"
unset js
[ -n "$CA" ] && [ -n "$CRT" ] && [ -n "$KEY" ] || die "${NS}/${SECRET} lacks ca.crt/tls.crt/tls.key"
# Sanity before anything is written: the leaf is a CLIENT cert issued by this CA, and the key
# belongs to it. A wrong pairing would otherwise surface only as a TLS failure in CI.
openssl verify -CAfile <(printf '%s\n' "$CA") <(printf '%s\n' "$CRT") >/dev/null 2>&1 \
|| die "tls.crt does not verify against ca.crt"
openssl x509 -noout -ext extendedKeyUsage <<<"$CRT" 2>/dev/null | grep -q "TLS Web Client Authentication" \
|| die "tls.crt is not a client-auth certificate"
[ "$(openssl x509 -noout -pubkey <<<"$CRT" | openssl sha256)" = "$(openssl pkey -pubout <<<"$KEY" 2>/dev/null | openssl sha256)" ] \
|| die "tls.key does not match tls.crt"
echo " $(openssl x509 -noout -subject <<<"$CRT") / $(openssl x509 -noout -enddate <<<"$CRT")"
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")"; unset creds
[ -n "$U" ] && [ -n "$P" ] || die "no stored credential for $HOST"
echo "==> authenticating to Gitea as '${U}' (secret not shown)"
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
API="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/actions/secrets"
if [ "$CHECK" -eq 1 ]; then
# Gitea never returns a secret's value, only its name and creation time. "Stale" therefore
# means: the secret was created before the certificate's current notBefore.
names="$(api "$API" | jq -c '[.[]? | {name, created_at}]')"
nb="$(date -u -d "$(openssl x509 -noout -startdate <<<"$CRT" | cut -d= -f2)" +%s)"
rc=0
for n in BUILDKIT_CA BUILDKIT_CLIENT_CERT BUILDKIT_CLIENT_KEY; do
c="$(jq -r --arg n "$n" '.[] | select(.name==$n) | .created_at' <<<"$names")"
if [ -z "$c" ]; then echo " MISSING $n"; rc=1
elif [ "$(date -u -d "$c" +%s)" -lt "$nb" ]; then echo " STALE $n (stored $c, certificate re-issued since)"; rc=1
else echo " current $n (stored $c)"; fi
done
exit "$rc"
fi
put() { # name value
local code
code="$(api -o /dev/null -w '%{http_code}' -X PUT -H 'Content-Type: application/json' \
-d "$(jq -n --arg d "$2" '{data:$d}')" "${API}/$1")"
[ "$code" = 201 ] || [ "$code" = 204 ] || die "PUT $1 failed: HTTP ${code}"
echo " stored $1 (${#2} bytes, value not shown)"
}
echo "==> storing repository Actions secrets"
put BUILDKIT_CA "$CA"
put BUILDKIT_CLIENT_CERT "$CRT"
put BUILDKIT_CLIENT_KEY "$KEY"
unset CA CRT KEY
# VERIFY BY READING BACK (names only — Gitea never returns values). curl -sS exits 0 on an HTTP
# error, which is how set-ci-token.sh once claimed a store that never happened.
names="$(api "$API" | jq -r '[.[]?.name] | join(" ")')"
for n in BUILDKIT_CA BUILDKIT_CLIENT_CERT BUILDKIT_CLIENT_KEY; do
grep -qw "$n" <<<"$names" || die "stored $n but it is NOT listed (${names}) — refusing to claim success"
done
echo "==> stored and VERIFIED present. Re-run the images workflow (workflow_dispatch) to use them."
@@ -252,11 +252,16 @@ step "preconditions — repo side (these block ONLY arming ${VARIABLE}; the secr
# R1: the runner image is built and digest-pinned. Without it the Job cannot run run.sh at
# all (the stock chainsaw image has no bash/python3/kubectl/git) — plan §3 item 1.
# EVALUATED, not grepped: the line is `CONFORMANCE_RUNNER_IMAGE="${..._REPO}@${..._DIGEST}"`, so
# its literal text never contains 64 hex digits and the grep this replaced could never pass. The
# all-zero digest is the "not published yet" placeholder (Renovate replaces it after CI's first
# publish, .gitea/workflows/images.yaml) and must not count as pinned.
VERSIONS="${REPO_ROOT}/conformance/versions.env"
if grep -Eq '^[[:space:]]*CONFORMANCE_RUNNER_IMAGE=.*@sha256:[0-9a-f]{64}' "$VERSIONS" 2>/dev/null; then
pass "CONFORMANCE_RUNNER_IMAGE is digest-pinned in conformance/versions.env"
runner_img="$( (set +u; . "$VERSIONS" >/dev/null 2>&1; printf '%s' "${CONFORMANCE_RUNNER_IMAGE:-}") )"
if [[ "$runner_img" =~ @sha256:[0-9a-f]{64}$ ]] && [[ ! "$runner_img" =~ @sha256:0{64}$ ]]; then
pass "CONFORMANCE_RUNNER_IMAGE is digest-pinned in conformance/versions.env (${runner_img##*@})"
else
fail_arm "conformance/versions.env has no digest-pinned CONFORMANCE_RUNNER_IMAGE (plan §3 item 1) — the Job cannot run ./run.sh"
fail_arm "conformance/versions.env has no PUBLISHED digest for CONFORMANCE_RUNNER_IMAGE (got '${runner_img}') — the image pipeline has not published it yet, or Renovate's bump is unmerged (plan §3 item 1)"
fi
# R2: the Job template is the real thing, not the skeleton: the admitted image AND an
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Registry retention for this repo's OWN images (toolbox, agent-runner, conformance-runner):
# keep what is deployed and what is recent, delete the rest. DRY RUN unless --apply.
#
# ./infra/gitea/registry-retention.sh # show what would be deleted
# ./infra/gitea/registry-retention.sh --apply # delete it
# KEEP=15 ./infra/gitea/registry-retention.sh # keep the 15 newest sha- tags (default 10)
#
# RUNNING IT IS AN OPERATOR ACTION. It deletes registry content, so it is not wired into CI and
# no agent runs it with --apply.
#
# WHY A SCRIPT, AND NOT GITEA'S BUILT-IN "CLEANUP RULE" — measured against Gitea 1.24.3's source:
#
# 1. There is NO API for cleanup rules. routers/api/v1/api.go has no such route; the rules live
# only behind the web UI's session+CSRF forms (routers/web/web.go, org settings
# `/packages/rules/add`). A rule therefore cannot be created, read or drift-checked as code —
# which is also why ci/gitea-settings.sh cannot assert one exists.
# 2. A built-in rule would DELETE WHAT IS DEPLOYED. Every reference to these images is
# digest-only (`<repo>@sha256:...`, required by admission). Gitea stores a manifest as a
# file OF THE TAG VERSION it was pushed under (routers/api/packages/container/manifest.go
# createPackageAndVersion), and resolves a by-digest pull by searching those files
# (models/packages/container/search.go). So deleting the tag a digest was pushed under makes
# that digest unpullable — MANIFEST_UNKNOWN, i.e. ImagePullBackOff on the next reschedule.
# The built-in rule keeps "the newest N" and a name pattern, and knows nothing about which
# digest git currently pins. Renovate's bump PRs can lag, so "newest N" is not a guarantee.
#
# WHAT IS KEPT, per image:
# * the `main` and `latest` tags;
# * the KEEP newest `sha-<commit>` tags (by creation time);
# * EVERY tag whose manifest digest is referenced anywhere in the tracked tree at REF
# (default origin/main) — i.e. whatever Flux is deploying, and the toolbox digest
# agent-runner builds FROM;
# * every version whose NAME is a digest (`sha256:...`): those are index children, and Gitea's
# own cleanup handles them (services/packages/container/cleanup.go ShouldBeSkipped).
# Everything else is deleted. Gitea's `cleanup_packages` cron then reclaims the unreferenced
# blobs (default: daily, blobs older than 24h) — space comes back the day after, not instantly.
#
# THE STORAGE IT PROTECTS. Packages live on the SAME 10Gi Longhorn PVC as every repository and
# Gitea's database (catalog/gitea.yaml). The toolbox is ~1.4 GB uncompressed and every main push
# that touches its inputs adds a complete, non-reproducible copy. Unpruned, the registry is what
# fills the volume the git host lives on.
#
# CREDENTIAL: the one `git` has stored for the host (as the other scripts here). It needs
# `read:package` to list and `write:package` to --apply, and the account needs package write on
# the org. The secret is never printed.
set -euo pipefail
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; NS=home-network
IMAGES=(toolbox agent-runner conformance-runner)
KEEP="${KEEP:-10}"
REF="${REF:-origin/main}"
APPLY=0
for a in "$@"; do
case "$a" in
--apply) APPLY=1 ;;
-h|--help) sed -n '2,/^set -euo/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown argument: $a" >&2; exit 2 ;;
esac
done
[[ "$KEEP" =~ ^[0-9]+$ ]] && [ "$KEEP" -ge 1 ] || { echo "KEEP must be a positive integer" >&2; exit 2; }
command -v jq >/dev/null || { echo "jq is required" >&2; exit 1; }
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
git -C "$REPO_ROOT" fetch -q origin main 2>/dev/null || echo " (could not fetch origin/main; using the local ${REF})" >&2
git -C "$REPO_ROOT" rev-parse -q --verify "${REF}^{commit}" >/dev/null || { echo "no such ref: ${REF}" >&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 credential for $HOST" >&2; exit 1; }
echo "authenticating as '${U}' (secret not shown)"
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
REG_TOKEN="$(api "https://${HOST}/v2/token?service=container_registry" | jq -r '.token // empty')"
[ -n "$REG_TOKEN" ] || { echo "registry token exchange failed (does the credential have read:package?)" >&2; exit 1; }
manifest_digest() { # <image> <tag> -> digest, or nothing
curl -sS -I --config <(printf 'header = "Authorization: Bearer %s"\n' "$REG_TOKEN") \
-H 'Accept: application/vnd.oci.image.manifest.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
-H 'Accept: application/vnd.oci.image.index.v1+json' \
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
"https://${HOST}/v2/${NS}/$1/manifests/$2" \
| tr -d '\r' | awk -F': ' 'tolower($1)=="docker-content-digest"{print $2}' | tail -1
}
TOTAL_DEL=0
for img in "${IMAGES[@]}"; do
echo
echo "==> ${NS}/${img}"
# Digests git pins at REF. The marker convention puts the digest on the line after
# `# renovate-digest: image=<repo>`; the plain `<repo>@sha256:` grep catches any other use.
repo="${HOST}/${NS}/${img}"
pinned="$( { git -C "$REPO_ROOT" grep -h -A1 -F "renovate-digest: image=${repo} " "$REF" -- . 2>/dev/null || true
git -C "$REPO_ROOT" grep -h -F "${repo}@sha256:" "$REF" -- . 2>/dev/null || true; } \
| grep -oE 'sha256:[0-9a-f]{64}' | grep -v 'sha256:0\{64\}' | sort -u || true)"
echo " pinned at ${REF}: $(wc -w <<<"$pinned") digest(s)"
versions="[]"; page=1
while :; do
chunk="$(api "https://${HOST}/api/v1/packages/${OWNER}?type=container&q=${img}&limit=50&page=${page}")"
jq -e 'type=="array"' >/dev/null <<<"$chunk" || { echo " package list failed: $(head -c 200 <<<"$chunk")" >&2; exit 1; }
[ "$(jq length <<<"$chunk")" -gt 0 ] || break
versions="$(jq -c --argjson a "$versions" --arg n "$img" '$a + [ .[] | select(.name==$n) | {version, created_at} ]' <<<"$chunk")"
page=$((page + 1))
done
echo " versions: $(jq length <<<"$versions")"
keep_sha="$(jq -r '[ .[] | select(.version|startswith("sha-")) ] | sort_by(.created_at) | reverse | .[:'"$KEEP"'][] .version' <<<"$versions")"
while IFS= read -r v; do
[ -n "$v" ] || continue
case "$v" in
main|latest) echo " keep ${v} (moving tag)"; continue ;;
sha256:*) echo " keep ${v} (digest-named version; Gitea's cleanup owns those)"; continue ;;
esac
if grep -qxF "$v" <<<"$keep_sha"; then echo " keep ${v} (among the ${KEEP} newest)"; continue; fi
d="$(manifest_digest "$img" "$v")"
if [ -z "$d" ]; then echo " keep ${v} (could not read its digest — never delete what cannot be checked)"; continue; fi
if grep -qxF "$d" <<<"$pinned"; then echo " keep ${v} (${d} is pinned at ${REF})"; continue; fi
if [ "$APPLY" -eq 1 ]; then
code="$(api -o /dev/null -w '%{http_code}' -X DELETE \
"https://${HOST}/api/v1/packages/${OWNER}/container/${img}/$(jq -rn --arg v "$v" '$v|@uri')")"
if [ "$code" = 204 ]; then echo " DELETED ${v} (${d})"; TOTAL_DEL=$((TOTAL_DEL + 1));
else echo " FAILED ${v} HTTP ${code}" >&2; fi
else
echo " would delete ${v} (${d})"; TOTAL_DEL=$((TOTAL_DEL + 1))
fi
done < <(jq -r '.[].version' <<<"$versions")
done
echo
if [ "$APPLY" -eq 1 ]; then echo "deleted ${TOTAL_DEL} version(s). Blobs are reclaimed by Gitea's cleanup_packages cron."
else echo "DRY RUN: ${TOTAL_DEL} version(s) would be deleted. Re-run with --apply to delete them."; fi
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env bash
# =================================================================================================
# quiesce.sh — bring the prod cluster to a safe standstill before powering nodes off, and bring it
# back afterwards. Companion to runbooks/cluster-maintenance-shutdown.md.
# =================================================================================================
# WHY THIS EXISTS. On 2026-10-17 all three nodes had to be powered off to move them onto a UPS, and
# there was NO procedure for it: `infra/cluster.sh stop` is `virsh`-based and only ever applied to
# the decommissioned dev cluster. The whole sequence was improvised live. This script is that
# sequence, so the next person does not improvise it.
#
# WHY IT IS NOT JUST `shutdown -h`. Graceful node shutdown was unconfigured on every prod node that
# day, so systemd stopped containerd and SIGKILLed every pod where it stood. roles/k3s_server now
# sets shutdown-grace-period (+ the logind InhibitDelayMaxSec cap that actually enforces it), which
# makes a bare reboot safe. This script is still the right tool for a PLANNED whole-cluster
# shutdown, for three reasons the kubelet's drain does not cover:
# 1. The UniFi VM needs an ACPI guest shutdown, not an eviction.
# 2. CloudNativePG wants hibernation, not pod deletion -- and hibernation is IMPERATIVE state
# that Flux will not set or clear for you.
# 3. Draining three nodes one at a time makes pods RESCHEDULE onto the survivors, churning
# Longhorn replicas for no reason when the whole cluster is going down anyway. Scaling in
# place avoids all of it.
#
# SAFE TO RE-RUN. Every step is idempotent and `resume` is the exact inverse of `quiesce`.
#
# WHAT IT DELIBERATELY DOES NOT DO: power anything off. Node poweroff is an operator action
# (CLAUDE.md §5) and needs the become password; the runbook gives the ansible one-liner. It also
# does not touch TrueNAS (not in the inventory, SSH refused -- web UI only) or the router.
set -euo pipefail
CONTEXT="${CONTEXT:-k3s-prod}"
KUBECTL=(kubectl --context "$CONTEXT" --request-timeout=30s)
TIMEOUT="${TIMEOUT:-600}"
# The three CloudNativePG clusters, as namespace/name. Hibernation is per-Cluster.
CNPG=(keycloak/keycloak-db nextcloud-db/nextcloud-db semaphore-db/semaphore-db)
log() { printf '\033[34m[quiesce]\033[0m %s\n' "$*"; }
ok() { printf '\033[32m[ ok ]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[warn]\033[0m %s\n' "$*"; }
die() { printf '\033[31m[fail]\033[0m %s\n' "$*" >&2; exit 1; }
need() { command -v "$1" >/dev/null || die "$1 not on PATH — run this inside ./infra/devcontainer/dev.sh"; }
# ---------------------------------------------------------------- discovery
# Every Deployment/StatefulSet whose pods mount a PVC — Longhorn OR the TrueNAS NFS bulk tier.
# Computed from the LIVE cluster rather than hardcoded, so a newly onboarded service is covered
# without anyone remembering to add it here. PROTECTED namespaces are excluded because scaling
# them down would remove the very machinery that performs the detach (longhorn-system), keep the
# API reachable (kube-system), or finish the hibernation and the VM shutdown (cnpg-system,
# kubevirt).
pvc_workloads() {
"${KUBECTL[@]}" get pods -A -o json | python3 -c '
import json,sys
PROTECT={"longhorn-system","kube-system","cilium-secrets","cnpg-system","kubevirt","flux-system","cdi"}
out={}
for p in json.load(sys.stdin)["items"]:
ns=p["metadata"]["namespace"]
if ns in PROTECT: continue
if not any(v.get("persistentVolumeClaim") for v in (p["spec"].get("volumes") or [])): continue
ow=(p["metadata"].get("ownerReferences") or [])
if not ow: continue
k,n=ow[0]["kind"],ow[0]["name"]
if k=="ReplicaSet": k,n="deployment",n.rsplit("-",1)[0]
elif k=="StatefulSet": k="statefulset"
else: continue # Jobs, and CNPG Clusters, are handled separately
out[(ns,k,n)]=1
for ns,k,n in sorted(out): print(ns,k,n)
'
}
# ---------------------------------------------------------------- the PVC-deletion guard
# *** DO NOT REMOVE THIS CHECK. IT EXISTS BECAUSE SCALING A StatefulSet DESTROYED DATA. ***
# A StatefulSet may carry `persistentVolumeClaimRetentionPolicy.whenScaled: Delete`, and then
# scaling it to 0 DELETES ITS PVCs -- and with them the Longhorn volumes. MEASURED 2026-10-17: the
# quiesce for the UPS move scaled `monitoring/loki` (whenScaled: Delete) to 0 and its
# `storage-loki-0` PVC and volume were deleted outright. Loki stores with `type: filesystem`, so
# that PVC was the only copy; 72h retention bounded the loss to a rolling window, but nothing about
# the mechanism is bounded -- the same action on a Delete-policy StatefulSet holding real state
# would be unrecoverable. The volume count going 25 -> 24 was the only visible symptom, and it was
# first misread as a miscount.
#
# So: such a StatefulSet is NEVER scaled. It is left RUNNING and its volume hard-detaches at
# poweroff, which costs a replica rebuild on restart (degraded -> healthy, automatic). A rebuild is
# an enormously better outcome than a deletion, and that is the whole trade being made here.
sts_deletes_pvc_on_scale() { # ns name -> 0 if scaling it would delete its PVCs
local policy
policy=$("${KUBECTL[@]}" -n "$1" get statefulset "$2" \
-o jsonpath='{.spec.persistentVolumeClaimRetentionPolicy.whenScaled}' 2>/dev/null || true)
[ "$policy" = "Delete" ]
}
wait_for() { # wait_for <description> <shell-condition>
local desc="$1" cond="$2" waited=0
log "waiting: $desc (up to ${TIMEOUT}s)"
until eval "$cond"; do
(( waited += 10 )); (( waited >= TIMEOUT )) && { warn "TIMED OUT waiting: $desc"; return 1; }
sleep 10
done
ok "$desc"
}
# ---------------------------------------------------------------- quiesce
do_quiesce() {
need kubectl; need python3
# Refuse to start if Longhorn is already unhealthy. Powering off on top of a degraded volume is
# how one replica becomes zero; better to stop here and let it rebuild first.
local unhealthy
unhealthy=$("${KUBECTL[@]}" -n longhorn-system get volumes.longhorn.io \
-o jsonpath='{range .items[*]}{.status.robustness}{"\n"}{end}' | grep -cvE '^(healthy|unknown)$' || true)
[ "$unhealthy" = "0" ] || die "$unhealthy Longhorn volume(s) are not healthy — fix or wait for rebuild BEFORE powering off"
ok "Longhorn: all volumes healthy"
# 1. Flux first, or it reverts every scale-down below within its reconcile interval.
log "suspending Flux Kustomizations"
local k
for k in $("${KUBECTL[@]}" -n flux-system get kustomizations -o name); do
"${KUBECTL[@]}" -n flux-system patch "${k}" --type=merge -p '{"spec":{"suspend":true}}' >/dev/null
done
ok "all Kustomizations suspended"
# 2. The VM needs an ACPI shutdown of the GUEST. KubeVirt does that on running:false; a plain
# pod eviction would be a power cut to the VM.
if "${KUBECTL[@]}" -n unifi get vm unifi >/dev/null 2>&1; then
log "ACPI-stopping the UniFi VM"
"${KUBECTL[@]}" -n unifi patch vm unifi --type=merge -p '{"spec":{"running":false}}' >/dev/null
fi
# 3. Postgres: hibernation is a clean smart shutdown that KEEPS the PVCs. CNPG does one instance
# at a time, primary last-ish, so this is not instant.
log "hibernating CloudNativePG clusters"
local c ns name
for c in "${CNPG[@]}"; do
ns="${c%%/*}"; name="${c##*/}"
"${KUBECTL[@]}" -n "$ns" annotate cluster.postgresql.cnpg.io "$name" \
cnpg.io/hibernation=on --overwrite >/dev/null 2>&1 || warn "could not hibernate $c"
done
# 4. Everything holding a PVC, scaled in place. NFS consumers first so nothing holds a hard
# TrueNAS mount; PiHole last because it is the LAN resolver.
log "scaling down PVC-holding workloads"
local line ns2 kind nm deferred=() skipped_delete_policy=()
while read -r ns2 kind nm; do
[ -z "${ns2:-}" ] && continue
if [ "$kind" = "statefulset" ] && sts_deletes_pvc_on_scale "$ns2" "$nm"; then
warn "SKIPPING $ns2/$nm — whenScaled: Delete would DELETE its PVC (see the guard above)."
warn " Left running; its volume hard-detaches at poweroff and rebuilds on restart."
skipped_delete_policy+=("$ns2/$nm")
continue
fi
if [ "$ns2" = "pihole" ]; then deferred+=("$ns2 $kind $nm"); continue; fi
"${KUBECTL[@]}" -n "$ns2" scale "$kind" "$nm" --replicas=0 >/dev/null 2>&1 \
&& printf ' scaled 0 %s/%s %s\n' "$ns2" "$kind" "$nm" \
|| warn "could not scale $ns2/$kind/$nm"
done < <(pvc_workloads)
for line in "${deferred[@]:-}"; do
[ -z "${line:-}" ] && continue
# shellcheck disable=SC2086
set -- $line
"${KUBECTL[@]}" -n "$1" scale "$2" "$3" --replicas=0 >/dev/null 2>&1 \
&& printf ' scaled 0 %s/%s %s (LAN DNS — expect name resolution to stop)\n' "$1" "$2" "$3"
done
# 5. The only assertion that matters. An attached volume at poweroff comes back degraded and
# rebuilds; this is what we are buying with all of the above.
wait_for "UniFi VMI gone" \
'[ "$('"${KUBECTL[*]}"' -n unifi get vmi --no-headers 2>/dev/null | wc -l)" = "0" ]' || true
wait_for "all Postgres instances stopped" \
'[ "$('"${KUBECTL[*]}"' get pods -A --no-headers 2>/dev/null | grep -cE "(keycloak|nextcloud|semaphore)-db-[0-9]")" = "0" ]' || true
wait_for "all Longhorn volumes detached" \
'[ "$('"${KUBECTL[*]}"' -n longhorn-system get volumes.longhorn.io -o jsonpath="{range .items[*]}{.status.state}{\"\n\"}{end}" 2>/dev/null | grep -c attached)" = "0" ]' || \
warn "some volumes are STILL ATTACHED — they will come back degraded and rebuild; acceptable, but note which"
if [ "${#skipped_delete_policy[@]}" -gt 0 ]; then
echo
warn "NOT scaled down (whenScaled: Delete — scaling them would delete their PVCs):"
printf ' %s\n' "${skipped_delete_policy[@]}"
warn "Expect these volumes to come back 'degraded' and rebuild. That is correct and automatic."
fi
echo
ok "QUIESCED. Safe to power off."
cat <<'NEXT'
Next, from inside the devcontainer (it holds the become password):
cd /work/infra/ansible && ansible -i inventory/hosts.ini k3s_prod -b \
-m shell -a 'shutdown -h +1 "maintenance"'
THEN, in this order and not before:
1. TrueNAS (192.168.2.30) — web UI only; not in the ansible inventory and SSH is refused.
2. Router / switch last.
NEXT
}
# ---------------------------------------------------------------- resume
do_resume() {
need kubectl
# THE STEP THAT IS ALWAYS FORGOTTEN. Hibernation is imperative state: Flux neither sets nor
# clears it, so resuming Flux alone leaves all three databases down and every app that needs
# one looking broken for reasons that point nowhere near Postgres.
log "un-hibernating CloudNativePG clusters"
local c ns name
for c in "${CNPG[@]}"; do
ns="${c%%/*}"; name="${c##*/}"
"${KUBECTL[@]}" -n "$ns" annotate cluster.postgresql.cnpg.io "$name" \
cnpg.io/hibernation=off --overwrite >/dev/null 2>&1 || warn "could not un-hibernate $c"
done
# Flux restores every replica count AND the VM's running:true from git, so nothing here needs to
# undo the scale-downs by hand.
log "resuming Flux Kustomizations"
local k
for k in $("${KUBECTL[@]}" -n flux-system get kustomizations -o name); do
"${KUBECTL[@]}" -n flux-system patch "${k}" --type=merge -p '{"spec":{"suspend":false}}' >/dev/null
done
ok "all Kustomizations resumed"
wait_for "Flux fully reconciled" \
'[ "$('"${KUBECTL[*]}"' -n flux-system get kustomizations --no-headers 2>/dev/null | grep -vc True)" = "0" ]' || \
warn "some Kustomizations are not Ready — the graph serialises behind platform-config, so give it a few minutes before worrying"
echo
ok "RESUMED."
cat <<'NEXT'
Known cold-start race, not a fault in this script: oauth2-proxy performs OIDC discovery at
startup and exits 1 on the 503 it gets while Keycloak is still coming up, then sits in
exponential CrashLoopBackOff. If a replica is stuck, delete the pod once:
kubectl --context k3s-prod -n oauth2-proxy delete pod <name>
NEXT
}
do_status() {
need kubectl
printf 'nodes: %s Ready\n' "$("${KUBECTL[@]}" get nodes --no-headers | grep -c ' Ready')"
printf 'flux: %s/%s Ready\n' \
"$("${KUBECTL[@]}" -n flux-system get kustomizations --no-headers | grep -c True)" \
"$("${KUBECTL[@]}" -n flux-system get kustomizations --no-headers | wc -l)"
printf 'longhorn: %s attached, %s detached, %s not healthy\n' \
"$("${KUBECTL[@]}" -n longhorn-system get volumes.longhorn.io -o jsonpath='{range .items[*]}{.status.state}{"\n"}{end}' | grep -c attached)" \
"$("${KUBECTL[@]}" -n longhorn-system get volumes.longhorn.io -o jsonpath='{range .items[*]}{.status.state}{"\n"}{end}' | grep -c detached)" \
"$("${KUBECTL[@]}" -n longhorn-system get volumes.longhorn.io -o jsonpath='{range .items[*]}{.status.robustness}{"\n"}{end}' | grep -cvE '^(healthy|unknown)$' || true)"
printf 'postgres: %s instance pod(s)\n' \
"$("${KUBECTL[@]}" get pods -A --no-headers | grep -cE '(keycloak|nextcloud|semaphore)-db-[0-9]' || true)"
printf 'unifi VM: %s VMI\n' "$("${KUBECTL[@]}" -n unifi get vmi --no-headers 2>/dev/null | wc -l)"
}
case "${1:-}" in
quiesce) do_quiesce ;;
resume) do_resume ;;
status) do_status ;;
*) cat >&2 <<USAGE
usage: $0 {quiesce|resume|status}
quiesce suspend Flux, ACPI-stop the UniFi VM, hibernate Postgres, scale down every
PVC-holding workload, and wait for all Longhorn volumes to detach.
Does NOT power anything off — see runbooks/cluster-maintenance-shutdown.md.
resume the exact inverse: un-hibernate Postgres, resume Flux, wait for reconcile.
status one-screen readout of nodes / Flux / Longhorn / Postgres / VM.
Run it inside the pinned toolbox, which supplies kubectl and the context:
./infra/devcontainer/dev.sh run bash -c './infra/maintenance/quiesce.sh status'
USAGE
exit 2 ;;
esac
+85 -30
View File
@@ -2,10 +2,24 @@
# =================================================================================================
# new-peer.sh — enrol one device on the remote-access VPN (platform/vpn-ingress).
#
# Generates a WireGuard keypair ON THIS MACHINE, renders the device's client profile, optionally
# prints a QR code for a phone, and prints the one `[Peer]` stanza to paste into
# platform/vpn-ingress/peers.yaml. The private half NEVER leaves this machine: it is not sent to the
# cluster, not committed, and not written to any log.
# Generates a WireGuard keypair, renders the device's client profile, prints a QR code for a phone,
# and prints the one `[Peer]` stanza to paste into platform/vpn-ingress/peers.conf. The device's
# private half is written to ONE 0600 file and nowhere else: not sent to the cluster, not committed,
# never logged.
#
# RUN IT IN THE DEV CONTAINER — that is where the toolchain lives (CLAUDE.md §5):
#
# ./infra/devcontainer/dev.sh run bash -c './infra/vpn-ingress/new-peer.sh pixel-8 2'
#
# It needs `wg` (wireguard-tools), `qrencode` and `sops`, all three baked into the image. It will
# run on a host that happens to have them too, but the container is the supported way and the only
# one that is reproducible.
#
# THE SERVER'S PUBLIC KEY IS DERIVED FROM THE ENCRYPTED FILE, not from a copy you keep lying around:
# `sops -d` the private half out of platform/vpn-ingress/wg-server-key.sops.yaml and pipe it
# straight into `wg pubkey`. The plaintext never touches disk and never reaches a terminal. (This
# is why both tools being in ONE image matters — see the Containerfile comment.) Override with
# WG_SERVER_PUBKEY or WG_SERVER_KEY_FILE if you are working off-tree.
#
# WHY A SCRIPT AND NOT A RUNBOOK STEP. Three of these fields are easy to get wrong in ways that are
# either silently insecure or silently broken, and all three are mechanical:
@@ -25,24 +39,30 @@
# <last-octet> 2-254, the device's address inside WG_CLIENT_CIDR. Must be unused.
# --split split tunnel: route ONLY the LAN and the tunnel subnet. Default is a FULL
# tunnel (0.0.0.0/0) so the device also egresses the house WAN.
# --out DIR where to write the profile (default: a 0700 dir under $TMPDIR).
# --out DIR where to write the profile. Default: `.cache/wg-peers/` in the repo, which is
# GITIGNORED (`.gitignore` `.cache/`) and, unlike /tmp inside the container,
# survives the container exiting — the repo is its only persistent read-write
# mount, so a profile written anywhere else is gone before you can move it to a
# desktop. The script refuses any destination git does not ignore.
#
# ENVIRONMENT
# WG_SERVER_KEY_FILE server PRIVATE key (default ~/.config/wireguard-home/server.key); its public
# half is derived from it. Alternatively set WG_SERVER_PUBKEY directly.
# WG_SERVER_PUBKEY the server's public key, if you want to skip the SOPS decrypt entirely.
# WG_SERVER_KEY_FILE a local copy of the server PRIVATE key to derive the public half from,
# instead of reading the SOPS file.
# WG_SERVER_SOPS_FILE default platform/vpn-ingress/wg-server-key.sops.yaml
# WG_ENDPOINT_HOST default wan.home.keithjackson.dev
# WG_CLIENT_CIDR default 10.90.0.0/24 (must match clusters/base/cluster-vars.yaml)
# WG_LAN_CIDR default 192.168.2.0/24 (prod LAN_CIDR)
# WG_DNS default 192.168.2.23 (prod PIHOLE_DNS_VIP)
#
# REQUIRES: wireguard-tools (`wg`). `qrencode` is optional and only used for the QR code.
# REQUIRES: `wg`, `qrencode`, `sops` — all three in the dev container.
# =================================================================================================
set -euo pipefail
die() { printf 'new-peer.sh: %s\n' "$*" >&2; exit 1; }
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
PEERS_FILE="${REPO_ROOT}/platform/vpn-ingress/peers.yaml"
PEERS_FILE="${REPO_ROOT}/platform/vpn-ingress/peers.conf"
# --- defaults, each mirroring a value that is DECLARED elsewhere in this repo -------------------
# These are duplicated here rather than parsed out of the YAML on purpose: this script is meant to
@@ -51,7 +71,7 @@ PEERS_FILE="${REPO_ROOT}/platform/vpn-ingress/peers.yaml"
: "${WG_CLIENT_CIDR:=10.90.0.0/24}" # clusters/base/cluster-vars.yaml
: "${WG_LAN_CIDR:=192.168.2.0/24}" # clusters/prod LAN_CIDR
: "${WG_DNS:=192.168.2.23}" # clusters/prod PIHOLE_DNS_VIP
: "${WG_SERVER_KEY_FILE:=${HOME}/.config/wireguard-home/server.key}"
: "${WG_SERVER_SOPS_FILE:=${REPO_ROOT}/platform/vpn-ingress/wg-server-key.sops.yaml}"
# wan.<base-domain> is an UNPROXIED A record that platform/externaldns/ddns.yaml keeps pointed at
# the current WAN IP (checked every 5 min). Use it, never a literal address: this house has a
# DYNAMIC WAN IP, and a profile pinned to today's address stops working without warning and with no
@@ -83,14 +103,28 @@ printf '%s' "$NAME" | grep -Eq '^[a-z0-9][a-z0-9-]{0,30}$' \
printf '%s' "$OCTET" | grep -Eq '^[0-9]+$' || die "octet must be a number: got '$OCTET'"
[ "$OCTET" -ge 2 ] && [ "$OCTET" -le 254 ] || die "octet must be 2-254 (.1 is the server): got '$OCTET'"
command -v wg >/dev/null || die "wireguard-tools not installed ('wg' not on PATH). Debian/Ubuntu: apt install wireguard-tools"
command -v wg >/dev/null || die "'wg' not on PATH. Run this inside the dev container:
./infra/devcontainer/dev.sh run bash -c './infra/vpn-ingress/new-peer.sh $NAME $OCTET'"
# --- the server's public key --------------------------------------------------------------------
# Order: an explicit pubkey, then a local private-key file, then the SOPS file in the tree. The
# last is the normal path and needs no local copy of anything.
if [ -z "${WG_SERVER_PUBKEY:-}" ]; then
[ -r "$WG_SERVER_KEY_FILE" ] || die "cannot read server key '$WG_SERVER_KEY_FILE'.
Either point WG_SERVER_KEY_FILE at it, or set WG_SERVER_PUBKEY to the public half.
If the server key does not exist yet, see runbooks/vpn-remote-access.md step 1."
WG_SERVER_PUBKEY="$(tr -d ' \r\n' < "$WG_SERVER_KEY_FILE" | wg pubkey)"
if [ -n "${WG_SERVER_KEY_FILE:-}" ]; then
[ -r "$WG_SERVER_KEY_FILE" ] || die "cannot read WG_SERVER_KEY_FILE '$WG_SERVER_KEY_FILE'"
WG_SERVER_PUBKEY="$(tr -d ' \r\n' < "$WG_SERVER_KEY_FILE" | wg pubkey)"
else
command -v sops >/dev/null || die "'sops' not on PATH. Run this inside the dev container (see --help)."
[ -r "$WG_SERVER_SOPS_FILE" ] || die "cannot read '$WG_SERVER_SOPS_FILE'"
# The decrypted private key goes straight into `wg pubkey` — never to a file, never to a tty.
# `wg pubkey` fails on the committed REPLACE-ME placeholder, which is the useful error here.
WG_SERVER_PUBKEY="$(sops -d --extract '["stringData"]["privatekey"]' "$WG_SERVER_SOPS_FILE" \
| tr -d ' \r\n' | wg pubkey 2>/dev/null)" || WG_SERVER_PUBKEY=""
[ -n "$WG_SERVER_PUBKEY" ] || die "could not derive the server public key from
$WG_SERVER_SOPS_FILE
Either it still holds the REPLACE-ME placeholder (see runbooks/vpn-remote-access.md step 2),
or this shell cannot decrypt it (the age keyring is mounted at ~/.config/sops in the container)."
fi
fi
CLIENT_BASE="${WG_CLIENT_CIDR%/*}"; CLIENT_BASE="${CLIENT_BASE%.*}"
@@ -99,22 +133,41 @@ CLIENT_ADDR="${CLIENT_BASE}.${OCTET}"
# --- refuse a collision, because the failure is otherwise invisible -----------------------------
# Two devices sharing an address does not error anywhere: the server routes that address to
# whichever peer handshook most recently, so BOTH devices appear to work intermittently.
if [ -r "$PEERS_FILE" ] && grep -q "AllowedIPs *= *${CLIENT_ADDR}/32" "$PEERS_FILE"; then
die "${CLIENT_ADDR}/32 is already allocated in platform/vpn-ingress/peers.yaml. Pick another octet."
# COMMENT LINES ARE NOT ALLOCATIONS. This used to grep the whole file and so matched the
# COMMENTED-OUT EXAMPLE STANZA in the register, which made `.2` — the first address anyone would
# ask for — permanently un-allocatable with a message claiming it was already taken. Strip
# comments first: a real stanza's `AllowedIPs` line is never commented, while both the template
# and every peer's `# <name> — <owner>` label are.
# FAIL if the register is missing rather than skipping the check. The path was silently wrong once
# (it still said peers.yaml after the file became peers.conf), and because the old guard was
# `[ -r "$PEERS_FILE" ] && ...` the only symptom would have been duplicate detection quietly
# never firing again — the exact silent-skip shape this repo treats as a defect.
[ -r "$PEERS_FILE" ] || die "cannot read the peer register '$PEERS_FILE'.
Duplicate-address detection cannot run, so this script refuses to issue a profile."
if grep -vE '^[[:space:]]*#' "$PEERS_FILE" | grep -q "AllowedIPs *= *${CLIENT_ADDR}/32"; then
die "${CLIENT_ADDR}/32 is already allocated in platform/vpn-ingress/peers.conf. Pick another octet."
fi
if [ -r "$PEERS_FILE" ] && grep -qi "^ *# *${NAME} " "$PEERS_FILE"; then
printf 'new-peer.sh: WARNING a peer comment already mentions "%s" in peers.yaml.\n' "$NAME" >&2
if grep -qi "^ *# *${NAME} " "$PEERS_FILE"; then
printf 'new-peer.sh: WARNING a peer comment already mentions "%s" in peers.conf.\n' "$NAME" >&2
fi
# --- output location: 0700, and never inside the repo -------------------------------------------
if [ -z "$OUT_DIR" ]; then
OUT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/wg-peer-XXXXXX")"
else
mkdir -p "$OUT_DIR"
fi
[ -n "$OUT_DIR" ] || OUT_DIR="${REPO_ROOT}/.cache/wg-peers"
mkdir -p "$OUT_DIR"
chmod 700 "$OUT_DIR"
case "$(cd "$OUT_DIR" && pwd)" in
"$REPO_ROOT"|"$REPO_ROOT"/*) die "refusing to write a private key inside the git checkout ($OUT_DIR)" ;;
OUT_ABS="$(cd "$OUT_DIR" && pwd)"
# A profile holds a private key, so the destination must not be committable. Inside the repo that
# means git must IGNORE it — test the real property rather than a proxy, because the repo is the
# container's only persistent read-write mount and so the only sane place to write. Outside the
# repo, anywhere goes.
case "$OUT_ABS" in
"$REPO_ROOT"|"$REPO_ROOT"/*)
if ! git -C "$REPO_ROOT" check-ignore -q "$OUT_ABS/probe.conf" 2>/dev/null; then
die "refusing to write a private key to '$OUT_ABS': it is inside the git checkout and NOT
gitignored, so the profile would be committable. Use the default (.cache/wg-peers) or add an
ignore rule first. Verify with: git check-ignore -v <path>"
fi
;;
esac
PROFILE="${OUT_DIR}/${NAME}.conf"
@@ -162,7 +215,7 @@ cat <<EOF
Profile : ${PROFILE} (0600; contains the private key)
------------------------------------------------------------------------------------------
STEP 1 — add this to platform/vpn-ingress/peers.yaml, under data.peers.conf, then PR it:
STEP 1 — append this to platform/vpn-ingress/peers.conf, then PR it:
# ${NAME} — <owner>, enrolled $(date -u +%Y-%m-%d)
[Peer]
@@ -181,7 +234,7 @@ cat <<EOF
STEP 3 — delete the profile from this machine once the device has it:
shred -u ${PROFILE} 2>/dev/null || rm -f ${PROFILE}
The tunnel will not work until the peers.yaml PR is MERGED and Flux has rolled the DaemonSet
The tunnel will not work until the peers.conf PR is MERGED and Flux has rolled the DaemonSet
(~1 min). Until then the device handshakes and gets silence, exactly like an unknown key.
------------------------------------------------------------------------------------------
EOF
@@ -190,6 +243,8 @@ if command -v qrencode >/dev/null; then
printf ' QR code (this IS the private key — your terminal scrollback now holds a credential):\n\n'
qrencode -t ansiutf8 < "$PROFILE"
else
printf ' qrencode not installed, so no QR code was printed (Debian/Ubuntu: apt install qrencode).\n'
printf ' Transfer %s to the device by another route instead.\n' "$PROFILE"
# Should not happen in the dev container: qrencode is baked into the image. Kept as a real
# fallback for a host run, rather than failing after the profile has already been written.
printf ' qrencode not on PATH, so no QR code was printed. It is in the dev container —\n'
printf ' rerun there, or transfer %s to the device by another route.\n' "$PROFILE"
fi
+24
View File
@@ -127,6 +127,30 @@ resources:
# so a ${BASE_DOMAIN} token in either resolves to the same value and the two declarations
# cannot disagree about the domain.
- ../platform/keycloak/client-urls.yaml
# PASSKEYS — the same one-shot failure, for the WebAuthn passwordless policy and the
# browser login flow. Measured on prod 2026-10-18, hours after the passkey declaration
# merged: the KeycloakRealmImport read Done=True / Started=False with the whole
# webAuthnPolicyPasswordless* block and the restructured browser-mfa flow INERT. This
# CronJob converges the policy and the enrolment required action, and builds the
# declared browser flow BLUE/GREEN: under a new alias, verified by readback, and
# structurally unable to write the flow that is currently bound (a half-rewritten bound
# flow is a total SSO outage, and this repo has had one). It BINDS the verified flow
# (one atomic PUT of browserFlow) ONLY when passkey-desired-state.yaml sets
# bind-browser-flow: "true"; the default "false" leaves the realm's binding untouched.
# See the script header in that file for the full rationale.
- ../platform/keycloak/passkey-reconciler.yaml
# Its DESIRED STATE, GENERATED from keycloak-realm-import.yaml by
# ci/render-keycloak-passkey-policy.py and gated by the L0 check. Same reason as
# client-urls.yaml above: the reconciler pod has no Kubernetes API access
# (automountServiceAccountToken: false), so it cannot read the KeycloakRealmImport CR
# and its input must be MOUNTED.
#
# IT MUST BE RENDERED BY THIS SAME KUSTOMIZATION as the realm import above, for exactly
# the reason given for client-urls.yaml: webAuthnPolicyPasswordlessRpId is the
# ${BASE_DOMAIN} token, and a split across Kustomizations would let one token resolve to
# two different values. An RP ID is baked into every passkey at enrolment and can never
# be re-keyed, so a domain the two files disagree about is an unrecoverable credential.
- ../platform/keycloak/passkey-desired-state.yaml
- ../platform/keycloak/k8s-oidc-rbac.yaml # maps OIDC groups -> cluster roles (kubectl SSO RBAC)
- ../platform/keycloak/httproute.yaml
- ../platform/keycloak/servicemonitor.yaml
+74
View File
@@ -0,0 +1,74 @@
# platform/buildkit — rootless BuildKit, the CI image builder
The builder behind [`.gitea/workflows/images.yaml`](../../.gitea/workflows/images.yaml). It
builds `toolbox`, `agent-runner` and `conformance-runner`, and on `main` pushes them to the
Gitea registry. Flux reconciles it as the Kustomization `buildkit`
([`clusters/base/buildkit.yaml`](../../clusters/base/buildkit.yaml), `dependsOn: platform`).
## Why it exists (operator decision on PR #81, 2026-10-29)
CI jobs run as containers on the act runner's privileged DinD sidecar, and **cannot reach any
Docker daemon**. act_runner 0.2.11 forces `docker_host: "-"` for a `tcp://` DinD, and gitea/act
strips every bind mount when `valid_volumes` is empty. Measured on PR #81's first run. Giving jobs
that daemon would make every branch push root on the node. So the rule is: **no job may reach a
privileged daemon.** Builds go to this rootless daemon over mTLS instead.
## Shape
| Piece | What it is |
|---|---|
| `deployment.yaml` | `moby/buildkit:v0.34.1-rootless`, pinned by digest. Not privileged: it runs as uid 1000, which rootlesskit maps to uid 0 inside a user namespace only. The `securityContext` is commented line by line with how each item was measured |
| `certificates.yaml` | cert-manager: a self-signed CA used only here, plus the server cert `buildkitd-tls` and the CI client cert `buildkit-client-ci` |
| `service.yaml` | ClusterIP `buildkitd.buildkit.svc.cluster.local:1234`, mTLS only. Never on the Gateway |
| `networkpolicy.yaml` | Default-deny, plus two narrow allows. **Ingress** is only from the act-runner pod: a job's traffic is masqueraded to that pod's IP by DinD (measured), so that pod is the identity Cilium sees. **Egress** is DNS, world :443/:80 for downloads, and, for the registry push, the Gateway (`ingress` entity) **plus** Gitea's backend identity on :3000 — Cilium's Gateway re-checks this pod's egress against the backend it proxies to, and without that rule answers `403 Access denied` itself (measured 2026-10-30, `decisions/2026-10-30-gitea-admit-buildkit.md`) |
| `configmap.yaml` | `buildkitd.toml`: cache GC capped at 4 GB, because the cache is an `emptyDir` on node disk |
| `versions.env` | The CI side: pinned `buildx` (with its sha256) and the address and certificate name CI dials. Not a Kubernetes resource |
## Admission
**No Kyverno exception is needed.**
- Every ClusterPolicy that is actually applied (the list in
[`platform-config/kustomization.yaml`](../../platform-config/kustomization.yaml)) matches only
namespaces labelled `homelab.keithjackson.dev/app-namespace: "true"`. This namespace, like
`gitea-actions`, does not carry that label.
- The job-shape policies (`pm-agent-job-shape`, `conformance-trigger-job-shape`) match only their
own namespaces.
- There is no cluster-wide Pod Security default. This namespace is deliberately left without a
`pod-security` label, because PSS `baseline` forbids the `Unconfined` seccomp and AppArmor
profiles that rootless BuildKit needs.
## Prerequisites (operator, in order)
1. **Unprivileged user namespaces on the nodes.** Already true. Measured 2026-10-29 on all three
Debian 13.7 nodes (kernel 7.1.8): `kernel.unprivileged_userns_clone = 1`,
`user.max_user_namespaces = 127206`, and no Ubuntu-style AppArmor userns restriction.
2. **Merge.** Flux applies this directory. Check that the Kustomization `buildkit` is Ready and
that all three Certificates in namespace `buildkit` are READY.
3. **Copy the client certificate to CI.** Run
[`infra/gitea/buildkit-client-secret.sh`](../../infra/gitea/buildkit-client-secret.sh).
Re-run it yearly, after renewal; `--check` reports whether the copy is stale.
4. **Restart buildkitd after the server certificate renews.** It reads the certificate at start,
and cert-manager renews it 30 days before expiry, so a restart inside that window is needed.
Until step 3 is done, the `images` workflow fails at its builder preflight with a message naming
the cause. It never skips.
## What a build step can do
The daemon runs with `--oci-worker-no-process-sandbox`, so build steps share its PID namespace.
buildkitd warns about this itself at startup.
- **A build step CAN** signal or ptrace the daemon and read what it can read, including this
pod's TLS key and the build cache.
- **A build step CANNOT** reach the node. Everything runs as uid 1000 in a user namespace,
unprivileged, with no host mounts.
- **Who can run one:** anyone holding the client certificate, i.e. every CI job, including pull
requests.
## Local equivalent
The pinned image runs under rootless podman with the same restrictions:
`--cap-drop ALL --cap-add SETUID --cap-add SETGID --security-opt seccomp=unconfined --read-only`,
plus tmpfs at the same five paths. That is how the `securityContext` was measured. Rootless podman
maps only 65,537 uids, so the image's `/etc/subuid` (`user:100000:65536`) must be overridden with
two ranges that fit: `user:1:999` and `user:1001:64535`.
+115
View File
@@ -0,0 +1,115 @@
---
# mTLS for buildkitd, issued by cert-manager from a CA that exists ONLY for this purpose.
#
# buildkit-selfsigned (Issuer) --signs--> buildkit-ca (CA Certificate, 10y)
# buildkit-ca (Issuer) --signs--> buildkitd-tls (server, the daemon presents it)
# --signs--> buildkit-client-ci (client, CI presents it)
#
# buildkitd verifies EVERY client certificate against this CA (--tlscacert), so holding a client
# certificate signed by it is what "may build" means. Nothing else in the cluster trusts this CA,
# and it trusts nothing else.
#
# THE CLIENT CERTIFICATE REACHES CI AS ACTIONS SECRETS, not through the cluster: CI jobs hold no
# cluster credential (R-SEC-02). infra/gitea/buildkit-client-secret.sh copies ca.crt/tls.crt/
# tls.key from the Secret below into the repository secrets BUILDKIT_CA, BUILDKIT_CLIENT_CERT and
# BUILDKIT_CLIENT_KEY without printing them. Re-run it after every renewal (see renewBefore).
#
# WHY A PRIVATE CA AND NOT LET'S ENCRYPT. The platform's TLS rule (R-TLS-01/02, docs/08,
# ADR-0006: ACME DNS-01) is about EXTERNALLY REACHABLE services; ADR-0006 itself says "mTLS between
# workloads is a separate concern … not covered here", and a public CA cannot issue for a
# `*.svc.cluster.local` name or a client-auth certificate anyway. buildkitd is ClusterIP-only. Each
# Certificate below is therefore annotated `homelab.keithjackson.dev/tls-scope: workload-mtls`,
# which the L0 check tls-lifecycle-automated accepts ONLY if the certificate proves it: no
# Gateway serves its Secret, every name is cluster-internal, and the issuer is a namespaced
# selfSigned/CA Issuer whose CA is itself issued here. Issuance and renewal stay unattended.
#
# ROTATION. cert-manager renews both leaves `renewBefore` their expiry and rewrites the Secrets.
# * server: buildkitd reads its certificate at START, so the pod must restart inside the 30-day
# renewal window (a node reboot by kured, a Deployment rollout, or a manual restart).
# * client: the Actions secrets are a COPY; re-run the script. Until then CI fails loudly at
# its TLS preflight (.gitea/workflows/scripts/images.sh), never silently.
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: buildkit-selfsigned
namespace: buildkit
spec:
selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: buildkit-ca
namespace: buildkit
annotations:
homelab.keithjackson.dev/tls-scope: workload-mtls # see the header: R-TLS-01 is about external TLS
spec:
isCA: true
commonName: buildkit-ca
secretName: buildkit-ca
duration: 87600h # 10 years: rotating the CA means re-issuing everything and re-copying
privateKey:
algorithm: ECDSA
size: 256
issuerRef:
name: buildkit-selfsigned
kind: Issuer
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: buildkit-ca
namespace: buildkit
spec:
ca:
secretName: buildkit-ca
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: buildkitd-tls
namespace: buildkit
annotations:
homelab.keithjackson.dev/tls-scope: workload-mtls
spec:
secretName: buildkitd-tls
commonName: buildkitd.buildkit.svc.cluster.local
dnsNames:
- buildkitd.buildkit.svc.cluster.local # what CI dials (platform/buildkit/versions.env)
- buildkitd.buildkit.svc
- buildkitd
duration: 8760h
renewBefore: 720h
usages:
- server auth
- digital signature
privateKey:
algorithm: ECDSA
size: 256
rotationPolicy: Always
issuerRef:
name: buildkit-ca
kind: Issuer
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: buildkit-client-ci
namespace: buildkit
annotations:
homelab.keithjackson.dev/tls-scope: workload-mtls
spec:
secretName: buildkit-client-ci
commonName: gitea-actions-image-pipeline
duration: 8760h
renewBefore: 720h
usages:
- client auth
- digital signature
privateKey:
algorithm: ECDSA
size: 256
rotationPolicy: Always
issuerRef:
name: buildkit-ca
kind: Issuer
+24
View File
@@ -0,0 +1,24 @@
---
# buildkitd.toml. Validated against the pinned image (v0.34.1-rootless): `buildctl debug workers
# -v` reports "Maximum used space: 4.29GB / Reserved space: 1.07GB" for the default policy.
#
# WHY 4GB. The cache lives in an emptyDir on the NODE's disk (deployment.yaml), and the nodes had
# 7-9 GB free when this was written. The largest image (toolbox) is ~1.4 GB uncompressed; the
# three images share their Fedora base. The emptyDir's sizeLimit sits above this cap, so GC
# trims the cache well before the kubelet would evict the pod for exceeding it.
apiVersion: v1
kind: ConfigMap
metadata:
name: buildkitd-config
namespace: buildkit
labels:
app.kubernetes.io/name: buildkitd
data:
buildkitd.toml: |
[worker.oci]
enabled = true
gc = true
maxUsedSpace = "4GB"
reservedSpace = "1GB"
[worker.containerd]
enabled = false
+142
View File
@@ -0,0 +1,142 @@
---
# buildkitd, ROOTLESS. The builder behind .gitea/workflows/images.yaml; see README.md.
#
# THE SECURITY CONTEXT IS THE BOUNDARY, AND EVERY LINE OF IT WAS MEASURED (2026-10-29, the pinned
# image under podman with the same restrictions; decisions/2026-10-29-image-pipeline.md):
# * privileged: false — never. This is the whole point of the operator's decision on PR #81:
# the alternative was handing CI the privileged DinD daemon, i.e. root on the node.
# * runAsUser/Group 1000 — the image's `user`; rootlesskit maps it to uid 0 INSIDE a user
# namespace only. The nodes allow that (measured: kernel.unprivileged_userns_clone = 1,
# user.max_user_namespaces = 127206 on all three Debian 13 nodes).
# * capabilities: drop ALL, add SETUID + SETGID — the image's newuidmap/newgidmap are NOT
# setuid; they carry FILE capabilities, which need SETUID/SETGID in the bounding set.
# Measured: with drop ALL and nothing added, rootlesskit fails "newuidmap ... operation not
# permitted" and the daemon never starts.
# * allowPrivilegeEscalation: true — required for the same reason: no_new_privs (what `false`
# sets) stops a file capability from ever taking effect. It does NOT grant privileges the
# bounding set above does not contain.
# * seccompProfile: Unconfined — measured: with the default seccomp profile the daemon starts
# but every RUN step fails (the runtime cannot create the build step's namespaces/mounts).
# * appArmorProfile: Unconfined — upstream's documented requirement (buildkit
# examples/kubernetes, *.rootless.yaml); the nodes run AppArmor (enabled=Y) and containerd's
# default profile denies mount. NOT measured here: the test host runs SELinux, not AppArmor.
# * readOnlyRootFilesystem: true — measured to work with the five emptyDirs below.
#
# --oci-worker-no-process-sandbox: build steps share the daemon's PID namespace instead of getting
# their own (creating one needs an unmasked /proc, i.e. more privilege). Consequence, stated by
# buildkitd itself at startup: a build step can signal or ptrace the daemon and read what the
# daemon can read — including this pod's TLS key and the build cache. So a build step is trusted
# at the level of "may impersonate this builder", which is the level anyone holding the client
# certificate already has. It can NOT reach the node: everything still runs as uid 1000 in a user
# namespace, unprivileged, with no host mounts.
apiVersion: apps/v1
kind: Deployment
metadata:
name: buildkitd
namespace: buildkit
labels:
app.kubernetes.io/name: buildkitd
spec:
replicas: 1
strategy:
type: Recreate # one builder, one cache; never two daemons fighting over a node's disk
selector:
matchLabels:
app.kubernetes.io/name: buildkitd
template:
metadata:
labels:
app.kubernetes.io/name: buildkitd
spec:
automountServiceAccountToken: false # the builder needs no Kubernetes API at all
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000 # makes the 0440 TLS Secret below readable by uid 1000, and nobody else
containers:
- name: buildkitd
# Pinned by digest (the multi-arch index). The kubernetes manager in renovate.json5
# tracks platform/**, so Renovate proposes bumps; the CI client (buildx, pinned in
# versions.env) talks to any recent daemon.
image: docker.io/moby/buildkit:v0.34.1-rootless@sha256:f68752a5a94acfcdad2d07867b7075601a421638e17fc5f28efc1c814d0c4f89
args:
- --addr
- unix:///run/user/1000/buildkit/buildkitd.sock # local only: the probes below
- --addr
- tcp://0.0.0.0:1234 # CI, mTLS only
- --tlscacert
- /certs/ca.crt # client certs are VERIFIED against this CA: no cert, no build
- --tlscert
- /certs/tls.crt
- --tlskey
- /certs/tls.key
- --config
- /etc/buildkit/buildkitd.toml
- --oci-worker-no-process-sandbox
ports:
- name: buildkit
containerPort: 1234
protocol: TCP
readinessProbe:
exec:
command: ["buildctl", "debug", "workers"]
initialDelaySeconds: 5
periodSeconds: 30
livenessProbe:
exec:
command: ["buildctl", "debug", "workers"]
initialDelaySeconds: 30
periodSeconds: 60
timeoutSeconds: 10
securityContext:
privileged: false
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
allowPrivilegeEscalation: true
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
add: ["SETUID", "SETGID"]
seccompProfile:
type: Unconfined
appArmorProfile:
type: Unconfined
resources:
# Build steps (dnf, npm, pip) run INSIDE this container's cgroup, so the limit covers
# the daemon and the build together.
requests:
cpu: 250m
memory: 512Mi
ephemeral-storage: 2Gi
limits:
memory: 4Gi
volumeMounts:
- { name: certs, mountPath: /certs, readOnly: true }
- { name: config, mountPath: /etc/buildkit, readOnly: true }
- { name: cache, mountPath: /home/user/.local/share/buildkit }
- { name: run, mountPath: /run/user }
- { name: tmpdir, mountPath: /home/user/.local/tmp } # the image's TMPDIR
- { name: tmp, mountPath: /tmp }
- { name: clicfg, mountPath: /home/user/.docker } # buildctl (the probes) writes here
volumes:
- name: certs
secret:
secretName: buildkitd-tls # cert-manager: ca.crt + tls.crt + tls.key
defaultMode: 0440
- name: config
configMap:
name: buildkitd-config
- name: cache
emptyDir:
sizeLimit: 6Gi # GC caps use at 4GB (configmap.yaml); this is the backstop
- name: run
emptyDir: {}
- name: tmpdir
emptyDir: {}
- name: tmp
emptyDir: {}
- name: clicfg
emptyDir: {}
+13
View File
@@ -0,0 +1,13 @@
---
# platform/buildkit — rootless BuildKit, the CI image builder. Reconciled by the Flux
# Kustomization `buildkit` (clusters/base/buildkit.yaml). See README.md.
# versions.env is NOT a resource: it pins the CI-side client (buildx) and the address CI dials.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- certificates.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- networkpolicy.yaml
+26
View File
@@ -0,0 +1,26 @@
---
# buildkit — the image builder for CI (.gitea/workflows/images.yaml). Operator decision on PR #81
# (2026-10-29): images are built by a ROOTLESS BuildKit daemon reached over mTLS, and NO CI job
# may reach a privileged daemon. See README.md for the whole design.
#
# A NAMESPACE OF ITS OWN, deliberately NOT `gitea-actions`:
# * The act-runner namespace exists to hold a PRIVILEGED DinD sidecar. Putting the builder
# beside it would put a TLS server key next to a privileged container for no benefit.
# * Its NetworkPolicies, Secrets and quota then belong to one component, and the ingress rule
# ("only the act-runner pod may connect") can be written from the outside.
#
# NO `app-namespace` LABEL, like gitea-actions: this is CI plumbing, not a user-facing app. That
# is also why NO Kyverno exception is needed — every applied ClusterPolicy
# (platform-config/kustomization.yaml) matches only namespaces labelled
# homelab.keithjackson.dev/app-namespace: "true", and none of the job-shape policies match here.
#
# NO pod-security.kubernetes.io LABEL, on purpose. Rootless BuildKit needs seccomp AND AppArmor
# `Unconfined` (measured — see deployment.yaml), which PSS `baseline` forbids; there is no
# cluster-wide PSA default here, so an unlabelled namespace admits it. It is NOT privileged and
# the deployment's securityContext is the boundary — read it before relaxing anything.
apiVersion: v1
kind: Namespace
metadata:
name: buildkit
labels:
homelab.keithjackson.dev/ci: buildkit
+109
View File
@@ -0,0 +1,109 @@
---
# Default-deny for the namespace, then two narrow allows.
#
# WHO MAY CONNECT — AND WHY THE ACT-RUNNER POD IS THE RIGHT SOURCE (measured 2026-10-29).
# CI jobs do not run as pods. act_runner starts each job as a container on its DinD sidecar, on a
# docker bridge network INSIDE the act-runner pod's network namespace, and dockerd masquerades it:
# kubectl -n gitea-actions exec deploy/act-runner -c dind -- iptables -t nat -S POSTROUTING
# -A POSTROUTING -s 172.18.0.0/16 ! -o br-c0f13d041ad6 -j MASQUERADE
# -A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
# So a job's connection leaves with the act-runner POD's IP and therefore Cilium's identity for
# that pod (namespace gitea-actions, app.kubernetes.io/name=act-runner). That is exactly what the
# ingress rule names. It cannot distinguish one job from another, or a job from act_runner
# itself — per-job authentication is the mTLS client certificate, not this policy. What the
# policy adds is that NOTHING ELSE in the cluster can even open the port.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: buildkit
labels:
homelab.keithjackson.dev/baseline: default-deny
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
---
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: buildkitd-allow-ci-ingress
namespace: buildkit
labels:
app.kubernetes.io/name: buildkitd
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: buildkitd
ingress:
- fromEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: gitea-actions
app.kubernetes.io/name: act-runner
toPorts:
- ports:
- { port: "1234", protocol: TCP }
---
# WHAT A BUILD NEEDS TO REACH. Build steps download (dnf mirrors, GitHub releases, dl.k8s.io,
# npm, PyPI, Fedora/Docker registries) and the result is pushed to the Gitea registry.
# * world 443/80: the downloads. 80 because Fedora's mirror list includes plain-http mirrors
# (dnf verifies package GPG signatures, not transport).
# * ingress (the Gateway's Envoy, reserved:ingress): the push. gitea.home.keithjackson.dev
# resolves in-cluster to the Gateway VIP (split-horizon CoreDNS), and Cilium hands that
# traffic to the Gateway, whose identity is `ingress` — the same identity gitea's own
# ingress allow admits.
# * gitea's BACKEND identity, :3000 only: ALSO the push, and the half that `ingress` alone does
# not cover. Cilium >= 1.16.8/1.17.2 (CVE-2025-30162; this cluster runs 1.20.1) makes the
# Gateway's Envoy re-evaluate THIS pod's egress policy against the identity of the backend it
# resolves the request to (enforce_policy_on_l7lb). `toEntities: ingress` gets the TCP
# connection to Envoy; without this rule Envoy then refuses the HTTP request itself with
# `403 Access denied`, and Gitea never sees it. Measured 2026-10-30 (images run 337 failed
# this way): Hubble on buildkitd's node logged `buildkit/buildkitd (ingress) ->
# gateway/cilium-gateway-shared:443 http-request DROPPED`, traffic_direction EGRESS, and
# Gitea's router log had no entry for it — while the same unauthenticated GET /v2/ from the
# act-runner pod (egress unrestricted) reached Gitea and got its normal 401. It is the same
# fix catalog/render.py applies for every SSO app's hop to Keycloak (commit 68690e5).
# Gitea's INGRESS needs nothing: the request arrives there as `reserved:ingress`, already
# admitted. Do not add buildkit to catalog/gitea.yaml's inClusterClients — that would open
# the ClusterIP path from build code to Gitea, which nothing here uses.
# Nothing else in the cluster: not the API server, not other pods. A build step is code from a
# pull request.
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
name: buildkitd-allow-egress
namespace: buildkit
labels:
app.kubernetes.io/name: buildkitd
spec:
endpointSelector:
matchLabels:
app.kubernetes.io/name: buildkitd
egress:
- toEntities:
- world
toPorts:
- ports:
- { port: "443", protocol: TCP }
- { port: "80", protocol: TCP }
- toEntities:
- ingress
- toEndpoints:
- matchLabels:
io.kubernetes.pod.namespace: gitea
app.kubernetes.io/name: gitea
toPorts:
- ports:
- { port: "3000", protocol: TCP }
+19
View File
@@ -0,0 +1,19 @@
---
# The address CI dials: tcp://buildkitd.buildkit.svc.cluster.local:1234 (platform/buildkit/
# versions.env), mTLS only. ClusterIP — never exposed on the Gateway.
apiVersion: v1
kind: Service
metadata:
name: buildkitd
namespace: buildkit
labels:
app.kubernetes.io/name: buildkitd
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: buildkitd
ports:
- name: buildkit
port: 1234
targetPort: buildkit
protocol: TCP
+15
View File
@@ -0,0 +1,15 @@
# platform/buildkit/versions.env — the CI side of the builder. Sourced by
# .gitea/workflows/scripts/images.sh. NOT a Kubernetes resource (kustomization.yaml omits it).
#
# The CLIENT. CI drives the daemon with `buildx` (remote driver) rather than bare `buildctl`
# because the image pipeline needs `bake`'s `target:` contexts: on a pull request agent-runner is
# built FROM the toolbox built in the same run, which never exists in any registry.
# Pinned with the sha256 from the release's own checksums.txt
# (https://github.com/docker/buildx/releases/download/v0.38.1/checksums.txt); the pipeline
# refuses a download that does not match. Bump BOTH lines together.
BUILDX_VERSION=0.38.1
BUILDX_SHA256=e25d5cd675092bb5d1d03632fdc46cdf3fede7b514dc5f023a105e00d3ce5ad4
# The DAEMON's address and the name its certificate carries (platform/buildkit/certificates.yaml).
BUILDKIT_HOST=tcp://buildkitd.buildkit.svc.cluster.local:1234
BUILDKIT_SERVERNAME=buildkitd.buildkit.svc.cluster.local
+112
View File
@@ -0,0 +1,112 @@
---
# =================================================================================================
# coredns HA floor — hold in-cluster DNS at >= 2 replicas, which k3s does not.
# =================================================================================================
# MEASURED on prod 2026-10-18: `kube-system/coredns` ran with **replicas: 1**. In-cluster DNS is a
# single pod on a single node, so **draining or rebooting that one node is a cluster-wide
# in-cluster DNS outage** for as long as the pod takes to reschedule and become ready — every pod
# that looks up a Service, every CNPG replica finding its primary, every operator talking to the
# API by name. It is the single cheapest availability defect in the cluster, and it was invisible
# because `1/1 Ready` looks perfectly healthy.
#
# This matters more now than it did: the reboot automation (kured + unattended-upgrades) drains and
# reboots one node at a time on a schedule, which turns a latent single point of failure into a
# recurring one, unattended, in the middle of the night.
#
# -------------------------------------------------------------------------------------------------
# WHY AN HPA, AND NOT JUST `replicas: 2` — this is the whole decision
# -------------------------------------------------------------------------------------------------
# **k3s owns the coredns Deployment and we cannot durably edit it.** It is applied by k3s's own
# deploy controller from a bundled manifest, not by Flux:
#
# $ kubectl -n kube-system get addon coredns
# NAME SOURCE CHECKSUM
# coredns /data/k3s/server/manifests/coredns.yaml 8cd91595...
# $ kubectl -n kube-system get deploy coredns -o jsonpath='{.metadata.managedFields[*].manager}'
# deploy@k3s-prod-1 k3s
#
# and the Deployment carries an `objectset.rio.cattle.io/hash` label — a wrangler objectset. k3s
# RE-STAGES its bundled manifests on startup, so a `replicas: 2` written into that file (or a
# `kubectl scale`) is reverted the next time k3s restarts or is upgraded. MEASURED the same day: a
# hand `kubectl scale` to 2 was NOT actively reverted (it held, and the AddOn checksum did not
# change) — the deploy controller re-applies on manifest change and on restart, not on a drift
# timer. So the hand fix survives until exactly the event we are protecting against: a reboot.
#
# The two durable alternatives were weighed and rejected:
# * **`coredns.yaml.skip` + our own templated copy** (the pattern roles/kube_vip already uses for
# a manifest in server/manifests). Fully deterministic and never flaps — but it takes OWNERSHIP
# of coredns away from k3s permanently, so k3s upgrades would stop updating it and the version
# would have to be refreshed by hand forever. Too high a price for a replica count.
# * **A Flux-managed patch of the Deployment itself.** Rejected outright: Flux and k3s's deploy
# controller would both own one object and fight continuously. That is the shape of the
# `default-deny` NetworkPolicy drift loop this cluster already suffered (Kyverno generate + Flux
# both owning one resource, ~2500 events per 10 minutes).
#
# An HPA avoids both. It is a SEPARATE object that k3s does not own and will never reconcile away,
# and it drives the Deployment's `/scale` subresource rather than its spec — so there is no
# shared-ownership fight. If k3s re-stages `replicas: 1`, the HPA observes it under `minReplicas`
# and scales back to 2 within one reconcile (~15s by default).
#
# THE COST, STATED PLAINLY: there is a brief window — seconds, after a k3s restart — where coredns
# is back at 1 replica until the HPA notices. That is strictly better than the status quo, where it
# is at 1 replica permanently, but it is not zero. Accepting that window is the trade being made
# here; the `.skip` option above is what to reach for if it ever proves unacceptable.
#
# -------------------------------------------------------------------------------------------------
# THIS IS A FLOOR-HOLDER, NOT A REAL AUTOSCALER
# -------------------------------------------------------------------------------------------------
# `minReplicas: 2` is the entire point. The CPU metric exists because an autoscaling/v2 HPA needs a
# metric to be a valid object, not because coredns is expected to scale on load: measured via
# metrics-server, the two pods use **5m and 4m** against a **100m** request — ~5% utilisation, so
# the 80% target will never be approached and the HPA will sit at `minReplicas` indefinitely.
#
# The CPU request is what makes this legal: coredns ships `requests: {cpu: 100m, memory: 70Mi}`
# (measured), and a Resource/Utilization metric is meaningless without a request. metrics-server is
# installed as a k3s addon and verified serving (`kubectl top pod -l k8s-app=kube-dns` returns
# figures), so the metric resolves rather than leaving the HPA with `ScalingActive=False`.
#
# `maxReplicas: 3` = the node count. coredns' pod anti-affinity prefers one pod per node, so a
# fourth replica would add no failure-domain coverage; the ceiling exists so a metrics glitch can
# never balloon DNS pods across the cluster.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: coredns
# kube-system, because that is where k3s puts coredns. This is the only object this component
# places outside a namespace it owns, and it is additive: it creates nothing in kube-system and
# mutates only the coredns Deployment's scale subresource.
namespace: kube-system
labels:
app.kubernetes.io/name: coredns
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: coredns
minReplicas: 2
maxReplicas: 3
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
# Deliberately high. See "floor-holder" above: this must not scale on normal DNS load.
averageUtilization: 80
behavior:
scaleDown:
# 10 minutes. DNS is the worst thing in the cluster to flap: a scale-down races in-flight
# queries and every client's negative cache. The HPA should be reluctant in this direction.
stabilizationWindowSeconds: 600
policies:
- type: Pods
value: 1
periodSeconds: 600
scaleUp:
# Prompt in the direction that restores redundancy — this is the path taken after a k3s
# restart has re-staged `replicas: 1`.
stabilizationWindowSeconds: 0
policies:
- type: Pods
value: 2
periodSeconds: 60
+4
View File
@@ -7,3 +7,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- coredns-custom.yaml
# HA floor for the coredns Deployment itself — a separate concern from the split-horizon server
# block above, and deliberately an HPA rather than a replica edit: k3s owns that Deployment via
# its own deploy controller and re-stages `replicas: 1` on restart. hpa.yaml has the reasoning.
- hpa.yaml
+206
View File
@@ -31,6 +31,8 @@ files pulled as remote kustomize bases (the kubevirt-stub pattern); Renovate mai
| `keycloak-realm-import.yaml` (`KeycloakRealmImport`: realm/groups/clients/MFA) | **platform-config** | config |
| `client-secret-reconciler.yaml` (CronJob: client secrets **and** client URLs ← git, every 15m) | **platform-config** | config |
| `client-urls.yaml` (**GENERATED** ConfigMap: the client-URL desired state) | **platform-config** | config |
| `passkey-reconciler.yaml` (CronJob: WebAuthn passwordless policy + enrolment action + the **browser flow** ← git every 15m; the flow blue/green, never mutating the bound one, **bound only when git says so**) | **platform-config** | config |
| `passkey-desired-state.yaml` (**GENERATED** ConfigMap: the passkey desired state — plus the one hand-edited line, `bind-browser-flow`) | **platform-config** | config |
| `httproute.yaml` (keycloak.home.keithjackson.dev) | **platform-config** | config |
| `servicemonitor.yaml` (Prometheus scrape) | **platform-config** | config |
@@ -154,6 +156,210 @@ NEW client, a new protocol mapper or a changed scope still needs the realm impor
(it will not on its own) or a deliberate admin-API/`kcadm` change. `Done=True` is never evidence that
a realm edit landed.
## Passkeys (WebAuthn Passwordless) — DECLARED IN GIT, RECONCILED INTO THE REALM (2026-10-18)
`keycloak-realm-import.yaml` now declares passkey login: the realm's **WebAuthn Passwordless**
policy (`webAuthnPolicyPasswordless*`), and a `browser-mfa` flow that offers a passkey as an
**ALTERNATIVE** beside the password form. Design decisions and their reasoning are in the manifest
comments, not repeated here. The headline choices:
| Decision | Value | One-line reason |
|---|---|---|
| Additive or replacing? | **Additive** — passkey *or* password | A misfiring mandatory flow locks the single operator out of every SSO app *and* kubectl |
| Enrolment | **Optional**, never a default action | Same reason; `webauthn-register-passwordless` is already enabled-not-forced by default |
| Relying-party ID | `${BASE_DOMAIN}` (not `keycloak.${BASE_DOMAIN}`) | An unre-keyable credential must not be bound to a hostname that can move |
| User verification | `required` | Without it, mere possession of an unlocked device is a complete login |
| Resident key | `Yes` | A non-discoverable credential is a second factor, not a passkey |
| OTP after a passkey? | **No** — conditional OTP stays on the password branch | A UV-required passkey is already two factors, and a phishing-resistant pair at that |
### The declaration did not reach the realm — a reconciler now carries all of it (2026-10-18)
Same one-shot failure this file already documents twice above, third field over. Measured on prod
2026-10-17:
```
$ kubectl --context k3s-prod -n keycloak get keycloakrealmimport homelab -o jsonpath='{.status}'
{"conditions":[{"status":"True","type":"Done"},{"status":"False","type":"Started"},
{"status":"False","type":"HasErrors"}]}
```
`Done=True` / `Started=False` — the import has completed and **will not re-run**, so a policy block
and three new sub-flows added to this file change nothing about how anyone logs in. The
client-secret/client-URL reconciler does **not** close this gap and was never meant to: its hard
endpoint allow-list makes `/authentication` unreachable **by design** (see the table above), and
realm-level policy fields are not client fields. This is exactly the "a NEW client, a new protocol
mapper or a changed scope still needs the realm import to actually re-run" case named above.
`passkey-reconciler.yaml` closes that gap the same way the client reconciler closed the
secret and URL halves: a CronJob on `*/15` that authenticates to the admin API and converges
declared state. It is a **sibling**, not a second pass of the client reconciler, precisely so that
reconciler's allow-list — and its guarantee that prod's real user and the `media-users` /
`platform-admins` / `viewers` groups are **structurally unreachable** — stays literally true.
Its desired state is `passkey-desired-state.yaml`, **generated** from the realm import by
`ci/render-keycloak-passkey-policy.py` and gated by
`conformance/tests/l0-static/keycloak-passkey-reconciler`, so the RP ID is declared exactly once.
That matters more here than for client URLs: an RP ID is baked into every credential at enrolment
and **can never be re-keyed**, so two declarations free to skew about the domain would be an
unrecoverable-credential bug, not a cosmetic one.
| Declared thing | Who applies it | Why |
|---|---|---|
| `webAuthnPolicyPasswordless*` (realm policy) | **The reconciler. GitOps.** | Realm top-level attributes; strict read-modify-write of `GET`/`PUT /admin/realms/homelab` |
| `webauthn-register-passwordless` required action | **The reconciler. GitOps.** | Per-alias `PUT`, one literally-named alias; enabled, `defaultAction: false` |
| The `browser-mfa` flow restructure | **The reconciler builds and verifies it. Git decides whether it is bound.** | Blue/green: build under a new alias, verify, and flip `browserFlow` in one atomic PUT **only when `bind-browser-flow: "true"`** — see below |
#### The flow converges blue/green — it never mutates the flow you log in through
Converging a five-flow / ten-execution restructure needs create-subflow,
create-execution, set-requirement and bind-config as **separate** admin API calls, with no
transaction. Doing that to the flow bound as `browserFlow` would mean a run dying part-way
leaves a **half-built login path** for Grafana, Gitea, Nextcloud, Headlamp, Semaphore,
PiHole, Crafty, the *arr stack *and* kubectl. This file already documents a total SSO
outage from a realm change, and the conditional-OTP sub-flow — which the declared
structure moves **one level deeper** than it sits today — once 403'd every platform-admin.
That risk is real but it is **not inherent**: it follows only from mutating the *bound*
flow. So the reconciler does not.
1. **Build** the declared tree under a **new, unused alias** — the declared alias plus a
hash of the desired shape, applied to every flow in the tree. Every multi-call write
lands on a flow nothing is using. A failure at any point leaves an orphaned, unbound
flow and **zero user-visible effect**.
2. **Verify** by readback — nesting levels, ordering, authenticators, requirements, and
the conditional-OTP config *contents*. Nothing is bound unless this passes, so the
regression that once 403'd every platform-admin is caught **before** it can bite.
3. **Bind** with **one atomic PUT** of the realm, setting `browserFlow` — **only if git says
so** (next section). Otherwise the run stops here and logs `WOULD BIND browser-mfa-<hash>`.
4. **Rollback is a PR plus one write** (below), and nothing is ever deleted, so the old flow
stays usable.
#### The bind switch: `bind-browser-flow` in `passkey-desired-state.yaml`
That generated file carries exactly one hand-edited line, `bind-browser-flow`, and it is
`"false"` by default. The renderer carries it through on `--write`, and `--check` refuses
anything but the quoted strings `"true"` / `"false"` (an unquoted `true` is a YAML boolean,
which a ConfigMap rejects at apply time and which would fail the whole `platform-config`
Kustomization). The CronJob reads it from the same mount as the desired state; no flag can
widen it.
| `bind-browser-flow` | What the reconciler does to the realm's `browserFlow` |
|---|---|
| `"false"` (default) | **Never writes it.** It builds and verifies the candidate (or reuses an already verified one), logs `WOULD BIND <alias>`, and exits 0. Whatever the realm is bound to — including your rollback — is left alone on every run. A hand-edited bound flow is left alone too (no rebuild beside it). |
| `"true"` | Binds the verified candidate in one atomic PUT, **and keeps asserting it**: a binding changed by hand is changed back within 15 minutes, and a hand-edit *inside* the bound flow makes the next run build and bind a fresh copy. That is GitOps doing its job — so never hotfix the flow while this is `"true"`. |
**Merging with `"false"` changes nothing about how anyone logs in.** Passes 1 and 2 do write
the realm (the passwordless policy and the enrolment action, neither of which any login
step uses until a flow offers passkeys), and pass 3 creates the unbound candidate flow, but
`browserFlow` is untouched.
**Going live** is a later one-line PR — `bind-browser-flow: "true"` — made **after** the
candidate has been tested in a browser (see "Testing the candidate flow before binding it").
**Rolling back**, in this order:
1. A PR setting `bind-browser-flow: "false"`, merged and applied by Flux. **First** — while it
is `"true"`, the next run rebinds the candidate over your rollback.
2. One write: bind the previous flow again. The Job log of the bind printed it
(`ROLLBACK is one write: set browserFlow back to ...`); it is `browser-mfa` for the first
go-live. Admin console: *Authentication → Flows → <previous flow> → Action → Bind flow →
Browser flow*, or `PUT /admin/realms/homelab` as a read-modify-write of the full realm
representation (a partial body drops the passkey policy).
If login is broken *right now*, do step 2 immediately and step 1 straight after: the worst
case of that order is that one scheduled run (≤15 min) rebinds the candidate before Flux
applies the PR, and you repeat step 2.
**The safety property is structural, not intentional.** The reconciler is *incapable* of
writing to the bound flow or anything beneath it: the live `browserFlow` alias and its
whole subtree of sub-flow aliases and execution ids are read first and screened against
every write before a socket opens. It is **fail-closed** — until that binding has been
read, every flow write is refused, and if the bound flow's executions cannot be read the
flow pass writes nothing at all. There is **no `DELETE`** anywhere in its allow-list.
**Idempotence is load-bearing**, not a nicety: a rebuild-and-reflip every 15 minutes
would churn the login path on a schedule. The bound flow is compared structurally against
the declared shape and, when they match, the run issues **zero writes and builds
nothing**. Sub-flow and config *aliases* are deliberately **not** compared — a flow the
reconciler built carries a hash suffix, so comparing labels would make it permanently
unrecognisable as converged.
**Recovery.** A crashed run can leave a partial flow under the primary candidate alias. A
later run reads it: if it matches desired it is a completed-but-unbound build and is
simply **bound**; if not, it is left alone and the next candidate alias is used. The
candidate count is **bounded**, so a persistently failing build fails loudly naming the
flows to clean up rather than creating unbounded sprawl.
```sh
# What the reconciler currently says. A normal run prints the full comparison.
kubectl --context k3s-prod -n keycloak get jobs -l app.kubernetes.io/name=keycloak-passkey-reconciler
kubectl --context k3s-prod -n keycloak logs -l app.kubernetes.io/name=keycloak-passkey-reconciler --tail=200
# Which candidate it would bind (bind-browser-flow "false"):
kubectl --context k3s-prod -n keycloak logs -l app.kubernetes.io/name=keycloak-passkey-reconciler --tail=300 \
| grep -E 'WOULD BIND|BOUND browserFlow|PROBLEM'
# Force a run now rather than wait for the tick. It does exactly what the CronJob does,
# including honouring bind-browser-flow. `kubectl create job --from=cronjob/...` CANNOT
# override the container command, so --audit / --no-bind need a one-off Job with the flag
# appended (--no-bind can only NARROW git's switch, never widen it).
kubectl --context k3s-prod -n keycloak create job --from=cronjob/keycloak-passkey-reconciler \
passkey-run-once
kubectl --context k3s-prod -n keycloak logs job/passkey-run-once
# ROLLBACK — see "The bind switch" above: PR bind-browser-flow "false" FIRST, then rebind
# the previous flow (Authentication -> Flows -> <previous> -> Action -> Bind flow ->
# Browser flow). The old flow is never deleted, so this always works.
# Regenerate the desired state after editing the passkey policy or the flow in the realm
# import (the merge gate will demand it).
python3 ci/render-keycloak-passkey-policy.py --write
```
### Testing the candidate flow before binding it
With `bind-browser-flow: "false"` the candidate exists but nothing logs in through it. Test it
on **one client** without touching the realm binding, using a per-client flow override:
1. Find the candidate: the Job log line `WOULD BIND browser-mfa-<hash>`.
2. Admin console → *Clients → `account-console` → Advanced → Authentication flow overrides →
Browser Flow* = `browser-mfa-<hash>`. This is a temporary, hand-made client change; remove it
in step 7.
3. **Password fallback.** Private window → `https://keycloak.<base domain>/realms/homelab/account`.
Expect a **username** screen, then a choice: passkey, or the password form. As a user with no
passkey, choose password and sign in.
4. **Platform-admin TOTP on the password branch.** Repeat step 3 as a `platform-admins` member:
after the password, a TOTP prompt **must** appear. If it does not — or you get a 403 — the
conditional-OTP step is wrong: stop, do not bind.
5. **Passkey enrolment.** Signed in to the account console → *Signing in → Passkey → Set up*. A
browser/OS passkey prompt must appear; if it does not, the RP ID is wrong for the origin and
nothing server-side will say so.
6. **Passkey login.** Sign out; private window again; username, then choose passkey. Expect to be
signed in with **no** TOTP prompt, even as a platform-admin.
7. Remove the override from step 2.
8. Only now: the one-line PR `bind-browser-flow: "true"`. The next run binds the candidate you
tested (same alias — the hash is of the declared shape).
### Verifying it actually works (nobody has, as of 2026-10-29)
No passkey has been enrolled and no browser has been pointed at this. The checks that would prove
it on the BOUND flow, after go-live, in order:
```sh
# 1. the policy is live (RP ID must read as the base domain, NOT the keycloak host, NOT a URL)
# Authentication -> Policies -> WebAuthn Passwordless Policy, in the admin console.
# 2. enrol: account console (https://keycloak.${BASE_DOMAIN}/realms/homelab/account) -> Signing in
# -> Passwordless -> set up. A browser/OS passkey prompt must appear; if it does not, the RP ID
# is wrong for the origin and NOTHING will say so.
# 3. log in: a fresh private window to any SSO-gated app. Expect username screen, then a passkey
# prompt. A platform-admin must NOT be asked for TOTP on this path.
# 4. regression (the important one): a user with NO passkey must still get the password screen,
# and a platform-admin must still be asked for TOTP there.
```
A failed passkey ceremony is usually silent in the server log — the browser refuses before Keycloak
is involved — so step 2 failing with no log entry is the expected signature of a wrong RP ID.
## Exceptions that keep native auth (do not force SSO) — ADR-0014
Plex/Seerr (Plex auth), Minecraft (Mojang/Microsoft), UniFi (native).
+285 -8
View File
@@ -74,6 +74,142 @@ spec:
bruteForceProtected: true
permanentLockout: false
sslRequired: external
# ===========================================================================================
# PASSKEYS — the WebAuthn PASSWORDLESS policy (R-IDP-04). 2026-10-17.
# ===========================================================================================
# Read this together with the browser-mfa flow at the bottom of the file: THIS block decides
# what a passkey must BE, the FLOW decides when one is OFFERED. Keycloak keeps TWO independent
# WebAuthn policies per realm and they are not interchangeable:
#
# webAuthnPolicy* -> the TWO-FACTOR policy, consumed by the `webauthn-authenticator`
# authenticator (a security key presented AFTER a password).
# webAuthnPolicyPasswordless* -> the PASSWORDLESS policy, consumed by
# `webauthn-authenticator-passwordless` (a passkey presented
# INSTEAD of a password). This is the one passkeys use.
#
# Setting the wrong family is a silent no-op: the passwordless authenticator reads
# getWebAuthnPolicyPasswordless() only (WebAuthnPasswordlessAuthenticator.getWebAuthnPolicy),
# so a policy written into the two-factor fields is simply never consulted by a passkey login.
# Only the passwordless family is declared here; the two-factor family is deliberately left at
# Keycloak's defaults because nothing in this realm uses `webauthn-authenticator` — TOTP
# (auth-otp-form) is the second factor for platform-admins, see the flow section.
#
# ADDITIVE AND OPTIONAL — THE DECISION THIS BLOCK EXISTS TO MAKE
# -------------------------------------------------------------
# Passkeys are offered ALONGSIDE the password, never instead of it, and enrolment is NOT forced.
# This is a single-operator homelab whose IdP gates Grafana, Gitea, Nextcloud, Headlamp,
# Semaphore, PiHole, Crafty, the *arr stack AND kubectl itself (the `kubernetes` client below).
# A mandatory passkey flow that misfires is therefore not an inconvenience, it is a total
# lock-out from the whole platform AND from the cluster API in one step — and the only repair
# paths left would be the Keycloak bootstrap admin and the break-glass cert kubeconfig. The
# expected gain from forcing enrolment (one human stops typing a password) is small; the
# expected loss is the platform. So: additive, optional, and password login keeps working for
# every user who never enrols.
#
# Concretely that means: no `requiredActions` block is declared anywhere in this file (see the
# long note in the flow section for why declaring one would be actively DANGEROUS), and the
# passkey step sits as an ALTERNATIVE beside the password form rather than ahead of it.
#
# RELYING-PARTY ID — THE FIELD THAT SILENTLY BREAKS EVERY PASSKEY IF IT IS WRONG
# ------------------------------------------------------------------------------
# The RP ID is baked into every credential at enrolment and can never be changed afterwards:
# a passkey created under one RP ID is unusable under another, with no error the user can act
# on — the browser simply reports that no credential is available. It is also browser-enforced,
# so it cannot be "fixed" server-side: per WebAuthn, the RP ID must be equal to, or a
# registrable-domain suffix of, the origin's effective domain. Keycloak is served at
# keycloak.<BASE_DOMAIN> (httproute.yaml, and the Keycloak CR's hostname), so the legal choices
# are keycloak.<BASE_DOMAIN>, <BASE_DOMAIN>, or the registrable domain one level up. It is NOT
# the full URL and NOT a scheme — "https://..." here would fail every ceremony.
#
# We choose BASE_DOMAIN, the shared platform suffix, NOT the keycloak host, for one concrete
# reason drawn from this repo's own history: a credential that cannot be re-keyed must not be
# bound to a name that can move. The oauth2-proxy client below ALREADY declares a redirect URI
# on auth.<BASE_DOMAIN>, i.e. this platform already contemplates serving auth on a second
# hostname; and the client-URL reconciler exists precisely because moving a hostname in git
# silently fails to reach the realm (see README, "Client URLs: the same one-shot failure").
# Binding passkeys to the keycloak subdomain would mean that relocating Keycloak invalidates
# every enrolled passkey; binding them to BASE_DOMAIN survives it. The cost is scope: any
# future WebAuthn relying party under this base domain could assert the same RP ID. In a
# homelab where every such party is already behind THIS IdP that is not a trust boundary worth
# buying. Note the residual, unavoidable risk: changing BASE_DOMAIN itself invalidates every
# passkey, which is a re-enrolment, not a repair.
#
# Written as the cluster-var so it is substituted by the same Flux postBuild pass, with the same
# value, as every hostname in this file — there is no second place the domain is decided.
webAuthnPolicyPasswordlessRpId: ${BASE_DOMAIN}
# The human-readable name the browser/OS shows in the passkey prompt and stores beside the
# credential ("Save a passkey for Homelab SSO?"). Matches the realm displayName above so the
# OS keychain entry is recognisable. It is also load-bearing on the UPDATE path: Keycloak's
# updateRealm applies the passwordless policy ONLY when this field is non-empty
# (DefaultExportImportManager line ~842), so an admin-API realm update that omits it drops the
# whole policy silently.
webAuthnPolicyPasswordlessRpEntityName: "Homelab SSO"
# ES256 then RS256 — exactly Keycloak's own DEFAULT_POLICY, declared explicitly rather than
# inherited so the realm's crypto is auditable from git. ES256 is what every platform
# authenticator (Touch ID/Face ID, Windows Hello, Android) and every FIDO2 key produces;
# RS256 is the fallback for older Windows Hello TPM-backed credentials. EdDSA/Ed25519 is
# deliberately NOT offered: authenticator and browser support is uneven, and an authenticator
# that picks an algorithm the verifier handles badly fails at enrolment, which is the worst
# moment to discover it. Order is a preference list, not an allow-list ordering requirement.
webAuthnPolicyPasswordlessSignatureAlgorithms:
- ES256
- RS256
# DISCOVERABLE CREDENTIAL REQUIRED. "Yes" is a Keycloak sentinel, not a boolean, and the login
# theme compares it as the literal string: webauthnRegister.js maps 'Yes' to
# authenticatorSelection.requireResidentKey=true and ANY other non-sentinel value to false.
# This is not optional for passkeys — a non-discoverable credential cannot be found by the
# browser without the server first naming it, so it is a second factor, not a passkey.
# (Keycloak 26.0.6 emits only the LEGACY requireResidentKey member, not the newer
# residentKey:"required"; per WebAuthn L2 the client maps requireResidentKey=true onto
# residentKey="required", so discoverable credentials are genuinely requested — but it also
# means this policy cannot express residentKey:"preferred" at all.)
webAuthnPolicyPasswordlessRequireResidentKey: "Yes"
# USER VERIFICATION REQUIRED. The authenticator must verify the human (biometric or PIN) and
# say so in the assertion. This is the field that makes a passkey login at least as strong as
# the password it replaces: with verification merely "preferred", possession of an unlocked
# phone or an unattended security key would be a COMPLETE login to every SSO-gated app — a
# single-factor possession check, strictly weaker than the password+TOTP path beside it.
# Required makes the passkey branch inherently two-factor (something you have + something you
# are/know), which is the premise the flow section relies on when it does NOT ask a
# passkey-authenticated platform-admin for TOTP as well.
webAuthnPolicyPasswordlessUserVerificationRequirement: required
# "not specified" is Keycloak's sentinel for "either kind of authenticator"
# (Constants.DEFAULT_WEBAUTHN_POLICY_NOT_SPECIFIED; the theme JS omits the WebAuthn member
# entirely when it sees this exact string). Deliberately NOT narrowed: "platform" would reject
# every YubiKey and every phone used as a cross-device passkey, and "cross-platform" would
# reject Touch ID and Windows Hello — i.e. either choice silently halves the usable hardware.
# The operator should be able to enrol a laptop AND a phone AND a hardware key.
webAuthnPolicyPasswordlessAuthenticatorAttachment: "not specified"
# ATTESTATION: none. Requesting "direct" would hand Keycloak an attestation statement it has
# no trust anchors to validate here (no metadata service is configured) and nothing to compare
# it against, since the AAGUID allow-list below is deliberately empty — so the statement would
# be collected, unverifiable, and would add an extra "share model information?" consent prompt
# on Safari/iOS for no security gain. Attestation is how you ENFORCE an approved-hardware list;
# we do not have one, so asking for it is cost without benefit.
webAuthnPolicyPasswordlessAttestationConveyancePreference: none
# Empty = accept any authenticator model. An AAGUID allow-list is a hardware whitelist: it
# would have to be maintained by hand, and every new phone, laptop or key the operator buys
# would be rejected at enrolment with a message that does not explain itself. It is also moot
# while attestation is "none" (above) — with no attestation statement there is no trustworthy
# AAGUID to match against in the first place.
webAuthnPolicyPasswordlessAcceptableAaguids: []
# Ceremony timeout in SECONDS (the login theme multiplies by 1000; 0 means "send no timeout and
# let the browser decide"). 300s is chosen over a tighter value because the slowest LEGITIMATE
# path is the cross-device hybrid flow — Keycloak renders a QR code, the operator unlocks a
# phone, scans it, and completes a biometric check — which routinely takes over a minute. A
# 60-120s timeout looks tidy and converts that flow into an unexplained failure. Browsers clamp
# this value (Chrome to at most 10 minutes), so 300 is honoured rather than reinterpreted.
webAuthnPolicyPasswordlessCreateTimeout: 300
# Send the user's existing passkeys as excludeCredentials at enrolment, so re-enrolling an
# authenticator the user already registered is refused by the authenticator itself instead of
# producing a second, indistinguishable credential in the account console. Cheap, and it keeps
# "which passkey is which" answerable — which matters when the recovery story is "delete the
# lost one".
webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister: true
# webAuthnPolicyPasswordlessExtraOrigins is intentionally NOT set. It exists to admit non-web
# origins (native app facet IDs, e.g. android:apk-key-hash:...). Every ceremony here happens in
# a browser on Keycloak's own origin, which is already covered by the RP ID above; adding
# origins is only ever a widening of who may ask for these credentials.
# --- Authorization model (R-IDP-06): groups drive access; platform-admins carry a realm role. ---
roles:
realm:
@@ -528,14 +664,105 @@ spec:
"access.token.claim": "true"
"userinfo.token.claim": "true"
"claim.name": groups
# --- MFA for platform-admins (R-IDP-04): a browser flow whose OTP step is CONDITIONAL on the
# platform-admin role. Non-admins log in without OTP; platform-admins must enroll/present TOTP.
# Set as the realm browserFlow below. VERIFY at bring-up (see runbook): a malformed flow fails
# the whole import — if so, the fallback is to comment this block + browserFlow and configure
# the conditional-OTP-by-role flow via the admin console. ---
# ===========================================================================================
# MFA for platform-admins (R-IDP-04) + PASSKEY login (2026-10-17). The realm browserFlow is
# bound to browser-mfa at the bottom of this file.
# ===========================================================================================
# Non-admins log in without OTP; platform-admins must enroll/present TOTP. VERIFY at bring-up
# (see runbook): a malformed flow fails the whole import — if so, the fallback is to comment
# this block + browserFlow and configure the flow via the admin console.
#
# THE SHAPE, AND WHY IT IS THIS SHAPE
# -----------------------------------
# browser-mfa (top level, bound as browserFlow)
# auth-cookie ALTERNATIVE already-authenticated SSO cookie
# identity-provider-redirector ALTERNATIVE (no IdPs configured; kept for parity)
# browser-mfa-forms ALTERNATIVE the interactive path:
# auth-username-form REQUIRED WHO are you (no password yet)
# browser-mfa-passkey-or-password REQUIRED ...and PROVE it, one of two ways:
# webauthn-authenticator-passwordless ALTERNATIVE a passkey, or
# browser-mfa-password-and-otp ALTERNATIVE the password path:
# auth-password-form REQUIRED password, then
# browser-mfa-conditional-otp CONDITIONAL TOTP iff platform-admin
#
# WHY THE USERNAME/PASSWORD SCREEN HAD TO BE SPLIT IN TWO. The previous flow used the combined
# `auth-username-password-form`, which collects both on one page. A passkey branch cannot live
# behind that: the server must know WHICH user is logging in before it can decide whether a
# passkey is even available, and the combined form has already taken the password by the time
# it knows. So it is replaced by the `auth-username-form` / `auth-password-form` pair — the
# same decomposition Keycloak's own passwordless recipe uses. THIS IS A VISIBLE UX CHANGE FOR
# EVERY USER, passkey or not: login becomes two screens (username, then password) instead of
# one. That cost is accepted deliberately — it is the enabling change, it is what Google and
# Microsoft logins already do, and the alternative (below) is worse.
#
# WHY NOT USERNAMELESS ("just tap your passkey", no username screen at all). Keycloak 26.0.6
# does support it: WebAuthnPasswordlessAuthenticator.requiresUser() returns FALSE, so the
# authenticator can legally run as a first step with no user in context. We deliberately do not
# do that, because of how the flow engine then behaves. In DefaultAuthenticationFlow the
# "does this user actually have this credential?" guard sits INSIDE the requiresUser() branch;
# with requiresUser() false and no user yet set, the engine skips that guard and simply invokes
# the authenticator. A passkey execution placed before the username form is therefore offered
# to EVERYONE, so every login — including every password-only user and every family member who
# will never own a passkey — would open on a passkey prompt they must dismiss. That is the
# opposite of additive. Keeping the passkey step AFTER auth-username-form means a user is in
# context, which is what makes the graceful degradation below actually work.
#
# WHY THE PASSKEY BRANCH DEGRADES CLEANLY TO A PASSWORD (the property the whole "additive"
# claim rests on). With a user in context, the engine takes the non-requiresUser branch and
# raises CREDENTIAL_SETUP_REQUIRED when the user has no credential of this type and the
# authenticator's factory does not allow self-setup — and WebAuthnPasswordlessAuthenticatorFactory
# .isUserSetupAllowed() is false, and the authenticator is a CredentialValidator, so both
# conditions hold. That exception is raised INSIDE the ALTERNATIVE loop, which catches it,
# marks the execution ATTEMPTED and proceeds to the next alternative. Net effect: a user with
# no passkey never sees a passkey screen and lands on the password form; a user WITH one gets
# the passkey screen and keeps the password as a "Try another way" option. Note that for a
# user holding BOTH, which screen appears FIRST is decided by Keycloak's authentication
# selection list (the user's own credential ordering), not by the priorities below.
#
# WHY A PASSKEY LOGIN IS NOT ALSO ASKED FOR TOTP. browser-mfa-conditional-otp hangs off the
# PASSWORD branch only, so a platform-admin who authenticates with a passkey is not challenged
# for TOTP. That is intentional, and it rests on the policy above: userVerification is
# `required`, so the authenticator has already proved possession of hardware AND verified the
# human (biometric or PIN) in one phishing-resistant, origin-bound ceremony. Stacking TOTP on
# top would add a replayable, phishable shared secret to a credential that is neither, for
# friction on every login. If a future operator disagrees, the change is small and local: move
# the browser-mfa-conditional-otp reference out of browser-mfa-password-and-otp and into
# browser-mfa-passkey-or-password's parent as a REQUIRED step, so it applies to both branches.
#
# WHY THERE IS NO `requiredActions` BLOCK IN THIS FILE — DO NOT ADD ONE
# --------------------------------------------------------------------
# Enrolment uses Keycloak's `webauthn-register-passwordless` required action, and the obvious
# move is to declare it here. DO NOT. In Keycloak 26.0.6 the realm import treats
# requiredActions as REPLACE, NOT MERGE: DefaultExportImportManager.importRealm branches on
# `if (rep.getRequiredActions() != null)` and, when the representation declares ANY, imports
# exactly those plus delete_account — the `else` branch that installs the full default set
# (DefaultRequiredActions.addActions) never runs. RealmManager.setupRequiredActions is no
# backstop either: it fires only when the realm has ZERO required-action providers.
#
# So declaring one action would create this realm with that action and delete_account and
# NOTHING ELSE — no CONFIGURE_TOTP, no UPDATE_PASSWORD, no VERIFY_EMAIL, no delete_credential,
# no UPDATE_PROFILE. The first casualty would be the MFA requirement this very flow enforces:
# with CONFIGURE_TOTP unregistered, a platform-admin who does not yet have TOTP could never
# enrol one, while auth-otp-form below still demands it — a hard lock-out of every
# platform-admin from every SSO-gated app, including Headlamp and kubectl, delivered by a
# change whose stated purpose was to ADD a login method.
#
# And the declaration buys nothing, because the default set ALREADY CONTAINS IT.
# DefaultRequiredActions registers `webauthn-register-passwordless` with enabled=true,
# defaultAction=false, priority 80, gated on Profile.Feature.WEB_AUTHN — which is Type.DEFAULT
# in 26.0.6, i.e. on unless explicitly disabled. enabled=true / defaultAction=false IS the
# additive-and-optional posture this change wants: the action exists and can be triggered for
# a user on demand, but it is not forced on anyone at next login. Declaring it would restate
# the default while deleting ten siblings.
#
# (The symmetric question for authenticationFlows has the opposite answer, which is why THIS
# block is safe: importAuthenticationFlows adds the declared flows and then backfills each
# unbound binding — browser, registration, direct grant, reset credentials, client auth, first
# broker login, docker auth — from the built-in defaults individually. Declaring flows does not
# suppress the built-ins; declaring required actions does suppress them.)
authenticationFlows:
- alias: browser-mfa
description: "Browser flow with conditional OTP required for platform-admins."
description: "Browser flow: passkey or password, with conditional OTP for platform-admins."
providerId: basic-flow
topLevel: true
builtIn: false
@@ -555,13 +782,63 @@ spec:
priority: 30
flowAlias: browser-mfa-forms
userSetupAllowed: false
# Identify the user FIRST (username only), then prove it. Both steps are REQUIRED: the
# username is never optional, and neither is proving possession of the account.
- alias: browser-mfa-forms
description: "Username/password then conditional OTP."
description: "Username, then passkey or password (+ conditional OTP)."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
- authenticator: auth-username-password-form
# auth-username-form = Keycloak's "Username Form" (UsernameFormFactory) — resolves the
# user WITHOUT taking a password, which is the prerequisite for offering a passkey.
# Replaces the previous auth-username-password-form; see the header for the UX cost.
- authenticator: auth-username-form
authenticatorFlow: false
requirement: REQUIRED
priority: 10
userSetupAllowed: false
- authenticatorFlow: true
requirement: REQUIRED
priority: 20
flowAlias: browser-mfa-passkey-or-password
userSetupAllowed: false
# THE FORK. Two ALTERNATIVEs, so satisfying either one completes the flow. Priority puts the
# passkey first for users who have one; users who do not fall through to the password branch
# automatically (CREDENTIAL_SETUP_REQUIRED is caught by the ALTERNATIVE loop — see header).
- alias: browser-mfa-passkey-or-password
description: "A passkey (WebAuthn passwordless), or password with conditional OTP."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
# webauthn-authenticator-passwordless = "WebAuthn Passwordless Authenticator"
# (WebAuthnPasswordlessAuthenticatorFactory). It reads the realm's PASSWORDLESS policy
# declared above — NOT the two-factor webAuthnPolicy* family. Keep it ALTERNATIVE: as
# REQUIRED it would force a passkey on every user, which is the lock-out this change is
# explicitly designed not to create.
- authenticator: webauthn-authenticator-passwordless
authenticatorFlow: false
requirement: ALTERNATIVE
priority: 10
userSetupAllowed: false
- authenticatorFlow: true
requirement: ALTERNATIVE
priority: 20
flowAlias: browser-mfa-password-and-otp
userSetupAllowed: false
# The unchanged pre-passkey behaviour, relocated intact into the password branch: password,
# then TOTP iff the user holds the platform-admin role. Nothing about the OTP condition
# changed; it simply no longer sits on the path a passkey login takes.
- alias: browser-mfa-password-and-otp
description: "Password, then conditional OTP for platform-admins."
providerId: basic-flow
topLevel: false
builtIn: false
authenticationExecutions:
# auth-password-form = Keycloak's "Password Form" (PasswordFormFactory) — the password
# half of the old combined form, now that the username has already been collected.
- authenticator: auth-password-form
authenticatorFlow: false
requirement: REQUIRED
priority: 10
@@ -0,0 +1,189 @@
---
# =============================================================================
# GENERATED FILE — DO NOT EDIT BY HAND, except the one bind-browser-flow line below.
#
# source: platform/keycloak/keycloak-realm-import.yaml (spec.realm)
# regenerate: python3 ci/render-keycloak-passkey-policy.py --write
# gated by: conformance/tests/l0-static/keycloak-passkey-reconciler
# (runs this renderer with --check on every merge)
#
# WHAT THIS IS: the DESIRED STATE for the `homelab` realm's PASSKEY configuration —
# the WebAuthn passwordless policy, the enrolment required action, and the declared
# browser authentication flow tree. platform/keycloak/passkey-reconciler.yaml mounts
# it and (a) converges the policy and the required action onto it, (b) builds the
# declared flow tree BLUE/GREEN under a new alias and verifies it by readback, never
# writing the flow users are logging in through, and (c) BINDS that verified flow as
# the realm's browserFlow ONLY when `bind-browser-flow` below is "true".
#
# EVERYTHING HERE IS GENERATED EXCEPT ONE LINE: `bind-browser-flow`. That is an
# operator decision, carried through by the renderer from this committed file (see
# ci/render-keycloak-passkey-policy.py). It defaults to "false".
#
# WHY IT IS A SEPARATE OBJECT: the realm import is a ONE-SHOT that never re-runs
# once Done=True, so a realm-level field or a flow edited in git never reaches the
# realm. Measured on prod 2026-10-18, hours after the passkey declaration merged:
# Done=True / Started=False, with the whole passkey block inert. The reconciler is
# the level-triggered loop that closes that; this file is what it reconciles
# TOWARDS.
#
# WHY IT IS GENERATED AND NOT AUTHORED: a hand-written copy of the RP ID, the
# user-verification requirement and the flow tree would be a second source of truth,
# free to disagree with the realm import — the same "two declarations that can
# disagree" shape as the bug being closed. It is rendered from the import and the
# render is gated, so the two cannot drift.
#
# STRINGS ARE VERBATIM: webAuthnPolicyPasswordlessRpId is the ${BASE_DOMAIN}
# substitution token exactly as the realm import writes it. Both files are rendered
# by the SAME Flux Kustomization (./platform-config), so the token resolves to the
# same value in both and the domain is decided in exactly one place.
#
# `requiredAction` is the ONE entry that is DERIVED rather than copied: the realm
# import deliberately declares no `requiredActions` block, because Keycloak 26.0.6
# treats that as REPLACE and declaring one action would delete CONFIGURE_TOTP and
# nine more (a hard lock-out of every platform-admin). It is derived instead from
# the presence of a webauthn-authenticator-passwordless execution in the flow:
# passkeys are offered, therefore enrolment must be enabled — and defaultAction is
# false, so enrolment stays opt-in.
#
# A field NOT declared on the realm in the import is simply ABSENT here, and the
# reconciler leaves it alone. Absence means UNMANAGED, never "force to a default".
# =============================================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: keycloak-passkey-desired-state
namespace: keycloak
labels:
app.kubernetes.io/name: keycloak-passkey-desired-state
app.kubernetes.io/part-of: keycloak
data:
# ---------------------------------------------------------------------------
# THE BIND SWITCH -- the ONLY hand-edited line in this file. Exactly "true" or
# "false", QUOTED.
#
# "false" the reconciler builds and verifies the candidate browser flow and
# logs `WOULD BIND <alias>`, but NEVER changes the realm's browserFlow.
# Whatever the realm is bound to -- including a rollback -- is left alone.
# "true" it binds the verified candidate in one atomic PUT, and keeps the
# realm bound to it: a binding changed by hand is changed back.
#
# GO LIVE: test the candidate in a browser first (platform/keycloak/README.md,
# "Passkeys"), then a PR setting this to "true".
# ROLL BACK: a PR setting this to "false" FIRST (or the next run rebinds), then
# one write setting browserFlow back to the previous alias from the Job log.
# ---------------------------------------------------------------------------
bind-browser-flow: "false"
passkey-desired-state.json: |
{
"authenticatorConfig": {
"conditional-otp-platform-admin": {
"condUserRole": "platform-admin",
"negate": "false"
}
},
"browserFlow": "browser-mfa",
"flows": {
"browser-mfa": {
"executions": [
{
"kind": "authenticator",
"name": "auth-cookie",
"requirement": "ALTERNATIVE"
},
{
"kind": "authenticator",
"name": "identity-provider-redirector",
"requirement": "ALTERNATIVE"
},
{
"kind": "flow",
"name": "browser-mfa-forms",
"requirement": "ALTERNATIVE"
}
],
"topLevel": true
},
"browser-mfa-conditional-otp": {
"executions": [
{
"configAlias": "conditional-otp-platform-admin",
"kind": "authenticator",
"name": "conditional-user-role",
"requirement": "REQUIRED"
},
{
"kind": "authenticator",
"name": "auth-otp-form",
"requirement": "REQUIRED"
}
],
"topLevel": false
},
"browser-mfa-forms": {
"executions": [
{
"kind": "authenticator",
"name": "auth-username-form",
"requirement": "REQUIRED"
},
{
"kind": "flow",
"name": "browser-mfa-passkey-or-password",
"requirement": "REQUIRED"
}
],
"topLevel": false
},
"browser-mfa-passkey-or-password": {
"executions": [
{
"kind": "authenticator",
"name": "webauthn-authenticator-passwordless",
"requirement": "ALTERNATIVE"
},
{
"kind": "flow",
"name": "browser-mfa-password-and-otp",
"requirement": "ALTERNATIVE"
}
],
"topLevel": false
},
"browser-mfa-password-and-otp": {
"executions": [
{
"kind": "authenticator",
"name": "auth-password-form",
"requirement": "REQUIRED"
},
{
"kind": "flow",
"name": "browser-mfa-conditional-otp",
"requirement": "CONDITIONAL"
}
],
"topLevel": false
}
},
"realm": "homelab",
"requiredAction": {
"alias": "webauthn-register-passwordless",
"defaultAction": false,
"enabled": true
},
"webAuthnPolicyPasswordless": {
"webAuthnPolicyPasswordlessAcceptableAaguids": [],
"webAuthnPolicyPasswordlessAttestationConveyancePreference": "none",
"webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified",
"webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister": true,
"webAuthnPolicyPasswordlessCreateTimeout": 300,
"webAuthnPolicyPasswordlessRequireResidentKey": "Yes",
"webAuthnPolicyPasswordlessRpEntityName": "Homelab SSO",
"webAuthnPolicyPasswordlessRpId": "${BASE_DOMAIN}",
"webAuthnPolicyPasswordlessSignatureAlgorithms": [
"ES256",
"RS256"
],
"webAuthnPolicyPasswordlessUserVerificationRequirement": "required"
}
}
File diff suppressed because it is too large. Load diff
+127
View File
@@ -0,0 +1,127 @@
# platform/kured
**kured (Kubernetes Reboot Daemon)** — coordinated, one-node-at-a-time OS reboots of the three prod
control-plane nodes.
- **Flux tier:** `clusters/base/kured.yaml` (its own Kustomization, `dependsOn: [platform]`,
`wait: true`)
- **Chart:** `kured` 6.1.0 → appVersion 1.23.0, from `https://kubereboot.github.io/charts`
- **The other half:** `infra/ansible/roles/unattended_upgrades` — the package upgrades. **Neither
half is useful alone.**
- **Runbook:** `/runbooks/node-reboot-automation`
- **Every tuned value and its reasoning:** `helmrelease.yaml`. That file is the argument; this one is
the orientation.
- **Design record:** `decisions/2026-10-17-automated-upgrades.md`
## What this component owns
The *mechanics* of a reboot: take a cluster-wide lock, cordon, drain, reboot, uncordon, hold the
lock a while longer. Nothing else.
**It does not decide whether to reboot.** kured reboots a node if and only if
`/var/run/reboot-required` exists there. On these nodes that file is written by a deliberate
operator kernel upgrade (`playbooks/node-baseline.yml --tags kernel`) — unattended-upgrades is
security-only with the kernel blacklisted, so it essentially never produces one. **The human gate is
the sentinel, not the daemon.**
## The problem this fixes
Rebooting these three nodes was a manual, serialized, supervised procedure, and
`roles/node_baseline` has to end its kernel run by shouting the rules at the operator: *"Reboot prod
nodes ONE AT A TIME, verify each returns before touching the next, and NEVER node 2 first"*. That is
a correct procedure and an error-prone one, and nothing enforced it.
What it has to get right, measured on prod 2026-10-17:
- **etcd quorum is 2 of 3** — all three nodes are control-plane+etcd. Two down is a lost cluster.
- **Every drain degrades all 25 Longhorn volumes to 2/3**, because 3 nodes × 3 replicas ×
`replica-soft-anti-affinity: false` leaves nowhere to place a replacement, and rebuild is
serialized behind a 600s wait. Reboot the next node too early and volumes hit 1/3, where
`block-if-contains-last-replica` can block a drain forever.
- **Six PodDisruptionBudgets allow zero voluntary disruptions** — 3 Longhorn `instance-manager-*`
(structurally 0 by design; this is how Longhorn gates the drain) and 3 CNPG `*-primary`.
## Placement — why its own Kustomization
`platform` is `wait: true` over twelve components and gates `platform-config` → every apps wave. A
reboot daemon that failed to go healthy there would stall **every application in the cluster** on
"dependency not ready". A component added to make maintenance safer must not be able to cause an
outage. `wait: true` here is a health **signal** for something nothing depends on — and nothing
should ever `dependsOn: [kured]`.
The `dependsOn: [platform]` edge is load-bearing, not conventional: it supplies the
`monitoring.coreos.com` CRDs this component's `PrometheusRule` needs (F-MON1 — a pass containing an
unknown kind aborts), plus the Prometheus that kured **queries** before every reboot.
## How concurrent reboots are made impossible
| mechanism | value | why |
|---|---|---|
| cluster-wide lock | one annotation on kured's own DaemonSet | a node reboots only while holding it; enforced by API-server optimistic concurrency, not timing |
| `concurrency` | `1` | one lock holder at a time |
| **`lockTtl`** | **`0` (disabled)** | a TTL lets reboots continue after the holder dies — on 3 etcd nodes that is the catastrophe. A hung node holds the lock **forever** and nothing cascades. |
| `lockReleaseDelay` | `30m` | the next node waits out Longhorn's 600s replenishment even if no alert latched |
| alert gate | `alertFilterMatchOnly: true` + anchored allow-list | refuses to proceed while a volume is degraded/faulted or a node is down |
| window | 03:00–06:00 daily, America/New_York | matches `renovate.json`'s window; daily (not weekly) is what makes `KuredRebootPending`'s `for: 36h` meaningful |
## Deliberate non-choices
- **No `--ignore-daemonsets` / `--delete-emptydir-data` / `--force`.** Those flags **do not exist**
in kured 1.23.0 — it hardcodes all three in the drain helper (`cmd/kured/main.go:438-449`).
Passing them would CrashLoop the DaemonSet. All three are genuinely required here (12 DaemonSets,
135 emptyDir pods).
- **`rebootMethod: command`, not `signal`.** `signal` would allow `privileged: false` + `CAP_KILL`,
but needs a host-side `kured-reboot.service` that does not exist on these nodes. A real future
hardening step; explicitly not taken here.
- **No tolerations.** No prod node is tainted (measured). A future tainted node would be silently
uncovered — hence `KuredNodeCoverageGap`.
- **`NodeEtcdRequestsSlow` / `NodeIOPressure*` are NOT in the alert gate**, despite being exactly
what you would want to pause for. prod-1 has a documented etcd WAL I/O starvation history and
including them risks a permanent wedge that looks like safety.
- **No Slack/shoutrrr notifications.** Would require a SOPS Secret and a decryption patch; the three
alerts cover the same ground through Alertmanager.
## Versioning
Chart pinned `>=6.1.0 <6.2.0`. The minor ceiling is **deliberate here**, unlike elsewhere in this
repo where it is a main cause of pin rot: a kured minor may change drain or lock semantics, and this
is the one component where "a human read the changelog" should be a precondition. `renovate.json`'s
`blast-radius` rule additionally forbids auto-merging it, so the ceiling is a review prompt, not a
wall.
## What breaks silently, and what catches it
Every failure mode of this component leaves a cluster that looks healthy — kured stays 1/1 Running
while rebooting nothing. `alerts.yaml` ships three rules over kured's single metric, with a
companion promtool suite:
| alert | catches |
|---|---|
| `KuredNotReporting` | the scrape pipeline is dead (chart defaults `service.create`/`metrics.create` to **false**; a NetworkPolicy can time the scrape out) — and the other two are inert while this is true |
| `KuredNodeCoverageGap` | kured missing on *some* node, which `absent()` cannot see |
| `KuredRebootPending` | a reboot was offered and blocked for >36h |
## Post-merge verification
```bash
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n kured get ds,pod,svc,servicemonitor -o wide'
infra/devcontainer/dev.sh run bash -c 'flux --context k3s-prod get kustomization kured'
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n kured logs ds/kured --tail=40'
```
Expect `3/3` ready, the Kustomization `Ready=True`, and kured logging a reboot-check decision each
period. Then confirm the scrape is real — this is the step that proves the alerts are not inert:
```bash
infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod -n monitoring exec \
sts/prometheus-kube-prometheus-stack-prometheus -c prometheus -- \
wget -qO- "http://localhost:9090/api/v1/query?query=count(kured_reboot_required)"'
```
**Expect `3`.** Anything else (and especially an empty result) means the Service, ServiceMonitor or
NetworkPolicy is wrong and all three alerts are silently dead.
**Before trusting the timer with a real kernel upgrade**, do one watched drain to settle the open
question the design record flags: whether CNPG actually switches its primary away in response to a
drain-induced eviction. If it does not, draining `k3s-prod-1` blocks on three 0-disruption PDBs at
once.
+282
View File
@@ -0,0 +1,282 @@
---
# promtool unit tests for the three rules in platform/kured/alerts.yaml.
#
# WHY THIS FILE EXISTS. The rules it covers are the ONLY thing that can tell an operator that the
# reboot automation has stopped working, and every failure mode they describe is silent — kured stays
# 1/1 Running while rebooting nothing. An alert that cannot fire is therefore not a cosmetic defect
# here, it is the removal of the only signal. This repo has shipped that bug in both directions
# already (a permanently-firing VPNTunnelDown; a structurally inert VeleroNoRecentBackup over an
# absent series), which is why the harness exists at all. Each rule below is pinned BOTH ways: quiet
# on the healthy state, firing on the constructed bad one, with the `for:` boundary asserted rather
# than assumed.
#
# HOW TO RUN. `platform/monitoring/tests/run.sh` — the one repo-wide rule-unit harness. It finds this
# file by the COMPANION convention (`<manifest>.promtool-test.yaml` beside `<manifest>.yaml`), so the
# manifest under test is exactly platform/kured/alerts.yaml and nothing else. Run just this file with
# platform/monitoring/tests/run.sh platform/kured/alerts.promtool-test.yaml
# The harness flattens the PrometheusRule CR's `.spec.groups` into the bare rules file promtool wants,
# `promtool check rules` it first so a non-parsing file cannot pass vacuously, and runs it all in a
# temp dir. It leaves Flux's `${BASE_DOMAIN}` in the runbook_url annotations verbatim, which is safe
# because the assertions below query the `ALERTS` series and never compare annotations.
#
# WHY THE ASSERTIONS QUERY `ALERTS` RATHER THAN USING `exp_alerts`. promtool's alert_rule_test
# compares annotations VERBATIM, which would mean pasting each rule's description prose in here and
# re-pasting it on every wording change. Querying the synthetic `ALERTS` series proves what actually
# matters — the alert reaches `pending` and then `firing` across its `for:` boundary, with the right
# labels — and stays decoupled from annotation text. Same choice as
# platform/cnpg/alerts.promtool-test.yaml.
#
# THE SERIES SHAPES ARE REAL. `kured_reboot_required{node=...}` is a GaugeVec with exactly one label,
# `node` — read from kured's cmd/kured/main.go:92-96 (the only registered metric in the project).
# `kube_node_info{node=...}` was measured on prod Prometheus on 2026-10-17 returning
# `count(kube_node_info) == 3`, matching the three nodes. The extra kube_node_info labels prod
# carries are omitted deliberately: every rule aggregates with `count()`, so they cannot affect the
# result, and inventing plausible-but-wrong values would be worse than leaving them out.
#
# evaluation_interval is 5m, not the usual 1m: KuredRebootPending has `for: 36h`, and at a 1m
# interval promtool would evaluate that test over 2000+ steps for no added coverage.
rule_files:
- alerts.rules.yaml
evaluation_interval: 5m
tests:
# ─────────────────────────────────────────────────────────────────────────────────────────────
# 1. THE HEALTHY STEADY STATE. Three nodes, kured running on all three, nothing pending.
# ALL THREE RULES MUST BE SILENT. This is the case that a badly-written `absent()` or a
# `count() <` comparison with an off-by-one gets wrong, and it is also the state the cluster
# is in ~100% of the time, so a false positive here would train the operator to ignore the
# whole group.
# ─────────────────────────────────────────────────────────────────────────────────────────────
- interval: 5m
name: healthy cluster - no reboots pending, kured on every node, all three rules quiet
input_series:
- series: 'kured_reboot_required{node="k3s-prod-1"}'
values: '0x60'
- series: 'kured_reboot_required{node="k3s-prod-2"}'
values: '0x60'
- series: 'kured_reboot_required{node="k3s-prod-3"}'
values: '0x60'
- series: 'kube_node_info{node="k3s-prod-1"}'
values: '1x60'
- series: 'kube_node_info{node="k3s-prod-2"}'
values: '1x60'
- series: 'kube_node_info{node="k3s-prod-3"}'
values: '1x60'
promql_expr_test:
# Nothing from this group may be pending or firing, at any point in the window.
- expr: 'count(ALERTS{alertname=~"Kured.*"})'
eval_time: 4h
exp_samples: []
# And the underlying expressions must each be genuinely false, not merely unevaluated:
# absent() yields no series because the vector is populated...
- expr: 'absent(kured_reboot_required)'
eval_time: 4h
exp_samples: []
# ...coverage is exact (3 == 3), so the `<` comparison drops the sample...
- expr: 'count(kured_reboot_required) < count(kube_node_info)'
eval_time: 4h
exp_samples: []
# ...and no node reports a pending reboot.
- expr: 'max by (node) (kured_reboot_required) == 1'
eval_time: 4h
exp_samples: []
# ─────────────────────────────────────────────────────────────────────────────────────────────
# 2. KuredNotReporting — THE WHOLE PIPELINE IS DEAD. No kured series at all, which is what the
# cluster looks like if the chart's `service.create`/`metrics.create` regress to their FALSE
# defaults, if the ServiceMonitor is dropped, or if a NetworkPolicy silently times out the
# scrape (the Longhorn precedent). The nodes still exist, so kube_node_info is present —
# proving the rule keys on kured's absence and not on a dead cluster.
# ─────────────────────────────────────────────────────────────────────────────────────────────
- interval: 5m
name: no kured series anywhere - KuredNotReporting fires after 30m
input_series:
- series: 'kube_node_info{node="k3s-prod-1"}'
values: '1x60'
- series: 'kube_node_info{node="k3s-prod-2"}'
values: '1x60'
- series: 'kube_node_info{node="k3s-prod-3"}'
values: '1x60'
promql_expr_test:
# The expression itself yields 1 immediately.
- expr: 'absent(kured_reboot_required)'
eval_time: 10m
exp_samples:
- labels: '{}'
value: 1
# `for: 30m` is honoured — still only pending at 25m.
- expr: 'ALERTS{alertname="KuredNotReporting"}'
eval_time: 25m
exp_samples:
- labels: 'ALERTS{alertname="KuredNotReporting", alertstate="pending", severity="warning"}'
value: 1
# ...and firing once past it.
- expr: 'ALERTS{alertname="KuredNotReporting", alertstate="firing"}'
eval_time: 35m
exp_samples:
- labels: 'ALERTS{alertname="KuredNotReporting", alertstate="firing", severity="warning"}'
value: 1
# THE GUARD CLAUSE: with kured absent, the other two rules are inert — exactly the reason
# this rule has to exist. KuredRebootPending cannot fire (no series to be 1) and
# KuredNodeCoverageGap cannot either, because `count()` over an empty vector returns NO
# SAMPLE rather than 0, so the `<` comparison has nothing to compare. Asserting that here
# pins the dependency: if a future edit made coverage-gap fire in this state, the overlap
# would be deliberate rather than accidental.
- expr: 'ALERTS{alertname="KuredRebootPending"}'
eval_time: 35m
exp_samples: []
- expr: 'ALERTS{alertname="KuredNodeCoverageGap"}'
eval_time: 35m
exp_samples: []
- expr: 'count(kured_reboot_required) < count(kube_node_info)'
eval_time: 35m
exp_samples: []
# ─────────────────────────────────────────────────────────────────────────────────────────────
# 3. KuredNodeCoverageGap — kured COVERS ONLY TWO OF THREE NODES. k3s-prod-3's series stops at
# 1h and never returns (a node taint this DaemonSet does not tolerate, a node that never came
# back from a reboot, a DaemonSet nodeSelector edit). This also pins the `for: 2h` choice,
# which exists SO THAT AN ORDINARY REBOOT DOES NOT TRIP IT: the gap is real from ~1h05 but
# the alert is only pending an hour later, and a healthy reboot is minutes.
# ─────────────────────────────────────────────────────────────────────────────────────────────
- interval: 5m
name: kured missing on one node - KuredNodeCoverageGap fires after 2h, not before
input_series:
- series: 'kured_reboot_required{node="k3s-prod-1"}'
values: '0x60'
- series: 'kured_reboot_required{node="k3s-prod-2"}'
values: '0x60'
# Ends after 1h (13 samples at 5m) and is stale thereafter.
- series: 'kured_reboot_required{node="k3s-prod-3"}'
values: '0x12'
- series: 'kube_node_info{node="k3s-prod-1"}'
values: '1x60'
- series: 'kube_node_info{node="k3s-prod-2"}'
values: '1x60'
- series: 'kube_node_info{node="k3s-prod-3"}'
values: '1x60'
promql_expr_test:
# Before the gap opens, coverage is exact and nothing is pending.
- expr: 'ALERTS{alertname="KuredNodeCoverageGap"}'
eval_time: 30m
exp_samples: []
# The shortfall is real well before the alert fires — 2 < 3.
- expr: 'count(kured_reboot_required)'
eval_time: 2h
exp_samples:
- labels: '{}'
value: 2
# A SHORT OUTAGE DOES NOT ALERT: still only pending at 2h, an hour after the gap opened.
- expr: 'ALERTS{alertname="KuredNodeCoverageGap"}'
eval_time: 2h
exp_samples:
- labels: 'ALERTS{alertname="KuredNodeCoverageGap", alertstate="pending", severity="warning"}'
value: 1
# Firing once the gap has persisted past `for: 2h`.
- expr: 'ALERTS{alertname="KuredNodeCoverageGap", alertstate="firing"}'
eval_time: 3h15m
exp_samples:
- labels: 'ALERTS{alertname="KuredNodeCoverageGap", alertstate="firing", severity="warning"}'
value: 1
# KuredNotReporting must stay QUIET: two nodes are still publishing, so the vector is not
# absent. This is the distinction between "kured is gone" and "kured is incomplete", and
# getting it wrong in either direction would collapse two different diagnoses into one.
- expr: 'ALERTS{alertname="KuredNotReporting"}'
eval_time: 3h15m
exp_samples: []
# ─────────────────────────────────────────────────────────────────────────────────────────────
# 4. KuredRebootPending — A REBOOT IS BLOCKED. k3s-prod-1 reports a pending reboot continuously:
# the sentinel was written (an operator kernel upgrade) and the reboot has not happened across
# more than one 03:00-06:00 window. Asserts the 36h boundary in both directions, which is the
# assertion that makes the DAILY-window decision load-bearing rather than cosmetic: on a
# weekly window this threshold could not distinguish "blocked" from "waiting for Sunday".
# Series run to 38h20m (456 samples at 5m) so the post-boundary evaluation has real data.
# ─────────────────────────────────────────────────────────────────────────────────────────────
- interval: 5m
name: reboot pending on one node for over 36h - KuredRebootPending fires, per-node label kept
input_series:
- series: 'kured_reboot_required{node="k3s-prod-1"}'
values: '1x456'
- series: 'kured_reboot_required{node="k3s-prod-2"}'
values: '0x456'
- series: 'kured_reboot_required{node="k3s-prod-3"}'
values: '0x456'
- series: 'kube_node_info{node="k3s-prod-1"}'
values: '1x456'
- series: 'kube_node_info{node="k3s-prod-2"}'
values: '1x456'
- series: 'kube_node_info{node="k3s-prod-3"}'
values: '1x456'
promql_expr_test:
# The expression selects ONLY the pending node — the operator needs to know which box.
- expr: 'max by (node) (kured_reboot_required) == 1'
eval_time: 1h
exp_samples:
- labels: '{node="k3s-prod-1"}'
value: 1
# A NORMAL WAIT IS NOT AN ALERT. At 24h the node is still inside its legitimate
# once-per-day window wait, so the alert may be pending but must NOT be firing.
- expr: 'ALERTS{alertname="KuredRebootPending", alertstate="firing"}'
eval_time: 24h
exp_samples: []
# Still not firing just short of the boundary.
- expr: 'ALERTS{alertname="KuredRebootPending", alertstate="firing"}'
eval_time: 35h30m
exp_samples: []
# Firing past it, carrying the node label through to the notification.
- expr: 'ALERTS{alertname="KuredRebootPending", alertstate="firing"}'
eval_time: 36h30m
exp_samples:
- labels: 'ALERTS{alertname="KuredRebootPending", alertstate="firing", node="k3s-prod-1", severity="warning"}'
value: 1
# The two healthy nodes must NOT be alerted on. A rule written with `kured_reboot_required`
# unaggregated, or compared with `>= 0`, would raise all three and bury the real one.
- expr: 'count(ALERTS{alertname="KuredRebootPending", alertstate="firing"})'
eval_time: 36h30m
exp_samples:
- labels: '{}'
value: 1
# Liveness rules stay quiet: kured is present and covering all three nodes. A pending reboot
# is NOT a wiring fault and must not be reported as one.
- expr: 'ALERTS{alertname="KuredNotReporting"}'
eval_time: 36h30m
exp_samples: []
- expr: 'ALERTS{alertname="KuredNodeCoverageGap"}'
eval_time: 36h30m
exp_samples: []
# ─────────────────────────────────────────────────────────────────────────────────────────────
# 5. THE RULE MUST STOP. A reboot that SUCCEEDS clears the sentinel, and the alert must clear
# with it — the other half of the failure space this harness exists for (a permanently-firing
# rule is how VPNTunnelDown taught this repo the lesson). k3s-prod-1 is pending for ~40h,
# long enough to fire, then the reboot happens and the gauge returns to 0.
# ─────────────────────────────────────────────────────────────────────────────────────────────
- interval: 5m
name: reboot completes after firing - KuredRebootPending resolves
input_series:
# 1 for 480 samples (40h), then 0 for the remainder.
- series: 'kured_reboot_required{node="k3s-prod-1"}'
values: '1x480 0x120'
- series: 'kured_reboot_required{node="k3s-prod-2"}'
values: '0x600'
- series: 'kured_reboot_required{node="k3s-prod-3"}'
values: '0x600'
- series: 'kube_node_info{node="k3s-prod-1"}'
values: '1x600'
- series: 'kube_node_info{node="k3s-prod-2"}'
values: '1x600'
- series: 'kube_node_info{node="k3s-prod-3"}'
values: '1x600'
promql_expr_test:
# Firing while the reboot is still outstanding.
- expr: 'ALERTS{alertname="KuredRebootPending", alertstate="firing"}'
eval_time: 39h
exp_samples:
- labels: 'ALERTS{alertname="KuredRebootPending", alertstate="firing", node="k3s-prod-1", severity="warning"}'
value: 1
# Cleared once the sentinel is gone. If this assertion ever fails, the rule has become
# self-sustaining and the operator will start ignoring it.
- expr: 'ALERTS{alertname=~"Kured.*"}'
eval_time: 45h
exp_samples: []
+124
View File
@@ -0,0 +1,124 @@
---
# Alert rules for the kured reboot automation.
#
# WHY THESE THREE, AND WHY THEY ARE NOT OPTIONAL. Reboot automation's characteristic failure is
# SILENCE, not noise. Every way this component can break leaves a cluster that looks perfectly
# healthy: a sentinel nothing writes, a Service the chart did not create, a NetworkPolicy that drops
# the scrape, a taint that excludes a node, a drain wedged on a PodDisruptionBudget, a cluster lock
# held forever by a node that hung on warm reboot. In none of those cases does anything turn red on
# its own. kured itself stays 1/1 Running throughout.
#
# kured EXPOSES EXACTLY ONE METRIC. Read from cmd/kured/main.go:92-96 and :113 on 2026-10-17 — the
# only `prometheus.MustRegister` in the tree is a GaugeVec `Subsystem: "kured"`, `Name:
# "reboot_required"`, labelled `{node}`. So the full vocabulary available is
# `kured_reboot_required{node="..."}`, 1 when the sentinel exists and 0 when it does not. Each rule
# below is built from that one series plus `kube_node_info` (kube-state-metrics), and nothing else.
# There is deliberately no rule over an invented metric name: this repo has shipped a
# structurally-inert alert before (VeleroNoRecentBackup over an absent series, which is why
# platform/monitoring/tests/run.sh exists), and an alert that cannot fire is worse than no alert
# because it also removes the suspicion that would have caught it.
#
# THESE RULES LIVE HERE, NOT IN platform/monitoring/alerts.yaml, for two reasons. (1) The unit-test
# harness treats a `<manifest>.promtool-test.yaml` COMPANION as covering just its sibling, whereas a
# `tests/*.test.yaml` SUITE covers every PrometheusRule in its directory — so shipping them beside
# the component keeps the test obligation local and legible (the platform/cnpg precedent). (2)
# platform/monitoring/alerts.yaml is concurrently edited by other open branches; adding 3 rules to
# its tail would create a merge conflict for no benefit.
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: kured
namespace: kured
labels:
app.kubernetes.io/name: kured
spec:
groups:
- name: platform-health.node-reboots
rules:
# ── 1. IS THE MECHANISM ALIVE AT ALL? ────────────────────────────────────────────────
- alert: KuredNotReporting
# `absent()` yields a series only when the ENTIRE vector is empty, so this fires when no
# node at all is publishing — kured gone, or its Service/ServiceMonitor/NetworkPolicy
# regressed so nothing is scraped. It stays quiet while a single node is mid-reboot
# (the other two still report), which is what makes it specific to a wiring failure
# rather than to a reboot in progress.
#
# THIS IS THE RULE THAT GUARDS THE OTHER TWO. Both of them read `kured_reboot_required`,
# so if that series stops existing they go quiet and look like good news. This one turns
# the absence itself into the signal. The specific regression it is aimed at: the kured
# chart defaults `service.create` and `metrics.create` to FALSE, and Longhorn has already
# demonstrated on this cluster (see the companion note in
# conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh) that a
# NetworkPolicy can let targets be DISCOVERED while every scrape times out and the TSDB
# stays empty.
expr: absent(kured_reboot_required)
# 30m absorbs a HelmRelease upgrade rolling the DaemonSet and a Prometheus restart,
# without tolerating a real wiring break for more than one quiet night's worth of time.
for: 30m
labels:
severity: warning
annotations:
summary: kured is publishing no reboot_required series on any node
description: >-
No node is reporting kured_reboot_required, so the reboot automation is either not
running or not being scraped. While this is true, OS reboots are not happening and
the two alerts that would say so (KuredRebootPending, KuredNodeCoverageGap) are
themselves inert. Nodes will silently accumulate pending kernel upgrades.
runbook_url: "https://${BASE_DOMAIN}/runbooks/node-reboot-automation"
# ── 2. IS IT ALIVE ON *EVERY* NODE? ──────────────────────────────────────────────────
- alert: KuredNodeCoverageGap
# A per-node agent that silently covers only some nodes is the subtler half of the
# liveness question, and `absent()` cannot see it. kured publishes one series per node it
# runs on; kube-state-metrics publishes one `kube_node_info` per node that exists
# (measured 2026-10-17: 3, matching the 3 nodes). A shortfall means at least one node has
# no reboot automation — most likely a new or re-provisioned node carrying a taint this
# DaemonSet does not tolerate (it declares none, because no prod node is tainted today).
# That node would then never reboot, with nothing else to indicate it.
expr: count(kured_reboot_required) < count(kube_node_info)
# 2h, chosen to sit comfortably ABOVE the duration of a healthy reboot. A node being
# rebooted stops publishing for a few minutes and legitimately creates a shortfall; a
# node that is still missing after two hours is either genuinely uncovered or has hung on
# reboot, and both of those want a human.
for: 2h
labels:
severity: warning
annotations:
summary: kured is not running on every node
description: >-
Fewer nodes are reporting kured_reboot_required than exist in the cluster, so at
least one node has no reboot automation and will never be rebooted by it. Most
likely a node carries a taint this DaemonSet does not tolerate, or has hung during
a reboot.
runbook_url: "https://${BASE_DOMAIN}/runbooks/node-reboot-automation"
# ── 3. IS A REBOOT STUCK? ────────────────────────────────────────────────────────────
- alert: KuredRebootPending
# A node has wanted a reboot for longer than a full maintenance window plus margin, so
# the reboot was OFFERED and did not happen. The causes are all real and all invisible
# otherwise: the Prometheus alert gate is blocking on a degraded Longhorn volume that is
# not recovering; the drain hit `drainTimeout` against a 0-disruption PDB; another node
# holds the cluster lock and never released it (`lockTtl: 0` means a lock held by a hung
# node is held FOREVER — that is deliberate, see helmrelease.yaml, and this is the alert
# that makes the resulting stall visible).
expr: max by (node) (kured_reboot_required) == 1
# 36h, and the window being DAILY is what makes this number meaningful — see the window
# block in helmrelease.yaml. With a 03:00-06:00 window every day, a pending reboot waits
# at most ~24h legitimately, so 36h means at least one full window elapsed without the
# reboot completing. On a weekly window this threshold would have to exceed 7 days, which
# is longer than this cluster's 7d Prometheus retention and is reset by any Prometheus
# restart past the 1h for-outage-tolerance — i.e. unreliable in the one direction that
# matters. Note this alert can still be LATE for that reason, but it cannot be spurious.
for: 36h
labels:
severity: warning
annotations:
summary: Node {{ $labels.node }} has needed a reboot for over 36h
description: >-
kured reports a pending reboot on {{ $labels.node }} that has survived at least one
full 03:00-06:00 maintenance window. The reboot is being blocked, not merely
waiting: likely a degraded Longhorn volume holding the Prometheus alert gate, a
drain timing out against a PodDisruptionBudget, or a stale cluster lock held by a
node that did not come back. The kernel or security update that needed the reboot is
installed but NOT running.
runbook_url: "https://${BASE_DOMAIN}/runbooks/node-reboot-automation"
+289
View File
@@ -0,0 +1,289 @@
---
# kured (Kubernetes Reboot Daemon) — coordinated, one-node-at-a-time reboots of the three prod
# control-plane nodes.
#
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# READ THIS FIRST: kured DOES NOT DECIDE *WHETHER* TO REBOOT. IT ONLY DECIDES *WHEN* AND *HOW*.
# ═══════════════════════════════════════════════════════════════════════════════════════════════
# kured reboots a node if and only if the file `/var/run/reboot-required` exists on it. On these
# nodes exactly one thing creates that file: a deliberate operator run of
# `playbooks/node-baseline.yml --tags kernel` (which advances the backports kernel). The
# unattended-upgrades role this PR also adds installs SECURITY updates only and BLACKLISTS
# `linux-image-*`/`linux-headers-*`, so it can never produce a reboot-required sentinel on its own —
# see infra/ansible/roles/unattended_upgrades/defaults/main.yml for that argument in full.
#
# That is the whole answer to this repo's two standing objections to reboot automation:
#
# * infra/ansible/roles/node_baseline/tasks/main.yml:61-67 — "IT DOES NOT REBOOT." k3s-prod-2 hung
# on a warm reboot on 2026-09-03 (powered, off the LAN ~38 minutes, physical power button
# required), so rebooting was made "a SERIALIZED, SUPERVISED operator step ... and a role cannot
# enforce that." STILL TRUE, and nothing here changes it: node_baseline continues not to reboot.
# * docs/plans/cluster-upgrade-plan.md:291-299 — system-upgrade-controller was rejected because it
# "automates away the human gate between nodes", and the operator's hard requirement is an
# explicit PASS/STOP verification BETWEEN every node.
#
# THE HUMAN GATE IS PRESERVED, AND IT IS THE SENTINEL. The operator decides whether a kernel moves
# and on which hosts (`--limit k3s-prod-1`), which is what writes the sentinel on those hosts and
# only those hosts. kured then performs the cordon/drain/reboot/uncordon that the operator would
# otherwise have typed by hand — the error-prone mechanical half, including the ordering rule
# node_baseline's own message has to shout ("NEVER node 2 first"). Run the kernel play one host at a
# time and the operator keeps per-node PASS/STOP control exactly as before; run it on all three and
# kured still does them strictly one at a time, just in an order it chooses. Both are safe for
# quorum; only the first preserves the ordering preference. See runbooks/node-reboot-automation.md.
#
# cluster-upgrade-plan.md:329-330 and :545-548 name the condition under which its own rejection
# reverses — "SUC becomes viable once the cluster is on fast disks ... where drain-driven rebuild is
# cheap" — and prod is that cluster. This component takes that clause up, but NOT its drain
# assumption: see the Longhorn section below, where drain-driven rebuild is measurably NOT free even
# on SSD, and is gated on explicitly rather than waved through.
#
# WHAT IS STILL UNMITIGATED, STATED PLAINLY: kured cannot press a power button. If a node hangs on
# warm reboot the way k3s-prod-2 did in September, kured will not recover it. The blast radius is
# bounded by design rather than by luck — `concurrency: 1` plus `lockTtl: 0` means the hung node
# keeps the cluster lock forever, so NO second node is ever rebooted behind it; etcd keeps 2/3 quorum
# and the cluster stays up, degraded, until a human attends. The KuredRebootPending alert in
# alerts.yaml is what tells them. That is strictly better than the previous automation story (none)
# but it is NOT the same as "safe to ignore at 3am on a Sunday".
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: kured
namespace: kured
spec:
interval: 30m
chart:
spec:
chart: kured
# Chart 6.1.0 -> appVersion 1.23.0 (published 2026-06-30; newest of the 80 entries in
# https://kubereboot.github.io/charts/index.yaml, read 2026-10-17).
#
# THE `<6.2.0` CEILING IS A DELIBERATE CHOICE HERE, NOT THE REFLEX IT IS ELSEWHERE. This PR's
# own audit found that 9 of 12 HelmReleases in this repo pin a minor-locking range, and that
# this is a MAIN CAUSE of pin rot: Renovate can then only ever offer patches inside a line it
# has been told not to leave (kube-prometheus-stack is stuck 4 chart majors back that way).
# For a component whose job is to reboot control-plane nodes, that ceiling is worth paying on
# purpose: a kured MINOR may change drain or lock semantics, and this is the one component
# where "a human looked at the changelog" should be a precondition, not a nice-to-have.
# Renovate will raise the minor as a non-automerged PR (see the kured packageRule in
# renovate.json), so the ceiling is a review prompt, not a wall.
version: ">=6.1.0 <6.2.0"
sourceRef:
kind: HelmRepository
name: kured
namespace: kured
install:
remediation:
retries: 3
upgrade:
remediation:
retries: 3
values:
# ── IDENTITY / PLACEMENT ───────────────────────────────────────────────────────────────────
# No tolerations. Measured 2026-10-17: all three prod nodes carry `spec.taints` EMPTY (k3s does
# not taint its servers), so a DaemonSet reaches every node untainted. If a future node IS
# tainted, kured silently stops covering it and that node then never reboots — invisible,
# because the other two keep reporting. KuredNodeCoverageGap in alerts.yaml is the detector: it
# compares kured's per-node series count against the node count rather than merely asking
# whether kured exists.
priorityClassName: system-node-critical
# The chart's defaults are kept: `hostPID: true` and `containerSecurityContext.privileged: true`
# with `readOnlyRootFilesystem: true`. Both are load-bearing and neither is reducible:
# `rebootMethod: command` runs the reboot by nsenter'ing /proc/1/ns/mnt, which needs the host PID
# namespace to see PID 1 and the privilege to enter its mount namespace. The documented
# lower-privilege alternative (privileged:false + CAP_KILL) applies ONLY to
# `rebootMethod: signal`, which signals a host-side kured-reboot.service that does not exist on
# these nodes. Choosing `signal` would mean shipping and maintaining that unit via Ansible for a
# capability reduction on a DaemonSet that is already cluster-admin-adjacent by function; it is a
# reasonable future hardening step and is explicitly NOT taken here.
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
# No CPU limit on purpose (the repo's node-pressure work treats CPU limits on tiny system
# agents as a throttling hazard). Memory is capped: kured holds no state beyond one
# annotation and a gauge, and 128Mi is ~4x its idle RSS upstream reports.
memory: 128Mi
configuration:
# ── THE QUORUM CONSTRAINT: TWO NODES MUST NEVER BE DOWN AT ONCE ──────────────────────────
# All three prod nodes are control-plane + etcd (measured 2026-10-17: every node reports
# `control-plane,etcd`). etcd quorum is 2 of 3, so one node down is degraded-but-up and two
# nodes down is a LOST CLUSTER that does not self-heal. Everything in this block exists to
# make the second case structurally unreachable rather than merely unlikely.
#
# concurrency: 1 — kured's cluster-wide lock is a single annotation
# (`weave.works/kured-node-lock`) on kured's OWN DaemonSet object, and a node reboots only
# while it holds that lock. One lock + concurrency 1 = one reboot, enforced by the API server's
# optimistic concurrency rather than by timing. This is the chart default; it is written out
# explicitly because it is the single most important value in this file.
concurrency: 1
# lockTtl: 0 (DISABLED) — THIS IS A SAFETY DECISION, NOT A DEFAULT WE FORGOT TO TUNE.
# A TTL makes the lock EXPIRE, which exists for the case "the lock holder died and we want
# reboots to continue". On a 3-node etcd cluster that is precisely the catastrophe: if node A
# has hung on a warm reboot (which has happened here — see the header) and its lock expires,
# node B becomes eligible and takes the cluster from 2/3 to 1/3. We would rather the reboot
# cycle STOP FOREVER and page a human than have it step over a corpse. The cost is that a
# genuinely stale lock needs manual clearing; runbooks/node-reboot-automation.md has the
# procedure, and KuredRebootPending is the alert that surfaces it.
lockTtl: 0
# lockReleaseDelay: 30m — hold the lock for 30 minutes AFTER this node is back and uncordoned,
# before any other node may take it. See the Longhorn section: this is the STRUCTURAL half of
# the inter-node gate, deliberately not dependent on an alert's `for:` timer having latched.
lockReleaseDelay: 30m
# ── THE MAINTENANCE WINDOW: 03:00-06:00 EVERY DAY, America/New_York ──────────────────────
# The timezone and the 03:00-06:00 band match renovate.json's existing
# `"schedule": ["after 3am and before 6am"]` / `"timezone": "America/New_York"`, so every
# unattended change in this repo lands in the same quiet 3-hour window.
#
# DAILY, NOT WEEKLY, AND THE REASON IS THE ALERT — not impatience. A weekly (`- sun`) window
# was the first draft and it breaks the only alert that can tell you this mechanism has
# stalled. With a Sunday-only window, a kernel upgraded on a Monday legitimately waits six
# days, so "a reboot has been pending too long" cannot be distinguished from "it is Tuesday"
# until the threshold exceeds a full 7-day cycle. That forces `for: 8d`+, which is longer than
# this cluster's Prometheus retention (MONITORING_PROMETHEUS_RETENTION is 7d) and is reset by
# any Prometheus restart beyond the 1h default for-outage-tolerance — i.e. the alert would be
# unreliable in exactly the direction that matters. A daily window caps the legitimate wait at
# under 24h, which makes KuredRebootPending's `for: 36h` both robust and meaningful: 36 hours
# with a sentinel still set means at least one full window passed and SOMETHING BLOCKED IT.
#
# Daily does NOT weaken the quorum guarantee — that is `concurrency: 1` plus the lock, not the
# calendar. It does mean a 3-node kernel rollout can finish in one to three nights instead of
# three weeks, with each node still gated on the previous one's Longhorn rebuilds (below).
rebootDays:
- su
- mo
- tu
- we
- th
- fr
- sa
startTime: "03:00"
endTime: "06:00"
timeZone: America/New_York
# How often to re-evaluate (sentinel + window + alerts + lock). 1h is the chart default; at a
# 3h window that is three evaluation opportunities per node per night.
period: 1h
# ── THE SENTINEL ─────────────────────────────────────────────────────────────────────────
# Written ONLY by a deliberate operator kernel upgrade (see the header). `update-notifier-common`
# — the package that owns /etc/kernel/postinst.d and actually creates this file on Debian — was
# measured ABSENT on all three nodes on 2026-10-17, which is why the unattended_upgrades role
# installs it. Without that package nothing ever writes this path, kured correctly never
# reboots, every probe looks healthy, and the entire mechanism is a silent no-op. That failure
# mode is the reason KuredNotReporting exists.
#
# THE PATH kured LOGS IS NOT THIS PATH, and that trips people debugging it. The chart
# defaults `useRebootSentinelHostPath: true` (kept), which bind-mounts the DIRNAME of this
# value onto /sentinel read-only and renders the flag as
# `--reboot-sentinel=/sentinel/reboot-required` (daemonset.yaml:134). So the container watches
# /sentinel/reboot-required and the host file is /var/run/reboot-required. Same file, two
# names. Set this value as the HOST path, as below.
rebootSentinel: /var/run/reboot-required
# ── THE LONGHORN GATE: WHY POD READINESS IS NOT A SUFFICIENT INTER-NODE SIGNAL ────────────
# Measured on prod 2026-10-17, and this is the finding that shaped this file:
# * 25 Longhorn volumes, ALL `numberOfReplicas: 3`, all healthy, with exactly 25 replicas on
# each of the 3 nodes. So there is no single-replica volume to lose today.
# * BUT `replica-soft-anti-affinity: false` (replicas may not share a node) and
# `disable-scheduling-on-cordoned-node: true`. With 3 nodes and 3 replicas, cordoning ONE
# node leaves nowhere to place a replacement — so every drain takes ALL 25 volumes to
# degraded 2/3 for the whole reboot.
# * Recovery is slow and serialized: `replica-replenishment-wait-interval: 600` (10 minutes
# before a rebuild even starts) and `concurrent-replica-rebuild-per-node-limit: 1`.
# Therefore: if the next node is rebooted before rebuilds finish, those volumes go to 1/3, and
# the drain after that genuinely CAN hit Longhorn's `node-drain-policy:
# block-if-contains-last-replica` and block forever. The inter-node gate must be VOLUME
# ROBUSTNESS, not "are the pods Ready again".
#
# It is gated twice, on purpose, because each half covers the other's blind spot:
# 1. lockReleaseDelay: 30m (above) — unconditional. Covers the 600s replenishment wait with
# margin and does not depend on any alert having fired.
# 2. The Prometheus alert gate below — semantic. Covers a rebuild that takes LONGER than 30
# minutes (25 serialized rebuilds on one node can), which a fixed delay cannot.
prometheusUrl: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090
# Only consider alerts that are FIRING, not merely pending. A `pending` alert is a 10-minute
# guess; blocking a weekly reboot on one would make the gate fire on noise.
alertFiringOnly: true
# ── alertFilterMatchOnly: true IS LOAD-BEARING. DO NOT DROP IT. ──────────────────────────
# kured's two alert modes are near-opposites and the flag name does not advertise it:
# * false (the chart default): alertFilterRegexp is an IGNORE list — kured blocks on ANY
# firing alert except those matching.
# * true: alertFilterRegexp is an ALLOW list — kured blocks ONLY on alerts that match.
# We need `true`, because this cluster always has something firing. Measured via the Prometheus
# API on 2026-10-17, these five were firing with no reboot pending and no end in sight:
# CNPGPrimariesCoLocated, KubeDeploymentReplicasMismatch, KubeDeploymentRolloutStuck,
# KubePodNotReady, OAuth2ProxyGateUnobserved (the last three all trace to the pm-agent pod,
# ImagePullBackOff on a placeholder digest for 12 days). Under the default `false`, kured would
# have been blocked by those on day one and would never have rebooted anything — a silent
# no-op indistinguishable, from outside, from "no reboots were needed".
alertFilterMatchOnly: true
# The ALLOW list: the conditions under which rebooting ANOTHER node risks data or quorum.
# Anchored and explicit — a prefix match here would silently re-admit the chronic five above.
# LonghornVolumeDegraded — the inter-node gate (see above). `longhorn_volume_robustness
# {state="degraded"} == 1`, for: 10m, in platform/monitoring/alerts.yaml.
# LonghornVolumeFaulted — data already at risk; never reboot into this.
# KubeNodeNotReady — a node is already down. Rebooting a second one loses quorum. This
# is the single most important entry for the etcd constraint.
# NodeExporterDown — a node we cannot see the health of. Treated as "assume the worst".
# DELIBERATELY NOT INCLUDED: NodeEtcdRequestsSlow and the NodeIOPressure* family. They are
# exactly the conditions you would want to pause for, but prod-1 has a documented history of
# etcd WAL I/O starvation, so including them risks a permanent wedge that looks like safety.
# Revisit once those are quiet for a sustained period; KuredRebootPending makes a wedge visible
# either way.
alertFilterRegexp: ^(LonghornVolumeDegraded|LonghornVolumeFaulted|KubeNodeNotReady|NodeExporterDown)$
# ── THE DRAIN ────────────────────────────────────────────────────────────────────────────
# NOTE ON FLAGS THAT DO NOT EXIST: there is no `--ignore-daemonsets`, no
# `--delete-emptydir-data` and no `--force` in kured 1.23.0. Read from
# cmd/kured/main.go:438-449 on 2026-10-17, kured HARDCODES
# `IgnoreAllDaemonSets: true`, `DeleteEmptyDirData: true` and `Force: true` in the drain
# helper. Passing them as extraArgs would CrashLoop the DaemonSet on an unknown flag. This
# matters because all three are genuinely REQUIRED here and it is easy to assume they must be
# configured: 12 DaemonSets and 135 emptyDir-carrying pods were measured on 2026-10-17, and a
# drain without those three settings refuses to start.
#
# drainTimeout: 15m — the chart/kured default is 0, meaning INFINITE, and infinite is the wrong
# answer here. Measured 2026-10-17, six PodDisruptionBudgets currently allow ZERO voluntary
# disruptions, and a plain drain loops on them indefinitely:
# * 3x longhorn-system/instance-manager-* — one per node, `minAvailable: 1` against a
# selector pinned to a single node's single pod, so disruptionsAllowed is STRUCTURALLY 0
# forever. This is the mechanism by which Longhorn's node-drain-policy blocks: the
# longhorn-manager deletes the node's instance-manager PDB only once it judges the drain
# safe. The drain is SUPPOSED to wait on this one.
# * 3x CNPG `<cluster>-primary` (keycloak-db, nextcloud-db, semaphore-db) — `minAvailable: 1`
# against a selector matching exactly one pod (the primary), so again structurally 0 until
# the CNPG operator switches the primary away. All three primaries were on k3s-prod-1.
# With a timeout, a drain that is genuinely stuck ABORTS, the lock is released, and kured
# retries next period instead of holding the cluster lock forever on a wedged node. 15m is
# chosen to be longer than a CNPG switchover and longer than the `unifi` VM's eviction (below)
# usually needs, while still being well inside the 3h window.
drainTimeout: 15m
# Taint a node PreferNoSchedule while its reboot is pending, so the scheduler stops placing new
# pods on a node that is about to go away. With concurrency 1 this is a small win (there is no
# second rebooting node to shed pods onto) but it is free and it shortens the drain.
preferNoScheduleTaint: weave.works/kured-node-reboot
# Record reboot state on the Node objects (`weave.works/kured-reboot-in-progress`,
# `weave.works/kured-most-recent-reboot-needed`). Off by default. Turned on because the only
# other observability kured offers is ONE gauge, and during an incident `kubectl get node -o
# yaml` is the thing an operator can reach without Prometheus.
annotateNodes: true
logFormat: json
# ── METRICS: BOTH OF THESE MUST BE true OR THE ALERTS IN alerts.yaml CANNOT FIRE ───────────
# The chart ships `service.create: false` AND `metrics.create: false`. Leaving either at its
# default means no Service, or a ServiceMonitor with nothing to select, so `kured_reboot_required`
# is never scraped — and a PrometheusRule over a series that no target produces is INERT: quiet
# forever, indistinguishable from healthy. This repo has shipped that exact bug twice
# (VeleroNoRecentBackup over an absent series; the inert gotk_* rules in
# runbooks/image-automation.md), which is why platform/monitoring/tests/run.sh exists at all.
# KuredNotReporting (alerts.yaml) is the standing detector for this pair regressing.
# Prometheus selects ServiceMonitors cluster-wide: measured 2026-10-17, `serviceMonitorSelector`
# and `serviceMonitorNamespaceSelector` are both `{}`, so no label plumbing is needed here.
service:
create: true
port: 8080
metrics:
create: true
interval: 60s
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more