Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
553d027f7c | ||
|
|
ce849032d5 | ||
|
|
b766130b85 | ||
|
|
862773f31b | ||
|
|
db4ba79a1c | ||
|
|
0b6282aba4 | ||
|
|
c6cfaf62b6 | ||
|
|
4ae648f6d3 | ||
|
|
a4d5206294 | ||
|
|
47eb6afa02 | ||
|
|
74ba90c0b3 | ||
|
|
fb45d7441b | ||
|
|
8ed4e30201 | ||
|
|
24db269ea8 | ||
|
|
df96be98bf | ||
|
|
b4ff2879be | ||
|
|
d9571fc6d8 | ||
|
|
593d0fad15 | ||
|
|
0c6ee4921d | ||
|
|
d550a501e2 | ||
|
|
365e43cdea | ||
|
|
7f0cbc7ba2 | ||
|
|
a7eadd4054 | ||
|
|
dfc432c6ea | ||
|
|
262c6e4a97 | ||
|
|
523cd9118f | ||
|
|
d2c4bd5194 | ||
|
|
0dd57fbaf0 | ||
|
|
04eb38584a | ||
|
|
f65c12b395 | ||
|
|
8f35234fcc | ||
|
|
e539109830 | ||
|
|
27da5555a4 | ||
|
|
da1b36eb64 | ||
|
|
6bb8af1d70 | ||
|
|
7c4eb439b2 | ||
|
|
17eed6e8ae | ||
|
|
f5931944fa | ||
|
|
aacf361bb8 | ||
|
|
1301af9a4c | ||
|
|
42ce001d71 | ||
|
|
84bb0f0acd | ||
|
|
0d4bd7297d | ||
|
|
713b0c3dba | ||
|
|
fda5c31a94 | ||
|
|
c340bbfc4b | ||
|
|
a9a529750d | ||
|
|
793c3940a8 | ||
|
|
ef8ef5ac29 | ||
|
|
ee196c7bcd | ||
|
|
83d3568150 | ||
|
|
24bea918ab | ||
|
|
5642881c4d | ||
|
|
4d489f2059 | ||
|
|
a2440a3125 | ||
|
|
16894b7b73 | ||
|
|
1a0b20857a | ||
|
|
c150327a30 | ||
|
|
2f2e98aa19 | ||
|
|
9a41a8e1e5 |
No files matched your search
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
fetch-depth: 0 # full history so ci/touched-requirements.sh can diff vs main
|
||||
|
||||
|
||||
@@ -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@11d5960a326750d5838078e36cf38b85af677262 # 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
|
||||
@@ -60,9 +60,9 @@ jobs:
|
||||
spec-immutability:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4 # the implementation repo
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 # the implementation repo
|
||||
- name: Check out the read-only spec (sibling path)
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: ${{ vars.SPEC_REPO_SLUG }} # e.g. Home-Network/home-network-k3s (set in Gitea)
|
||||
path: ../home-network-k3s
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [spec-immutability]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 } # full history so touched-requirement diff works
|
||||
- name: Install pinned tools
|
||||
run: ./.gitea/workflows/scripts/install-tools.sh lint
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Install pinned tools
|
||||
run: ./.gitea/workflows/scripts/install-tools.sh l0
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [l0]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Install pinned tools
|
||||
run: ./.gitea/workflows/scripts/install-tools.sh l1
|
||||
@@ -107,7 +107,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [render-golden]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Install pinned tools
|
||||
run: ./.gitea/workflows/scripts/install-tools.sh l1
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
run: make -C ci l1
|
||||
- name: Upload L1 artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: l1-junit-coverage
|
||||
path: |
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
needs: [l1]
|
||||
if: ${{ vars.CLUSTER_AVAILABLE == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with: { fetch-depth: 0 }
|
||||
- name: Install pinned tools
|
||||
run: ./.gitea/workflows/scripts/install-tools.sh conformance
|
||||
@@ -142,7 +142,7 @@ jobs:
|
||||
run: make -C ci conformance
|
||||
- name: Upload L2/L3 artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: conformance-junit-coverage
|
||||
path: |
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
needs: [lint, l0, render-golden, l1, conformance]
|
||||
if: always()
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
- name: Decide
|
||||
env:
|
||||
R_LINT: ${{ needs.lint.result }}
|
||||
|
||||
@@ -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"
|
||||
Executable
+373
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -75,7 +75,7 @@ spec:
|
||||
spec:
|
||||
containers:
|
||||
- name: keycloak
|
||||
image: quay.io/keycloak/keycloak
|
||||
image: quay.io/keycloak/keycloak:26.0.6
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
volumeMounts:
|
||||
|
||||
@@ -5,8 +5,15 @@
|
||||
# Validates against schemas/servicedefinition.schema.json.
|
||||
name: keycloak
|
||||
kind: container
|
||||
# DOCUMENTATION-ONLY IMAGE. The running server is NOT chosen here: the Keycloak operator
|
||||
# (platform/keycloak/kustomization.yaml, three pinned release URLs) picks it via its
|
||||
# RELATED_IMAGE_KEYCLOAK, and the Keycloak CR has no spec.image. This SD's render,
|
||||
# apps/wave-1/keycloak.yaml, is not listed in apps/wave-1/kustomization.yaml and deploys nothing
|
||||
# (ci/render-golden.sh RENDER_GOLDEN_NOT_ONBOARDED). The tag records the operator's version for
|
||||
# readers; renovate.json5 disables Renovate for this image so it cannot raise PRs against a file
|
||||
# nothing deploys. The Keycloak version moves with the operator — update this tag by hand then.
|
||||
source:
|
||||
image: quay.io/keycloak/keycloak
|
||||
image: quay.io/keycloak/keycloak:26.0.6
|
||||
exposure: public # login/redirect flows for internet-exposed services; hardened (docs/15; R-DNS/R-NET)
|
||||
egress: internet # normal WAN egress (docs/03; R-NET-05)
|
||||
storage:
|
||||
|
||||
+32
-1
@@ -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.
|
||||
|
||||
+31
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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:65cc7c1ab576fe8cdf45e7f68c7cf4eb5ce7e0db46e62191f6190c51610b20d9"
|
||||
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,104 @@
|
||||
---
|
||||
date: 2026-10-06
|
||||
branch: feat/pm-agent-registry-pull-secret
|
||||
title: The kubelet's pull credential — the second half of making agent-runner pullable, and the seventh field the admission policy had not read
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
The registry had two blockers. The first — the nodes resolving the registry hostname to the
|
||||
non-hairpinned WAN and failing TLS against the router's `CN=%D` certificate — was diagnosed
|
||||
and fixed by an `/etc/hosts` pin, merged in #59 and applied by the operator. This is the
|
||||
second: **the pull is unauthenticated, and this registry refuses unauthenticated clients
|
||||
outright.**
|
||||
|
||||
`pm-agent-registry` is a new `kubernetes.io/dockerconfigjson` Secret, SOPS-encrypted with
|
||||
placeholder values, referenced by `imagePullSecrets` on both the dispatcher Deployment and
|
||||
the committed worker Job template. It is a **second Secret holding the same Gitea token** as
|
||||
`pm-agent-credentials`, because the two consumers need two formats: the application reads a
|
||||
raw token from env, the kubelet reads a dockerconfigjson before any container starts. That
|
||||
duplication is the established pattern here (the Keycloak client secret is mirrored the same
|
||||
way, for the same reason) and it is documented in both `.example` files.
|
||||
|
||||
The operator's part is the value, and one scope: the Gitea token must now carry
|
||||
**`read:package`** alongside `write:repository` and `write:issue`. Steps are in
|
||||
`platform/pm-agent/pm-agent-registry.sops.yaml.example`, including the trap that
|
||||
`.dockerconfigjson`'s registry key must be the bare host as the image reference spells it —
|
||||
with a scheme or a port it matches nothing, the kubelet pulls anonymously, and the failure is
|
||||
indistinguishable from the Secret being absent.
|
||||
|
||||
**The admission policy gained a seventh rule.** `imagePullSecrets` is a pod-spec field that
|
||||
no container references, which is exactly why it is easy to leave unread — the same shape of
|
||||
omission as `initContainers`, `ephemeralContainers`, `env` and `envFrom` before it. The
|
||||
kubelet reads the named Secret, in this namespace, before anything runs. Rule 1's pattern now
|
||||
REQUIRES the committed entry and rule 7 forbids any other or any second one; both halves are
|
||||
needed because a `foreach` over an absent list yields no result.
|
||||
|
||||
The leverage is small and worth saying so: the image is digest-pinned, so a different pull
|
||||
credential cannot change *what* runs, and the only Secrets in this namespace are the agent's
|
||||
own. It is pinned anyway, because "only the committed shape" is this policy's whole premise
|
||||
and the rule costs four lines.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **`kyverno test platform/kyverno/tests/pm-agent` → 33 passed, 0 failed** under the pinned
|
||||
CLI (was 28 before this change). Three new fixtures, each a real path rather than a style
|
||||
violation: `extra-pull-secret` (the committed entry PLUS a second one — rule 1's array
|
||||
pattern is satisfied by the first element, which is why the count is asserted separately),
|
||||
`wrong-pull-secret` (a different Secret in this namespace), and `no-pull-secret` (absent
|
||||
entirely, refused by rule 1's pattern because rule 7's foreach cannot see it).
|
||||
- **`PM_SELFTEST=1 pm-dispatch` → exit 0**, now also asserting that the committed Job
|
||||
template names exactly `pm-agent-registry` — so the template and the policy cannot drift
|
||||
apart without the image build failing.
|
||||
- **`./ci/gate.sh` in the pinned toolbox, from a §0 worktree → exit 3 (UNDETERMINED)**, with
|
||||
`gitea-settings` the only stage that did not run (no `SETTINGS_API_TOKEN` on this
|
||||
workstation). Every other stage green, `spec-immutability` included. rc 3 is not a pass.
|
||||
- **The gate caught me shipping the Secret in plaintext.** I wrote the placeholder
|
||||
`pm-agent-registry.sops.yaml` and never ran `sops -e -i` on it. `no-plaintext-secrets`
|
||||
refused it with both halves named — "has no top-level `sops:` block with a mac" and
|
||||
"Secret value '.dockerconfigjson' is not SOPS ciphertext" — which is exactly the check
|
||||
earning its place: the file looked finished, and the value was a placeholder, so nothing
|
||||
about reading the diff would have made it obvious. Encrypted, and the stage is green.
|
||||
- **The registry really does refuse anonymous clients**, measured before building this
|
||||
(2026-10-05): the anonymous registry token's `access` claim is `null`, every `/v2/…`
|
||||
path answers 401 whether it exists or not, and `/api/v1/packages/Home-Network` replies
|
||||
`{"message":"token is required"}`. That is why this is a credential and not a posture
|
||||
tweak.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the pull now works.** Nothing has been pushed to that registry and the committed
|
||||
digest is still the all-zero placeholder, so neither half of the registry fix has been
|
||||
exercised end to end. The first real pull is what proves both at once — and if it fails, the
|
||||
error names which half: `x509 … not valid for any names` is the resolution pin, `401` /
|
||||
`unauthorized` is this credential.
|
||||
- **That the `/etc/hosts` pin from #59 took effect.** The operator applied it; I could not
|
||||
confirm the effect, because the dispatcher pod's retained events are all repeating
|
||||
`BackOff` with no fresh `Pulling`, so the `x509` message still visible is the one from
|
||||
before the play ran. Not evidence either way.
|
||||
- **Whether `read:package` is sufficient** for a pull from a Gitea package of this type. It is
|
||||
the documented read scope for packages; nothing here has exercised it, because no package
|
||||
exists yet.
|
||||
- **The dockerconfigjson placeholder is not a valid credential and is not meant to be.** Its
|
||||
`auth` field is a `REPLACE-ME` string rather than base64, so a pull with it will fail at
|
||||
authentication rather than be mistaken for working.
|
||||
- **Anything about runtime.** Unchanged from the previous entries: the image has never been
|
||||
built or pushed, no dispatcher has authenticated to Gitea, no issue has been triaged, no
|
||||
worker Job has ever been created, and the admission policy — live, `Enforce`, now seven
|
||||
rules — has still never admitted or refused a real Job.
|
||||
|
||||
**Addendum 2026-10-30 (rebased onto #81/#82/#83; see `decisions/2026-10-30-pm-agent-live.md`).**
|
||||
Three statements above are now out of date, and one was wrong when written:
|
||||
- *"Nothing has been pushed to that registry"* — no longer true. CI published agent-runner in
|
||||
images run 340 (`sha-523cd9118f95` = `main`, digest `sha256:c4eba5b3…6eb42`), and this
|
||||
branch now pins it.
|
||||
- *"That the `/etc/hosts` pin from #59 took effect"* — now measured: all three prod nodes
|
||||
carry the line, resolve the name to `192.168.2.20`, and get `401` with `ssl_verify_result=0`
|
||||
from `https://gitea.home.keithjackson.dev/v2/` (see the new entry).
|
||||
- *"Whether `read:package` is sufficient"* — the scope is necessary but not the whole answer:
|
||||
org-package access is decided by account permission (team Packages unit, or org
|
||||
visibility). Handled in the registry `.example`.
|
||||
- **Wrong when written:** the scope list. Gitea 1.24.3 puts `GET /api/v1/user` — the
|
||||
dispatcher's first call — behind the `user` scope category, so a token with only
|
||||
`write:repository`, `write:issue` and `read:package` CrashLoops the dispatcher on startup.
|
||||
The token needs `read:user` too.
|
||||
@@ -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).
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -0,0 +1,101 @@
|
||||
---
|
||||
date: 2026-10-30
|
||||
branch: feat/pm-agent-registry-pull-secret
|
||||
title: PM agent — #61 rebased onto the CI image pipeline, first published digest pinned, and the remaining path to live audited
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
PR #61 (`feat/pm-agent-registry-pull-secret`, the kubelet pull credential and admission rule
|
||||
7) was rebased onto `origin/main` after #81 (CI image pipeline), #82 and #83. Conflicts in
|
||||
`docs/plans/pm-agent-issue-to-pr.md`, `platform/pm-agent/README.md` and
|
||||
`runbooks/pm-agent.md` were resolved keeping both sides: #61's "pull credential now wired"
|
||||
and #81's "CI publishes, Renovate bumps". No manual build-and-push text came back.
|
||||
|
||||
On top of the rebase, as a second commit:
|
||||
|
||||
- **The first published digest is pinned**: `deployment.yaml` and `PM_WORKER_IMAGE` in
|
||||
`configmap.yaml` now name
|
||||
`gitea.home.keithjackson.dev/home-network/agent-runner@sha256:c4eba5b3c508bf456a03391ac76f8275e7ea28a829da1450d99f6f6f25c6eb42`
|
||||
(images run 340, tag `sha-523cd9118f95`, which is also `main`). The `# renovate-digest:`
|
||||
markers are unchanged and still sit on the line directly above each value, so later bumps
|
||||
stay Renovate's. Pinning by hand once removes the dependency on Renovate's first-ever run.
|
||||
- **The first run is dry, and scoped to a label**: `PM_DRY_RUN: "true"`,
|
||||
`PM_REQUIRE_LABEL: "agent"` (the plan's follow-up 3); `config-generation` bumped to `2`.
|
||||
Dry run is also a *correctness* requirement, not only caution: the pinned digest was
|
||||
built before this PR added `imagePullSecrets` to the worker Job template, which is baked
|
||||
into the image. Admission now requires that entry, so a worker Job built from the run-340
|
||||
image would be denied. Merging this PR touches `infra/agent/**`, so `images.yaml`
|
||||
re-publishes agent-runner; Renovate's bump to that digest is what makes workers
|
||||
admissible. Only then flip `PM_DRY_RUN` to `false`.
|
||||
- **The token scope list was wrong; it is fixed.** Gitea 1.24.3's `routers/api/v1/api.go`
|
||||
closes the `/user` route group with `tokenRequiresScopes(AccessTokenScopeCategoryUser)`,
|
||||
and the dispatcher's first call is `GET /user`. The documented scopes are now
|
||||
`read:user`, `write:repository`, `write:issue`, `read:package` everywhere (both
|
||||
`.example`s, README, runbook row).
|
||||
- **Org-package permission is documented separately from the scope.** Per
|
||||
`services/context/package.go` `determineAccessMode` (v1.24.3), a non-admin's access to an
|
||||
org-owned package is the highest Packages-unit mode across its org teams, else read if the
|
||||
org is visible to it. Home-Network is `public`, so it works today by visibility alone; the
|
||||
registry `.example` now has the operator create a `pm-agent-pull` team (no repositories,
|
||||
Packages = Read) so it does not depend on that.
|
||||
- **`sops` is toolbox-only**: both `.example`s now say so and give the in-place
|
||||
`./infra/devcontainer/dev.sh run sops <file>` edit (the same procedure
|
||||
`runbooks/vpn-remote-access.md` documents), which writes no plaintext file.
|
||||
- The runbook's Secret-key check used `-o jsonpath='{.data}'`, which prints values before the
|
||||
pipe trims them; it is now a `go-template` that emits key names only. Two new runbook
|
||||
rows: the `read:user` 403, and the admission denial from a pre-#61 digest.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Registry digest**, read-only from the registry API (bearer token from `/v2/token`,
|
||||
`HEAD /v2/home-network/agent-runner/manifests/<tag>` with OCI and Docker accept headers):
|
||||
`main` and `sha-523cd9118f95` both answer `200`,
|
||||
`Docker-Content-Digest: sha256:c4eba5b3…6eb42`, `application/vnd.oci.image.manifest.v1+json`.
|
||||
Tags list: `[main, sha-523cd9118f95]`. The admission pattern
|
||||
`gitea.home.keithjackson.dev/home-network/agent-runner@sha256:*` matches the pinned value.
|
||||
- **#81's claim that the template's digest needs no tracking holds**: `pm-dispatch.build_job`
|
||||
sets `containers[0].image = cfg.worker_image` (from `PM_WORKER_IMAGE`) on every Job.
|
||||
Renovate's `renovate-digest` custom manager only matches `platform/pm-agent/*.yaml` and
|
||||
`versions.env`, so the template is not touched by it.
|
||||
- **Node path to the registry, read-only over SSH, all three prod nodes**: `/etc/hosts` line 9
|
||||
is `192.168.2.20 keycloak.home.keithjackson.dev gitea.home.keithjackson.dev`, nsswitch is
|
||||
`hosts: files dns`, and `curl https://gitea.home.keithjackson.dev/v2/` from the node answers
|
||||
`401` with `ssl_verify_result=0` and `remote_ip=192.168.2.20`. That is the host resolver,
|
||||
which is what containerd (a node process) uses; a pod's view would not have shown it.
|
||||
- **Network path from the namespace**: dispatcher and worker both use
|
||||
`PM_GITEA_URL=http://gitea.gitea.svc.cluster.local:3000` (ConfigMap, Job template, and
|
||||
`pm-worker`'s hard allowlist, which refuses anything else); the git remote and the API base
|
||||
are both derived from it. So nothing goes through the Gateway and #83's
|
||||
`toEndpoints`-through-Gateway issue does not apply. Egress: `toEndpoints` gitea pods `:3000`
|
||||
(both CNPs), `kube-apiserver` (dispatcher only), `world:443` (the model). Ingress on the
|
||||
Gitea side: `gitea-allow-pm-agent-ingress` exists live and is `VALID True`.
|
||||
- **Live state (read-only, `--context k3s-prod`)**: namespace `pm-agent` exists; Deployment
|
||||
`0/1`, pod `ImagePullBackOff` for 24d on the all-zero digest; Secret `pm-agent-credentials`
|
||||
(2 keys) exists, `pm-agent-registry` does not yet (it ships in this PR); Flux Kustomization
|
||||
`pm-agent` Ready at `main@dfc432c`; ClusterPolicy `pm-agent-job-shape` Ready.
|
||||
- **Gitea state (read-only API)**: 1.24.3; org Home-Network `public`; no user `pm-agent`
|
||||
exists; teams are `Owners` and `registry-push`; collaborators are `jakman85` (owner) and
|
||||
`renovate-bot` (write); the repository has **no labels** and **no open issues**.
|
||||
- **Placeholders, without decrypting**: the registry ciphertext decodes to 155 bytes, exactly
|
||||
the length of the `.example`'s placeholder JSON. The credentials values decode to 57 and 39
|
||||
bytes — neither a 40-char Gitea token nor a ~100-char Claude OAuth token. Both files'
|
||||
`lastmodified` is the commit that created them as placeholders.
|
||||
- `kyverno test`, `PM_SELFTEST=1 pm-dispatch` and the gate: numbers in the PR description and
|
||||
the final handoff report for this run.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Any authenticated pull, poll or worker.** No `pm-agent` account or token exists, so the
|
||||
scope set has not been exercised end to end. The `read:user` requirement is read from
|
||||
Gitea's v1.24.3 source, not from a live 403.
|
||||
- **That the credentials ciphertext is a placeholder.** Lengths are strong evidence, not
|
||||
proof; I did not decrypt.
|
||||
- **That Renovate will open the post-merge bump.** Its token was being re-minted at the time
|
||||
of writing and it has never completed a run here. Renovate's `kubernetes` manager also
|
||||
scans `infra/agent/*.yaml` and `platform/**`; what it does with a digest-only reference
|
||||
with no tag (the template) is unmeasured.
|
||||
- **The dispatcher's `claude --print` call to `api.anthropic.com`** through `world:443`, and
|
||||
whether the run-340 image's dispatcher behaves as this tree's code does (it predates #61's
|
||||
selftest line, nothing else in `pm-dispatch.py`).
|
||||
- **The admission policy against a real Job.** Still never admitted or refused one live.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
date: 2026-10-30
|
||||
branch: fix/renovate-hold-auth-and-self
|
||||
title: Operator decision — the oauth2-proxy sidecar and Renovate's own version never auto-merge
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
`renovate.json5` gains two packageRules. Both set `automerge: false` and `addLabels: ["needs-review"]`.
|
||||
They sit after the auto-merge rule, so they override it at every update type. Each rule's
|
||||
`description` carries the operator's reason.
|
||||
|
||||
1. **`quay.io/oauth2-proxy/oauth2-proxy`.** This is the single depName emitted for:
|
||||
- `catalog/render.py` `OAUTH2_PROXY_IMAGE` (custom regex manager);
|
||||
- the five rendered `apps/wave-*` sidecars;
|
||||
- `platform/crafty/deployment.yaml` (kubernetes manager).
|
||||
|
||||
**Reason:** since v7.13.0, oauth2-proxy validates refreshed OIDC sessions against the access
|
||||
token, not the id_token. Every sidecar sets `--cookie-refresh`, and that path is untested against
|
||||
this Keycloak: the platform oauth2-proxy runs v7.15.5 without `--cookie-refresh`.
|
||||
`render-golden` and `--selftest` pass on the bump, but both check rendered text, not the binary.
|
||||
2. **`ghcr.io/renovatebot/renovate`.** This is the job `container.image` in
|
||||
`.gitea/workflows/renovate.yaml`. The rule also lists `renovatebot/github-action`, so a
|
||||
reintroduction would be held too. It is not used anywhere today; `ci/lint.sh` rejects it.
|
||||
|
||||
**Reason:** nothing re-validates `renovate.json5` against the new version before it takes
|
||||
effect. The 39.x `CONFIG_VALIDATION` failure is the precedent.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **The config validates on the pinned 44.167.0.** `renovate-config-validator` printed
|
||||
`Config validated successfully`. The migration diff is only the pre-existing top-level `docker`
|
||||
block.
|
||||
- **The flag change is exactly the intended one.** I used Renovate 44.167.0's own
|
||||
`resolveConfigPresets` and `applyPackageRules` to evaluate b4ff287's config and this branch's
|
||||
config against the same updates (the local dry run of the #86 config). Exactly **2 of 29**
|
||||
branch rows differ:
|
||||
- `ghcr.io-renovatebot-renovate-44.x`: `automerge=true labels=` → `automerge=false labels=needs-review`;
|
||||
- `quay.io-oauth2-proxy-oauth2-proxy-7.x`: the same change, and every upgrade in the branch
|
||||
resolves `false`, the `catalog/render.py` one included.
|
||||
|
||||
The other 27 rows are byte-identical.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The three GitHub-hosted branches** in run 357 (`pin-dependencies`, `kubevirt-cdi`,
|
||||
`intel-intel-device-plugins-for-kubernetes-0.x`) are not in the harness table: my local run has
|
||||
no github.com token. Neither rule's package names can match them.
|
||||
- **A live Renovate run with this config.** The next debug-level dispatch should show
|
||||
`needs-review` on both branches.
|
||||
- **The oauth2-proxy refresh path itself** remains untested. Exercising it on one app is the
|
||||
review the label asks for.
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
date: 2026-10-30
|
||||
branch: fix/renovate-run-hygiene
|
||||
title: Renovate's first completed dry runs, audited; it would have opened red PRs for every app and missed the databases. Config fixed
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**`renovate.json5` only.** Each change carries a comment quoting the measurement behind it.
|
||||
|
||||
1. **Three new regex managers, so updates edit the SOURCE.**
|
||||
- `catalog/*.yaml` `source.image`.
|
||||
- `catalog/render.py` `OAUTH2_PROXY_IMAGE`.
|
||||
- CNPG `imageName:` under `platform/` and `apps/`.
|
||||
|
||||
The first two match the rendered line's depName, so the catalog edit and the generated
|
||||
`apps/wave-*` edit land in **one** branch.
|
||||
2. **`allowedVersions` mirroring each ServiceDefinition's `update.range`:**
|
||||
- gitea/gitea `<2.0.0`
|
||||
- nextcloud `<32.0.0`
|
||||
- qbittorrent `<6.0.0`
|
||||
- radarr `<6.0.0`
|
||||
- sonarr `<5.0.0`
|
||||
- semaphore `<3.0.0`
|
||||
|
||||
Plus CNPG postgresql `<17.0.0`, because a Postgres major is a migration, not a bump.
|
||||
3. **The blast-radius never-automerge list gains** `gitea/gitea`, `gitea/act_runner` and
|
||||
`ghcr.io/cloudnative-pg/postgresql`.
|
||||
4. **HelmRelease range widening** (`matchManagers: flux`, ranged currentValue) is never
|
||||
auto-merged and is labelled `range-widen`.
|
||||
5. **Majors need a Dependency Dashboard tick** (`dependencyDashboardApproval`) before a PR opens.
|
||||
6. **`flux` manager docker deps are disabled.** These are duplicates of `kubernetes` manager
|
||||
extractions.
|
||||
7. **`velero/velero-plugin-for-aws` uses `semver` versioning**, so its RC pin can reach a stable
|
||||
release.
|
||||
8. **`python` minors are not auto-merged.**
|
||||
9. **Two false comments are corrected.**
|
||||
- Ranges "cap Renovate at patches": false, Renovate widens them.
|
||||
- `nextcloud:31-apache` and `docker:27-dind` "cannot be maintained": false, they were.
|
||||
|
||||
PR #85 is merged, so nothing here overlaps an open branch.
|
||||
|
||||
### The audit: dispatch runs 353 (07:12 UTC) and 354 (07:23 UTC), on 593d0fa
|
||||
|
||||
Both runs ended `success`, Renovate `"result": "done"`, `exitCode 0`. Both were at `info` level, so
|
||||
I reproduced the per-dependency detail with a debug-level local dry run of the same commit (see
|
||||
below).
|
||||
|
||||
The runs were inside the 03:00–06:00 America/New_York window, so branches were processed:
|
||||
- run 353: 37 "Would commit" branches;
|
||||
- run 354: 40, because the operator had added `RENOVATE_GITHUB_COM_TOKEN` 9 s before it.
|
||||
|
||||
Gitea's router log for the act-runner pod during both runs shows 317 2xx responses. The only
|
||||
non-2xx were all expected:
|
||||
- `/v2/` 401: the registry challenge;
|
||||
- `/admin/orgs` 403: the #85 scope probe, by design;
|
||||
- 6× `HEAD …/manifests/sha256:000…` 404: the placeholder digests.
|
||||
|
||||
| # | Class | Count | Cause | Severity | Fix |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | App-image PRs edit only generated `apps/wave-*` | 9 branches | A ServiceDefinition has no `apiVersion`, so the `kubernetes` manager extracts 0 deps from `catalog/`. The oauth2-proxy sidecar is a Python constant (`catalog/render.py:132`) | **Real problem.** Every such PR fails `ci/render-golden.sh` (reproduced: rc 1 "GOLDEN DRIFT"), stays red and holds a PR slot | git (1) |
|
||||
| 2 | CNPG `imageName` untracked | 3 Clusters | The `kubernetes` manager reads `image:` only. The one tracked postgres line was the grant Job (`nextcloud-db.yaml:303`, "keep in step with spec.imageName") | **Real problem.** A real run bumps the Job and leaves the databases behind | git (1) |
|
||||
| 3 | Bogus or out-of-policy majors | 5 branches: gitea 28.2.0, qbittorrent 20.04.1, radarr 6.x, nextcloud 35, postgresql 18.6 | Odd legacy tags, plus `update.range` in `catalog/` not being read by Renovate | Risk if run for real: unmergeable PRs, which hold slots | git (2) |
|
||||
| 4 | Auto-merge of the merge gate's own host | gitea 1.24.3→1.27.3, act_runner 0.2.11→0.6.1, both "minor" | Not in the blast-radius list. A 0.x minor is breaking by semver | **Risk if run for real** | git (3) |
|
||||
| 5 | Range widening auto-merged | external-dns, headlamp (minor); longhorn (blast radius); kube-prometheus-stack (major) | Renovate widens `>=a <b`. The config's comment claimed the opposite | **Risk if run for real:** silently lets Flux adopt new minors | git (4, 9) |
|
||||
| 6 | Majors hold PR slots | 10 majors in run 354 against `prConcurrentLimit: 5` | Majors never auto-merge, so they stay open | Risk: starves auto-mergeable PRs | git (5) |
|
||||
| 7 | `Cannot find replaceString … alpine:3.22` (INFO) | 1 per run | The same HelmRelease `image:` line is extracted by `flux` and `kubernetes` (also the velero plugin) | Cosmetic. The branch is still right | git (6) |
|
||||
| 8 | velero-plugin-for-aws stuck on `v1.14.3-rc.2` | 1 dep, 0 updates | Docker versioning treats `-rc.2` as a suffix, so stable `v1.14.3` and `v1.14.4` never qualify | **Real problem** (silent) | git (7) |
|
||||
| 9 | python 3.13→3.15 as auto-mergeable "minor" | 2 files | Language runtime minor | Risk if run for real | git (8) |
|
||||
| 10 | `WARN GitHub token is required` | run 353: 5 packages, 17 deps | No github.com token | Real (they were unmaintained) | **operator, done:** run 354 has no WARN and adds `pin-dependencies`, `kubevirt-cdi`, `actions-checkout-7.x`, `major-github-artifact-actions` |
|
||||
| 11 | Floating or untagged images (`invalid-value`) | 4: jackett, plex `latest`; pihole, keycloak no tag | No version to compare | Real, already documented | none in git. Pinning by hand is an operator/PM decision |
|
||||
| 12 | `runs-on: ubuntu-latest` `invalid-version` | 11 | The github-actions manager extracts runner labels | Cosmetic, debug-only | none |
|
||||
| 13 | `Docker Manifest is unknown` (debug) | 3 | HEAD on the all-zero placeholder digests | Expected. Clears when the own-image digest PRs merge | none |
|
||||
| 14 | `Docker: error fetching data from DockerHub` 403 (debug) | 5 | Hub refuses `page=11`. Renovate already has the tags | Cosmetic. Updates were still found | none |
|
||||
| 15 | `Config migration necessary` | 1 | Top-level `"docker": {"enabled": true}` | Cosmetic, pre-existing | none (as before) |
|
||||
|
||||
**Checked and fine:**
|
||||
- The 3 own-image digest updates were found in both runs. All 4 `# renovate-digest:` markers parse.
|
||||
- No marker failed to parse.
|
||||
- No package file is under `conformance/runs` or `tests`, nor is `DECISIONS-LOG.md`, `decisions/` or
|
||||
any `*.sops.yaml` (77 package files checked).
|
||||
- `kubevirt-cdi` grouping works.
|
||||
- The schedule window behaved as designed.
|
||||
- Vulnerability alerts: none in either log.
|
||||
|
||||
## What I verified
|
||||
|
||||
- I read both job logs in full (132 and 129 lines), and the router log for 10.0.0.38 during both
|
||||
runs.
|
||||
- **`renovate-config-validator` on 44.167.0**: `Config validated successfully`. The migration diff
|
||||
is only the pre-existing `docker` block.
|
||||
- **Local dry runs.** Pinned image, `--platform=local`, debug, origin/main snapshot. "Before" is
|
||||
593d0fa and "after" is this config; the GitHub-hosted deps were skipped in both.
|
||||
- **Extraction:** `regex` went from 6 files / 7 deps to **19 files / 20 deps**. Every other
|
||||
manager is unchanged: flux 15, github-actions 24, kubernetes 131, kustomize 2.
|
||||
- **Branches:** 33 before, **29 after**. Removed: gitea-28.x, qbittorrent-20.x, radarr-6.x,
|
||||
nextcloud-35.x and postgresql-18.x. Added: velero `v1.14.3-rc.2 → v1.14.4` (patch).
|
||||
- **Gitea, qbittorrent, radarr, sonarr, semaphore and seerr branches** now each carry
|
||||
`catalog/<svc>.yaml` + `apps/wave-*/<svc>.yaml`.
|
||||
- **The oauth2-proxy branch** carries `catalog/render.py` plus the 5 rendered apps and
|
||||
`platform/crafty`.
|
||||
- **The postgresql-16.x branch** carries all three Clusters.
|
||||
- The 2 flux docker duplicates report `disabled`.
|
||||
- **render==golden holds for the new branch shape.**
|
||||
- In the toolbox, I applied the gitea branch edit (catalog + apps) and the oauth2-proxy branch
|
||||
edit (`render.py` constant + 5 apps) textually. `./ci/render-golden.sh` reported
|
||||
`all 10 onboarded service golden(s) reproduce`, rc 0.
|
||||
- Negative control, the old shape with only `apps/wave-0/gitea.yaml` bumped: rc 1,
|
||||
`GOLDEN DRIFT`.
|
||||
- Both edits were reverted afterwards.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The effect of the automerge, label and dashboard-approval rules (3, 4, 5, 8). UNVERIFIED in a
|
||||
run.** `--platform=local` stops before branch processing, so I saw only which updates exist, not
|
||||
their merged flags. The matchers are standard (`matchPackageNames`, `matchManagers`,
|
||||
`matchCurrentValue`, `matchUpdateTypes`). The next dispatch's debug log shows them per branch.
|
||||
- **`allowedVersions` under docker versioning** used Renovate's "Falling back to npm semver
|
||||
syntax" path (16 debug lines). That it filters is shown by the five removed branches.
|
||||
- **render==golden for the other app branches** (qbittorrent, radarr, sonarr, semaphore, seerr). I
|
||||
proved it only for gitea and oauth2-proxy. They have the same shape: one `source.image` line ↔
|
||||
one rendered `image:` line.
|
||||
- **The `allowedVersions` rules duplicate `catalog/*/update.range`** and can drift from it. Nothing
|
||||
checks them against each other. A lint comparing the two is the obvious follow-up.
|
||||
- **Any real (non-dry) run.** None has happened. PR creation, labels, auto-merge through Gitea's
|
||||
API and the Dependency Dashboard issue are all unobserved.
|
||||
- **Sensitive path?** `renovate.json5` is not in `infra/gitea/approval-policy.json`, as far as I
|
||||
know. CI's `approval-policy` result on the PR is the measurement.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
date: 2026-10-30
|
||||
branch: feat/renovate-track-keycloak-operator
|
||||
title: Renovate tracks the Keycloak operator (the real Keycloak version), never auto-merged; the catalog's phantom image is pinned as documentation and disabled
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
- **`platform/keycloak/kustomization.yaml`**: each of the three `keycloak-k8s-resources/26.0.6`
|
||||
release URLs (two CRDs and `kubernetes.yml`) now has a
|
||||
`# renovate: datasource=github-tags depName=keycloak/keycloak-k8s-resources` marker on the line
|
||||
above it. That is the format the existing `custom.regex` marker manager parses (the kubevirt
|
||||
pattern). A comment explains that these URLs are the Keycloak version. The `Keycloak` CR has no
|
||||
`spec.image`, so the operator's `RELATED_IMAGE_KEYCLOAK` picks the server image.
|
||||
- **`renovate.json5`** has two new packageRules:
|
||||
- **`keycloak/keycloak-k8s-resources`**: `groupName: keycloak-operator`, `automerge: false`,
|
||||
`addLabels: [needs-review, blast-radius]`. A comment explains why. The operator, the CRDs and
|
||||
the server are released in lockstep. The IdP gates SSO for every app and for kubectl. And
|
||||
`passkey-reconciler.yaml` encodes Keycloak 26.0.6 Admin API shapes that the static gate never
|
||||
exercises. Majors still go through the existing dashboard-approval rule.
|
||||
- **`quay.io/keycloak/keycloak`**: `enabled: false`. Its only occurrences are in files that
|
||||
deploy nothing.
|
||||
|
||||
The "STILL CANNOT FIX" comment also gains a one-line correction for keycloak.
|
||||
- **`catalog/keycloak.yaml`**: `source.image` changed from `quay.io/keycloak/keycloak` (no tag) to
|
||||
`quay.io/keycloak/keycloak:26.0.6`. A comment says the image is DOCUMENTATION-ONLY and that the
|
||||
operator chooses the real one. `apps/wave-1/keycloak.yaml` was re-rendered, which changes one
|
||||
line.
|
||||
- **`platform/keycloak/README.md`**:
|
||||
- Corrects the false claim "Renovate maintains the version". Until now Renovate could not see
|
||||
the version.
|
||||
- Adds a new section, **"Upgrading Keycloak: the operator IS the version"**. It lists the 26.0.6
|
||||
assumptions in the passkey reconciler to diff against a new tag, and the post-merge checks.
|
||||
|
||||
**The phantom: pin and disable, not delete.** `apps/wave-1/keycloak.yaml` is not listed in
|
||||
`apps/wave-1/kustomization.yaml`, and render-golden excludes it (`RENDER_GOLDEN_NOT_ONBOARDED`).
|
||||
Deleting it would still not be a no-op:
|
||||
|
||||
- It is the fixture for the keycloak cases in `apps/wave-1/tests/app-slis.test.yaml` (the
|
||||
StatefulSet-based `keycloak-down` rule). Those cases are discovered by the
|
||||
every-PrometheusRule-in-apps/wave-1 convention.
|
||||
- Removing it means moving that rule and its tests to `platform/keycloak`. That is a separate
|
||||
observability change, not a Renovate one.
|
||||
|
||||
Pinning plus `enabled: false` changes one line in a file nothing applies. It cannot affect the
|
||||
cluster, and render-golden stays green.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Live state, prod, read-only kubectl, 2026-10-30.**
|
||||
- Keycloak CR `keycloak` has an empty `spec.image`.
|
||||
- The operator Deployment runs `quay.io/keycloak/keycloak-operator:26.0.6`, imageID
|
||||
`sha256:8e3a1a56346f…`, with `RELATED_IMAGE_KEYCLOAK=quay.io/keycloak/keycloak:26.0.6`.
|
||||
- `keycloak-0` and `keycloak-1` both run `quay.io/keycloak/keycloak:26.0.6`, imageID
|
||||
`sha256:a93d22e13b86…`.
|
||||
- There is no PrometheusRule containing "keycloak" in any namespace. This confirms the phantom
|
||||
deploys nothing, including its `keycloak-down` rule (see the next section).
|
||||
- **`renovate-config-validator`** on the pinned `ghcr.io/renovatebot/renovate:44.167.0`:
|
||||
`Config validated successfully`. The only migration note is the pre-existing `docker` block.
|
||||
- **Local dry run** (`RENOVATE_PLATFORM=local`, `RENOVATE_DRY_RUN=full`, debug, the same pinned
|
||||
image, this worktree, with a GitHub token passed by environment variable; the token string
|
||||
occurs 0 times in the log):
|
||||
- The regex manager went to 21 files / 24 deps.
|
||||
- `keycloak/keycloak-k8s-resources` was extracted three times from
|
||||
`platform/keycloak/kustomization.yaml`, each at `currentValue 26.0.6`, datasource
|
||||
`github-tags`, versioning `semver`.
|
||||
- Each extraction has one update, `26.8.1`, updateType `minor`, and all three share **one**
|
||||
`branchName: renovate/keycloak-operator`.
|
||||
- `quay.io/keycloak/keycloak` was extracted from `catalog/keycloak.yaml` (regex) and from
|
||||
`apps/wave-1/keycloak.yaml` (kubernetes) at 26.0.6, with `skipReason: disabled` and no
|
||||
updates.
|
||||
- **Rule effects.** `--platform=local` does not print each branch's merged flags. So I ran
|
||||
Renovate's own `applyPackageRules` (from `dist/util/package-rules` in the same 44.167.0 image)
|
||||
over this `renovate.json5` for each case:
|
||||
|
||||
| Update | Result |
|
||||
|---|---|
|
||||
| operator minor 26.0.6 → 26.8.1 | `automerge:false`, `groupName:keycloak-operator`, labels `dependencies, automated-update, needs-review, blast-radius` |
|
||||
| operator patch | same as the minor |
|
||||
| operator major | `automerge:false`, `dependencyDashboardApproval:true`, labels add `breaking` |
|
||||
| `quay.io/keycloak/keycloak`, catalog and rendered | `enabled:false` |
|
||||
| control: sonarr patch | `automerge:true`, so the rule did not disable auto-merge globally |
|
||||
- **render-golden, host:** `render.py --selftest` passed, and `all 10 onboarded service golden(s)
|
||||
reproduce`. Before the pin, the committed phantom was byte-identical to the canonical render. I
|
||||
re-rendered it after the pin.
|
||||
- **Toolbox gate** (commit 24db269, worktree with the ENGINE shim mounting `.git` and the spec
|
||||
repo, `DEV_KUBECONFIG=/dev/null`): **rc 3**.
|
||||
- The only stage that did not run is **`gitea-settings`**.
|
||||
- spec-immutability, decisions-log, lint, l0, render-golden, l1 and approval-policy all passed.
|
||||
- L2/L3 `conformance` scaffold-skipped, as expected.
|
||||
- **Not green.**
|
||||
- **Sensitive paths:** none touched (checked against `infra/gitea/approval-policy.json`), so no
|
||||
`/approve-override` is needed.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **A real Renovate run.** No PR was opened by Renovate. The labels and `automerge:false` are
|
||||
proven by Renovate's own matcher, not by an opened PR.
|
||||
- **That 26.8.1 is safe to adopt.** I did not diff 26.0.6 against 26.8.1. That includes the
|
||||
reconciler's assumptions: `recurseExecutions` displayName, `updateExecutions`, the
|
||||
`RpEntityName` guard, `requiredActions` REPLACE, and config aliases. The README section lists
|
||||
them for whoever reviews the first bump PR. It also says to take or confirm a fresh CNPG backup
|
||||
first, because a server minor may migrate the schema.
|
||||
- **FINDING, not fixed: Keycloak has no deployed "IdP down" alert.** The `keycloak-down` SLI rule
|
||||
exists only inside the undeployed `apps/wave-1/keycloak.yaml`. promtool tests it, but it is not
|
||||
on the cluster: there is no keycloak PrometheusRule live. Separately, because that file exists,
|
||||
`render.py --emit-waves` would treat keycloak as onboarded. It would then add a Deployment named
|
||||
`keycloak` beside the operator's StatefulSet (the known `--emit-waves` hazard). The proper fix is
|
||||
a separate PR: move the rule and its tests into `platform/keycloak`, then delete the phantom.
|
||||
- **The catalog tag can drift.** Nothing ties `catalog/keycloak.yaml`'s documentation tag to the
|
||||
operator URLs. It must be updated by hand after an operator bump, and the README says so.
|
||||
- **Concurrent branches** `fix/renovate-hold-auth-and-self` and `feat/pin-pihole-jackett-plex`
|
||||
also edit `renovate.json5`. Neither had merged when I checked. A rebase may be needed.
|
||||
@@ -241,13 +241,18 @@ about runtime behaviour in this document is a design claim, not a measurement.
|
||||
`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. Operator runs `infra/agent/build-image.sh`, pushes, and one PR replaces both
|
||||
placeholder digests. Expect the build itself to need iteration too: the nodejs module
|
||||
stream and the npm install have never run against this base. The same two blockers sit in
|
||||
front of `conformance-runner`, which shares the registry path and has never been pulled.
|
||||
(ii) the pull must be authenticated — **now wired**: `pm-agent-registry`
|
||||
(`dockerconfigjson`) is referenced by the Deployment and the committed worker Job
|
||||
template, and the admission policy pins `imagePullSecrets` to exactly that name. The
|
||||
operator supplies the value, from a Gitea token that also 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/`) and publishes it on a push to `main`
|
||||
(`decisions/2026-10-29-image-pipeline.md`); the first published digest is pinned in
|
||||
`deployment.yaml` and `configmap.yaml`, and Renovate carries every later bump through the
|
||||
`# renovate-digest:` markers (`decisions/2026-10-30-pm-agent-live.md`). The same
|
||||
credential blocker sits 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.
|
||||
|
||||
@@ -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
@@ -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."
|
||||
@@ -922,6 +922,8 @@ def selftest_template(path: str) -> None:
|
||||
assert pod.get("automountServiceAccountToken") is False, \
|
||||
"template must not mount a ServiceAccount token"
|
||||
assert pod.get("restartPolicy") == "Never", "a retried agent would double-push"
|
||||
assert [x.get("name") for x in pod.get("imagePullSecrets") or []] == ["pm-agent-registry"], \
|
||||
"the template must name exactly the committed pull secret (admission pins it)"
|
||||
assert len(pod.get("containers") or []) == 1, "exactly one container"
|
||||
c = pod["containers"][0]
|
||||
assert c.get("command") == ["/usr/local/bin/pm-worker"], "command must be the pinned entrypoint"
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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:d435fabd6fc43bf5bb4de0c1ea8c4b19d8cea5c858679bd1675ccbed471b528a
|
||||
|
||||
# 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
|
||||
|
||||
@@ -68,6 +68,13 @@ spec:
|
||||
# guarantee it cannot use one is for it not to have one.
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
# The kubelet's pull credential, the same one the dispatcher uses. Pinned by the
|
||||
# admission policy to exactly this name: `imagePullSecrets` is a pod-spec field, so a
|
||||
# Job could otherwise name any Secret in the namespace and have the kubelet read it
|
||||
# before a container ever starts. The image is digest-pinned, so the leverage is small
|
||||
# — but "only the committed shape" is the rule this template exists to express.
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
@@ -80,6 +87,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
|
||||
|
||||
@@ -420,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]
|
||||
@@ -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
|
||||
@@ -95,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
|
||||
@@ -34,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
|
||||
|
||||
Executable
+84
@@ -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")"
|
||||
@@ -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() {
|
||||
|
||||
Executable
+104
@@ -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
|
||||
|
||||
Executable
+130
@@ -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
|
||||
Executable
+265
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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: {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
+241
-1
@@ -19,7 +19,9 @@ Docker Hub images were purged (DECISIONS-LOG **F-KY1**) → that path ImagePullB
|
||||
3-replica **`longhorn`** StorageClass (DR-critical, R-IDP-05), with declarative backup hooks.
|
||||
|
||||
quay.io + ghcr.io are unaffected by the Bitnami purge. Operator manifests are pinned upstream release
|
||||
files pulled as remote kustomize bases (the kubevirt-stub pattern); Renovate maintains the version.
|
||||
files pulled as remote kustomize bases (the kubevirt-stub pattern). Renovate tracks the version via
|
||||
`# renovate:` markers on those URLs (since 2026-10-30 — before that it could not see them) and never
|
||||
auto-merges it; see [Upgrading Keycloak](#upgrading-keycloak-the-operator-is-the-version).
|
||||
|
||||
## Layout (controllers / configs split — F-MON1)
|
||||
|
||||
@@ -31,6 +33,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 +158,242 @@ 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.
|
||||
|
||||
## Upgrading Keycloak: the operator IS the version
|
||||
|
||||
The `Keycloak` CR has no `spec.image`, so the server image is whatever the operator's
|
||||
`RELATED_IMAGE_KEYCLOAK` names — the same tag as the operator (prod, 2026-10-30: operator
|
||||
`26.0.6`, `keycloak-0/1` on `quay.io/keycloak/keycloak:26.0.6`). Operator, CRDs and server are
|
||||
released in lockstep from `keycloak/keycloak-k8s-resources`, and the three URLs in
|
||||
`kustomization.yaml` are the one knob. Renovate raises them as ONE PR (`keycloak-operator` group,
|
||||
labels `needs-review` + `blast-radius`), **never auto-merged**; majors wait for a dashboard tick.
|
||||
`catalog/keycloak.yaml`'s image tag is documentation only (its render deploys nothing) and
|
||||
Renovate is disabled for it — update it by hand to match after an operator bump merges.
|
||||
|
||||
**An operator bump requires re-checking the passkey reconciler's assumptions.**
|
||||
`passkey-reconciler.yaml` (and `ci/render-keycloak-passkey-policy.py`, which generates its desired
|
||||
state) is written against **Keycloak 26.0.6 Admin API behaviour read from that tag's source** —
|
||||
grep it for `26.0.6`. The gate cannot catch a break: it is static and never runs the reconciler
|
||||
against a server. Before merging a bump, diff the new tag against 26.0.6 for at least:
|
||||
|
||||
- `AuthenticationManagementResource.recurseExecutions` — does a sub-flow row still carry its name
|
||||
in `displayName` (not `alias`)? The reconciler's flow matching depends on it.
|
||||
- `updateExecutions` / execution-requirement updates — same request shape and id resolution?
|
||||
- the WebAuthn Passwordless policy — is it still gated all-or-nothing on a non-empty
|
||||
`webAuthnPolicyPasswordlessRpEntityName`, and does a realm `PUT` still REPLACE rather than merge
|
||||
`requiredActions`? (The strict read-modify-write exists because of both.)
|
||||
- authenticator-config alias handling for copied flows.
|
||||
|
||||
Then, after the merge reconciles (operator actions): watch the `Keycloak` CR reach Ready and both
|
||||
pods roll; run the reconciler once **with `--audit`** (see "Passkeys" above for the one-off Job)
|
||||
and confirm it reports convergence with no write; confirm the client-secret and client-URL
|
||||
reconcilers' next runs succeed; log in to one SSO app and run one `kubectl --context k3s-prod`
|
||||
OIDC command. Roll back by reverting the PR — but a server minor may migrate the database
|
||||
schema on start, so take/confirm a fresh CNPG backup before merging.
|
||||
|
||||
## Exceptions that keep native auth (do not force SSO) — ADR-0014
|
||||
|
||||
Plex/Seerr (Plex auth), Minecraft (Mojang/Microsoft), UniFi (native).
|
||||
@@ -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
|
||||
|
||||
@@ -21,8 +21,17 @@ namespace: keycloak
|
||||
resources:
|
||||
- namespace.yaml
|
||||
# --- Keycloak Operator v26.0.6 (pinned upstream release manifests) ---
|
||||
# The operator, its CRDs and the SERVER IMAGE move in lockstep: the Keycloak CR has no
|
||||
# spec.image, so the operator's RELATED_IMAGE_KEYCLOAK (quay.io/keycloak/keycloak:<same tag>)
|
||||
# is what runs. These three URLs are therefore THE Keycloak version. Each carries a Renovate
|
||||
# marker (custom.regex manager, renovate.json5) under ONE depName, so all three bump in ONE
|
||||
# branch, never auto-merged (packageRule "keycloak-operator"). Before merging a bump, re-check
|
||||
# passkey-reconciler.yaml's 26.0.6 Admin API assumptions — see README.md "Upgrading Keycloak".
|
||||
# renovate: datasource=github-tags depName=keycloak/keycloak-k8s-resources
|
||||
- https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.0.6/kubernetes/keycloaks.k8s.keycloak.org-v1.yml
|
||||
# renovate: datasource=github-tags depName=keycloak/keycloak-k8s-resources
|
||||
- https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.0.6/kubernetes/keycloakrealmimports.k8s.keycloak.org-v1.yml
|
||||
# renovate: datasource=github-tags depName=keycloak/keycloak-k8s-resources
|
||||
- https://raw.githubusercontent.com/keycloak/keycloak-k8s-resources/26.0.6/kubernetes/kubernetes.yml
|
||||
# --- Admin (break-glass) credential — SOPS-encrypted, referenced by name (ADR-0007, R-IDP-04) ---
|
||||
- keycloak-admin-credentials.sops.yaml
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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: []
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
# HelmRepository for the official kured chart (github.com/kubereboot/charts). Renovate maintains the
|
||||
# pinned chart version in helmrelease.yaml via its `flux` manager, whose managerFilePatterns already
|
||||
# cover `platform/**` (renovate.json). Same Flux source pattern as platform/headlamp.
|
||||
apiVersion: source.toolkit.fluxcd.io/v1
|
||||
kind: HelmRepository
|
||||
metadata:
|
||||
name: kured
|
||||
namespace: kured
|
||||
spec:
|
||||
interval: 1h
|
||||
url: https://kubereboot.github.io/charts
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
# platform/kured — the Kubernetes Reboot Daemon.
|
||||
#
|
||||
# Rendered by its OWN Flux Kustomization (clusters/base/kured.yaml, dependsOn platform), NOT by a
|
||||
# line in platform/kustomization.yaml. The reasoning is the same one the Cilium split-out wrote down
|
||||
# and intel-gpu-plugin reused: inside the shared `wait: true` platform tier, this component's health
|
||||
# would be indistinguishable from eleven others, the only emergency valve would be suspending the
|
||||
# whole tier, and that tier's 5m timeout is wrong for a 3-node DaemonSet rollout. It matters more
|
||||
# than usual here, because `platform` gates platform-config which gates every apps wave — so a wedged
|
||||
# reboot daemon placed there could stall every application in the cluster. A component whose entire
|
||||
# purpose is to make maintenance safer must not be able to cause an outage.
|
||||
#
|
||||
# `namespace: kured` applies to every resource below, so none of them repeats it. Keep it: the
|
||||
# CiliumNetworkPolicy in networkpolicy.yaml is a CR whose namespace kustomize would otherwise leave
|
||||
# to chance.
|
||||
#
|
||||
# NO SECRET OF ANY KIND. kured authenticates to the API server with its own ServiceAccount token and
|
||||
# talks to nothing off-cluster — no registry credential, no webhook URL, no notification token. So
|
||||
# neither clusters/dev nor clusters/prod adds a SOPS decryption patch for this tier (same as
|
||||
# homepage/runbooks/intel-gpu-plugin). If Slack or shoutrrr notifications are ever wanted, the chart
|
||||
# takes them via `configuration.notifyUrl`, and THAT would need a SOPS-encrypted Secret plus a
|
||||
# decryption patch — do not inline a webhook URL here.
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
namespace: kured
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- helmrepository.yaml
|
||||
- helmrelease.yaml # chart installs the DaemonSet / RBAC / SA / Service / ServiceMonitor
|
||||
# default-deny baseline + the Prometheus scrape ingress + the apiserver/Prometheus egress.
|
||||
# The scrape ingress is NOT optional decoration: without it the ServiceMonitor's targets are
|
||||
# discovered and every scrape times out, leaving the TSDB empty and all three rules in alerts.yaml
|
||||
# permanently quiet. See the header in that file for the measured Longhorn precedent.
|
||||
- networkpolicy.yaml
|
||||
# Three rules over kured's single metric, with a companion promtool unit test beside them.
|
||||
- alerts.yaml
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
# kured — namespace for the Kubernetes Reboot Daemon DaemonSet.
|
||||
#
|
||||
# PLATFORM INFRA, NOT AN APPLICATION NAMESPACE, and here that distinction is the difference between
|
||||
# a working component and an admission rejection. kured runs `hostPID: true` with a `privileged: true`
|
||||
# container (it `nsenter`s /proc/1/ns/mnt to run `/bin/systemctl reboot` on the host). Both are
|
||||
# forbidden by platform/kyverno/policies/restrict-host-namespaces.yaml — but that policy, like every
|
||||
# other policy in this repo, is scoped by
|
||||
# `namespaceSelector: matchLabels: homelab.keithjackson.dev/app-namespace: "true"`
|
||||
# and its own header states the carve-out: "platform components that do [need these] live in platform
|
||||
# namespaces that do NOT carry the app-namespace label, so they are out of scope."
|
||||
# Verified by reading the policies on this branch, not assumed:
|
||||
# * restrict-host-namespaces.yaml rule `disallow-host-namespaces` (hostPID)
|
||||
# * restrict-host-namespaces.yaml rule `disallow-privileged-containers` (privileged)
|
||||
#
|
||||
# We set the label EXPLICITLY to "false" rather than leaving it unset, following
|
||||
# platform/intel-gpu-plugin/namespace.yaml and platform/vpn-egress: an unset label and a deliberate
|
||||
# "false" are identical to the admission engine but not to a reviewer, and this component's
|
||||
# correctness depends entirely on the answer.
|
||||
#
|
||||
# NO PodSecurity labels. There is no cluster-wide PSA baseline in this repo — `pod-security.*`
|
||||
# appears only where a namespace opts IN (conformance/incluster, platform/kyverno/admission,
|
||||
# platform/pm-agent), all at `enforce: restricted`. Adding them here would reject this DaemonSet for
|
||||
# the same reasons Kyverno would. Leaving them off is the convention for a privileged platform
|
||||
# namespace (platform/vpn-ingress/namespace.yaml sets none either).
|
||||
#
|
||||
# NO `gateway-access` label. kured serves no HTTP to users and attaches no HTTPRoute; its only
|
||||
# listener is the :8080 metrics endpoint, reached in-cluster by Prometheus. See networkpolicy.yaml.
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: kured
|
||||
labels:
|
||||
homelab.keithjackson.dev/app-namespace: "false"
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
# Default-deny baseline (R-NET-01): deny all ingress + egress, allow only DNS. `kured` is a PLATFORM
|
||||
# namespace, so Kyverno's generator — scoped to `app-namespace=true` — does not produce this; we ship
|
||||
# it explicitly, as crafty/headlamp/homepage/runbooks all do.
|
||||
#
|
||||
# THESE POLICIES DO APPLY TO kured, UNLIKE platform/vpn-ingress'. That component's identical-looking
|
||||
# baseline is honest-but-inert because its DaemonSet runs `hostNetwork: true` and therefore has no
|
||||
# Cilium endpoint for any policy to select. kured's chart defaults to `hostNetwork: false` and we
|
||||
# keep that default (it needs the host PID namespace to reboot, not the host network stack), so it
|
||||
# has a normal pod identity and every rule here is enforced.
|
||||
#
|
||||
# Cilium enforces the UNION of these policies (allow if ANY policy allows), so the CiliumNetworkPolicy
|
||||
# below adds the kube-apiserver path on top of this deny-all floor.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: default-deny
|
||||
namespace: kured
|
||||
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
|
||||
---
|
||||
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
# PROMETHEUS MUST REACH :8080, OR EVERY RULE IN alerts.yaml IS INERT.
|
||||
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
# This is not a theoretical hazard in this repo, it is a measured one. From the Longhorn entry in
|
||||
# conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh: with the ServiceMonitor
|
||||
# applied and no NetworkPolicy allowance, all three targets were DISCOVERED and every scrape failed
|
||||
# with "context deadline exceeded" after 10.0s — "a drop, not a refusal" — and the TSDB stayed
|
||||
# empty. Target discovery succeeding is therefore NOT evidence the scrape works, and the alerts
|
||||
# would have been quiet rather than broken. KuredNotReporting is the standing detector for exactly
|
||||
# this regression, and it can only work if this policy is correct.
|
||||
#
|
||||
# THE WHOLE `monitoring` NAMESPACE, NOT THE PROMETHEUS POD LABELS — following the same choice
|
||||
# longhorn/networkpolicy-metrics.yaml made and for the same reason: the prometheus-operator owns
|
||||
# those pod labels and rewrites them on upgrade (measured 2026-10-17, the pod carries
|
||||
# `app.kubernetes.io/name: prometheus` plus five operator-managed labels including a
|
||||
# controller-revision-hash), so selecting on them makes the scrape silently fail on a chart bump.
|
||||
# The namespace holds only the monitoring stack.
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: kured-allow-prometheus-scrape
|
||||
namespace: kured
|
||||
labels:
|
||||
app.kubernetes.io/name: kured
|
||||
spec:
|
||||
podSelector:
|
||||
matchLabels:
|
||||
# Set by the chart's `kured.matchLabels` helper (_helpers.tpl:70-71) with release name `kured`.
|
||||
app.kubernetes.io/name: kured
|
||||
policyTypes:
|
||||
- Ingress
|
||||
ingress:
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: monitoring
|
||||
ports:
|
||||
- protocol: TCP
|
||||
# The container port the chart names `metrics` (daemonset.yaml:211-213), from
|
||||
# `configuration.metricsPort`, which we leave at the chart default of 8080 and mirror in
|
||||
# the HelmRelease's `service.port`.
|
||||
port: 8080
|
||||
---
|
||||
# Egress allows. kured talks to exactly two things and nothing else; both are required and the
|
||||
# component is useless without either.
|
||||
#
|
||||
# 1. THE KUBE-APISERVER — the lock annotation on its own DaemonSet (`weave.works/kured-node-lock`),
|
||||
# cordon/uncordon (`nodes` patch), the node annotations, listing pods, and creating
|
||||
# `pods/eviction` objects during the drain. Reached via the `kube-apiserver` Cilium entity
|
||||
# rather than an IP: on this cluster the API is fronted by a kube-vip VIP that moves between
|
||||
# nodes on every reboot, so any CIDR-based rule here would break during the exact operation
|
||||
# this component exists to perform. Same construct as platform/headlamp/networkpolicy.yaml.
|
||||
#
|
||||
# 2. PROMETHEUS, on 9090 — the alert gate. The HelmRelease sets
|
||||
# `prometheusUrl: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090`
|
||||
# and `alertFilterMatchOnly: true`, so before taking the cluster lock kured asks Prometheus
|
||||
# whether any of LonghornVolumeDegraded / LonghornVolumeFaulted / KubeNodeNotReady /
|
||||
# NodeExporterDown is firing. IF THIS EGRESS IS BLOCKED, kured cannot evaluate the gate.
|
||||
# It treats the query error as "do not proceed" and skips the reboot — which is the correct
|
||||
# fail-safe direction, but it fails SILENTLY and indefinitely: reboots simply stop happening
|
||||
# and nothing goes red except, eventually, KuredRebootPending. Do not remove this rule
|
||||
# thinking it is only for metrics; metrics ingress is the policy above, this is the gate.
|
||||
#
|
||||
# NOTHING ELSE. No egress to the internet (kured pulls no content at runtime), no DNS beyond the
|
||||
# baseline above, no Longhorn or etcd access — kured reads Longhorn's health THROUGH Prometheus
|
||||
# alerts, never from Longhorn's own API, which is why that allow is absent here.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: kured-allow-egress
|
||||
namespace: kured
|
||||
labels:
|
||||
app.kubernetes.io/name: kured
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: kured
|
||||
egress:
|
||||
- toEntities:
|
||||
- kube-apiserver
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
io.kubernetes.pod.namespace: monitoring
|
||||
app.kubernetes.io/name: prometheus
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: "9090"
|
||||
protocol: TCP
|
||||
@@ -111,6 +111,12 @@ spec:
|
||||
=(hostNetwork): "false"
|
||||
=(hostPID): "false"
|
||||
=(hostIPC): "false"
|
||||
# The kubelet reads this Secret BEFORE any container starts, so it is part
|
||||
# of the shape even though no container references it. A pattern on an array
|
||||
# asserts "at least one element matches", which is enough to REQUIRE the
|
||||
# committed entry; rule 7 is what forbids a second one.
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
containers:
|
||||
- image: "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:*"
|
||||
command:
|
||||
@@ -336,6 +342,54 @@ spec:
|
||||
# dispatcher itself uses envFrom (it is a Deployment, not a Job, and not matched by this
|
||||
# policy); a worker must not.
|
||||
# =====================================================================================
|
||||
# =====================================================================================
|
||||
# Rule 7 — exactly one imagePullSecret, and it is the committed one.
|
||||
#
|
||||
# `imagePullSecrets` is a pod-spec field that no container references, which is precisely
|
||||
# why it is easy to leave unread — the same shape of omission as `initContainers` and
|
||||
# `env` before it. The kubelet reads the named Secret, in this namespace, before any
|
||||
# container starts. A Job naming a different one would have the kubelet read a Secret the
|
||||
# worker was never meant to touch.
|
||||
#
|
||||
# The leverage is genuinely small: the image is digest-pinned, so a different credential
|
||||
# cannot change WHAT runs, and the only Secrets in this namespace are the agent's own.
|
||||
# It is pinned anyway, because "only the committed shape" is the whole premise of this
|
||||
# policy, and because the cost of the rule is four lines while the cost of rediscovering
|
||||
# the omission is an incident.
|
||||
#
|
||||
# Rule 1's pattern REQUIRES the committed entry; this forbids any OTHER entry and any
|
||||
# second entry. A foreach over an absent list yields no result, which is why both halves
|
||||
# are needed.
|
||||
# =====================================================================================
|
||||
- name: worker-pull-secret-must-be-the-committed-one
|
||||
match:
|
||||
all:
|
||||
- resources:
|
||||
kinds:
|
||||
- Job
|
||||
namespaces:
|
||||
- pm-agent
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: pm-agent
|
||||
namespace: pm-agent
|
||||
validate:
|
||||
message: >-
|
||||
A worker Job may declare exactly ONE imagePullSecret, named `pm-agent-registry`.
|
||||
The kubelet reads that Secret before any container starts, so naming another one
|
||||
reaches a credential the worker was never meant to touch.
|
||||
foreach:
|
||||
- list: "request.object.spec.template.spec.imagePullSecrets"
|
||||
deny:
|
||||
conditions:
|
||||
any:
|
||||
- key: "{{ length(request.object.spec.template.spec.imagePullSecrets) }}"
|
||||
operator: NotEquals
|
||||
value: 1
|
||||
- key: "{{ element.name }}"
|
||||
operator: AnyNotIn
|
||||
value: ["pm-agent-registry"]
|
||||
|
||||
- name: worker-may-not-use-envfrom
|
||||
match:
|
||||
all:
|
||||
|
||||
@@ -56,6 +56,11 @@ results:
|
||||
kind: Job
|
||||
resources: [good-run, real-template]
|
||||
result: pass
|
||||
- policy: pm-agent-job-shape
|
||||
rule: worker-pull-secret-must-be-the-committed-one
|
||||
kind: Job
|
||||
resources: [good-run, real-template]
|
||||
result: pass
|
||||
|
||||
# ---------------- THE escalation, and the shape rule's other refusals ----------------
|
||||
# `evil-command` is the case the whole model exists for: right namespace, right creator,
|
||||
@@ -73,6 +78,14 @@ results:
|
||||
- host-namespaces
|
||||
- restart-onfailure
|
||||
- second-container
|
||||
- no-pull-secret
|
||||
result: fail
|
||||
|
||||
# ---------------- the pull secret: a second one, or a different one ----------------
|
||||
- policy: pm-agent-job-shape
|
||||
rule: worker-pull-secret-must-be-the-committed-one
|
||||
kind: Job
|
||||
resources: [extra-pull-secret, wrong-pull-secret]
|
||||
result: fail
|
||||
|
||||
# ---------------- args, and the second container ----------------
|
||||
|
||||
@@ -31,6 +31,8 @@ spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 65532
|
||||
@@ -95,6 +97,8 @@ spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
containers:
|
||||
- name: worker
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||
@@ -492,3 +496,75 @@ spec:
|
||||
readOnlyRootFilesystem: true
|
||||
envFrom:
|
||||
- secretRef: { name: pm-agent-credentials }
|
||||
---
|
||||
# A SECOND pull secret beside the committed one. containers[0] is perfectly compliant, and
|
||||
# rule 1's array pattern is satisfied by the first element — which is why the count is
|
||||
# asserted separately.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: extra-pull-secret
|
||||
namespace: pm-agent
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-registry
|
||||
- name: pm-agent-credentials
|
||||
containers:
|
||||
- name: worker
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||
command: ["/usr/local/bin/pm-worker"]
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
---
|
||||
# A DIFFERENT pull secret — the kubelet would read a Secret the worker was never meant to
|
||||
# touch, before any container starts.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: wrong-pull-secret
|
||||
namespace: pm-agent
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
imagePullSecrets:
|
||||
- name: pm-agent-credentials
|
||||
containers:
|
||||
- name: worker
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||
command: ["/usr/local/bin/pm-worker"]
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
---
|
||||
# NO pull secret at all. Rule 7's foreach yields nothing over an absent list, so this is
|
||||
# rule 1's pattern to refuse — which is why the requirement lives in both.
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: no-pull-secret
|
||||
namespace: pm-agent
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
automountServiceAccountToken: false
|
||||
serviceAccountName: pm-agent-worker
|
||||
containers:
|
||||
- name: worker
|
||||
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||
command: ["/usr/local/bin/pm-worker"]
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
@@ -1445,6 +1445,36 @@ spec:
|
||||
redirect-URI drift would go uncorrected and unnoticed (docs/15). This is the rule
|
||||
that would catch the healer being quietly absent during a domain migration.
|
||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-client-secrets"
|
||||
# --- The passkey reconciler (platform/keycloak/passkey-reconciler.yaml) ---
|
||||
# The sibling CronJob that converges the WebAuthn passwordless policy, the passkey
|
||||
# enrolment required action, and the browser login flow (blue/green; bound only when
|
||||
# git's bind-browser-flow is "true"). There is no failure that is "expected by
|
||||
# design": with the bind switch off, an unbound verified candidate is a SUCCESSFUL
|
||||
# run. So a failed Job is always news — drift it may not repair (e.g. a missing
|
||||
# CONFIGURE_TOTP, which locks every platform-admin out), a built flow that did not
|
||||
# verify, an exhausted candidate-alias budget, or an admin API it cannot reach.
|
||||
# backoffLimit is 0, so one failed tick is one failed Job; `for: 15m` waits out a
|
||||
# single transient failure while still firing within about two ticks.
|
||||
# A separate rule from the client reconciler's on purpose: different CronJob,
|
||||
# different job_name, different runbook — one cause, one alert.
|
||||
# UNVERIFIED-ON-CLUSTER at authoring time: the CronJob had never run, so its series
|
||||
# did not exist yet. The metric and label are the ones the sibling rule above uses.
|
||||
- alert: KeycloakPasskeyReconcilerFailing
|
||||
expr: |
|
||||
max(kube_job_status_failed{namespace="keycloak", job_name=~"keycloak-passkey-reconciler-.*"}) > 0
|
||||
for: 15m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "Keycloak passkey reconciler failing"
|
||||
description: >-
|
||||
A keycloak-passkey-reconciler Job has been failing for 15m. The realm's passkey
|
||||
policy, the passkey enrolment action or the browser login flow may differ from
|
||||
git, or a default required action (CONFIGURE_TOTP) may be missing. The reconciler
|
||||
never writes the flow users are logging in through, so a failure here does NOT
|
||||
by itself mean login is broken. Read the Job log: every PROBLEM line names the
|
||||
pass and the exact divergence.
|
||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/keycloak-passkey-reconciler"
|
||||
|
||||
# =====================================================================================
|
||||
# The monitor's own capacity — don't let the monitor blind itself (docs/10)
|
||||
|
||||
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user