Compare commits
116
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
04eb38584a | ||
|
|
f65c12b395 | ||
|
|
8f35234fcc | ||
|
|
e539109830 | ||
|
|
27da5555a4 | ||
|
|
da1b36eb64 | ||
|
|
aacf361bb8 | ||
|
|
1301af9a4c | ||
|
|
42ce001d71 | ||
|
|
84bb0f0acd | ||
|
|
0d4bd7297d | ||
|
|
713b0c3dba | ||
|
|
fda5c31a94 | ||
|
|
c340bbfc4b | ||
|
|
a9a529750d | ||
|
|
793c3940a8 | ||
|
|
ef8ef5ac29 | ||
|
|
ee196c7bcd | ||
|
|
83d3568150 | ||
|
|
24bea918ab | ||
|
|
5642881c4d | ||
|
|
4d489f2059 | ||
|
|
a2440a3125 | ||
|
|
16894b7b73 | ||
|
|
1a0b20857a | ||
|
|
c150327a30 | ||
|
|
2f2e98aa19 | ||
|
|
9a41a8e1e5 | ||
|
|
0132c680b3 | ||
|
|
439b35a3f0 | ||
|
|
e89df78210 | ||
|
|
0747849837 | ||
|
|
ad37acdf53 | ||
|
|
2acfed460a | ||
|
|
e1bebb857e | ||
|
|
23db97b958 | ||
|
|
5928d8153c | ||
|
|
146dd9a906 | ||
|
|
d1c07fe021 | ||
|
|
5c43fbac65 | ||
|
|
cf7d5e4316 | ||
|
|
be23cf3e26 | ||
|
|
4791653f2b | ||
|
|
523f482957 | ||
|
|
8059297036 | ||
|
|
90a79d29d3 | ||
|
|
523a463fd7 | ||
|
|
e35612dc7e | ||
|
|
a209a618fd | ||
|
|
6253d546f8 | ||
|
|
95597bd688 | ||
|
|
06ca9eeb88 | ||
|
|
18f741d744 | ||
|
|
867a82d8ad | ||
|
|
e4a9b02b00 | ||
|
|
0569d47d49 | ||
|
|
3ee663dbcc | ||
|
|
ad3f56eee8 | ||
|
|
005e80d38a | ||
|
|
8b5e950baa | ||
|
|
2cb891c05d | ||
|
|
54418363aa | ||
|
|
f92707f38c | ||
|
|
c5b5b8672a | ||
|
|
22c94effff | ||
|
|
d0db63911a | ||
|
|
81f627167d | ||
|
|
4386d1453b | ||
|
|
874c6703f0 | ||
|
|
bc7095677b | ||
|
|
a0e6ef145f | ||
|
|
c163deaef9 | ||
|
|
642a7752d8 | ||
|
|
989649e64a | ||
|
|
21c88b0dc7 | ||
|
|
b156ee6cfa | ||
|
|
0aa1a0a78a | ||
|
|
422834d4e2 | ||
|
|
1f4552d409 | ||
|
|
c95b1c7121 | ||
|
|
ed6afc3d3f | ||
|
|
254985baa6 | ||
|
|
dce20b7be6 | ||
|
|
028853c87c | ||
|
|
17f0c11403 | ||
|
|
3b1d6ae115 | ||
|
|
939461120a | ||
|
|
fbaeb3e038 | ||
|
|
9a69f6906a | ||
|
|
7ef2af205d | ||
|
|
788eaeae62 | ||
|
|
576e1abb19 | ||
|
|
a1e808e697 | ||
|
|
bcb8931b4f | ||
|
|
0d7f94098b | ||
|
|
5ffd0c98bc | ||
|
|
f0994c11a0 | ||
|
|
b52c8d1af6 | ||
|
|
1677dff568 | ||
|
|
8ccfdab180 | ||
|
|
948875aad3 | ||
|
|
bdfa72b1c7 | ||
|
|
4f16e83eec | ||
|
|
0c7e8dd3ba | ||
|
|
8c12a1e525 | ||
|
|
8a094c46f7 | ||
|
|
58f02fe8b1 | ||
|
|
2983378496 | ||
|
|
9048c2f5e8 | ||
|
|
753c48be24 | ||
|
|
1e71b578d5 | ||
|
|
99560c2d22 | ||
|
|
bce101d03f | ||
|
|
ba79a1b59b | ||
|
|
d44490736c | ||
|
|
fc7ec01628 |
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
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
# Container images — built, verified and published by CI, never from a workstation.
|
||||
#
|
||||
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
# WHY THIS FILE EXISTS (operator rule, 2026-10-29)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
||||
# "Container image creation, hosting and lifecycle must be part of CI/CD and the codebase, never a
|
||||
# one-off build/push from a workstation." Before this workflow, three images existed only as
|
||||
# workstation builds and NOTHING had ever been pushed to the Gitea registry: the Home-Network org's
|
||||
# package list was empty, platform/pm-agent/ pinned an all-zero digest and sat in ImagePullBackOff,
|
||||
# and conformance/versions.env carried an empty digest. Both build scripts said "publishing is an
|
||||
# operator action" — a step with no log, no recorded inputs and no verification anyone could read.
|
||||
#
|
||||
# WHAT IT DOES — the logic is in scripts/images.sh; this file only wires triggers and secrets.
|
||||
# pull_request -> build + VERIFY only. No push, and no registry credential is referenced.
|
||||
# push to main -> build + verify + PUBLISH to gitea.home.keithjackson.dev/home-network/<image>
|
||||
# dispatch -> same as a push when run on main; verify-only on any other ref.
|
||||
# Images: toolbox (infra/devcontainer), agent-runner (infra/agent, FROM toolbox) and
|
||||
# conformance-runner (conformance/incluster). Only images whose inputs changed are built.
|
||||
#
|
||||
# THE DIGEST LIFECYCLE — this workflow NEVER writes to git.
|
||||
# 1. publish pushes `sha-<commit>` (immutable) and `main` (moving), and prints the digest the
|
||||
# REGISTRY returns in the job summary.
|
||||
# 2. Renovate (renovate.yaml) follows the `main` tag of each image through the
|
||||
# `# renovate-digest:` markers next to every digest reference, and opens the bump PR.
|
||||
# 3. That PR goes through the gate like any other change, and Flux deploys it.
|
||||
# The bump PR is Renovate's and not this job's because a PR opened with the Actions token does not
|
||||
# trigger workflows, so it could never pass the REQUIRED gate check; Renovate's token is a real
|
||||
# user's.
|
||||
#
|
||||
# THE PUSH CREDENTIAL — `REGISTRY_PUSH_TOKEN`, a repository Actions secret, referenced BY NAME.
|
||||
# The automatic Actions token CANNOT be used. Gitea 1.24.3 source, verified:
|
||||
# * services/auth/basic.go (Basic.Verify) turns a task token into the pseudo-user
|
||||
# `gitea-actions` (ActionsUserID -2) and sets NO token scope;
|
||||
# * routers/api/packages/container/container.go (Authenticate, the /v2/token exchange every
|
||||
# docker client uses) then requires read:package / write:package / all scope and answers 401;
|
||||
# * services/context/package.go (determineAccessMode) has `// TODO: ActionUser permission check`
|
||||
# and no branch for that user, so even past login it would get at most READ on a public org.
|
||||
# So: a personal access token with scope `write:package`, minted by an account that has package
|
||||
# WRITE on the Home-Network org (org owner, or a team with the Packages unit at write). A dedicated
|
||||
# bot account in a packages-only team is the least-privilege choice. The username sent with it is
|
||||
# not checked by Gitea (basic.go resolves the owner from the token), so `vars.REGISTRY_PUSH_USER`
|
||||
# is optional and only labels the login.
|
||||
#
|
||||
# THE BUILDER — rootless BuildKit (operator decision on PR #81, 2026-10-29).
|
||||
# The act runner's job containers get NO Docker daemon (act_runner 0.2.11 forces docker_host "-"
|
||||
# for a tcp:// DinD, and gitea/act strips every bind when valid_volumes is empty — measured on
|
||||
# this PR's first run), and handing them the privileged DinD daemon would be root on the node for
|
||||
# anyone who can push a branch. So images are built by platform/buildkit/: a ROOTLESS buildkitd
|
||||
# (not privileged; uid 1000 in a user namespace), reached over mTLS at
|
||||
# buildkitd.buildkit.svc.cluster.local:1234 with a pinned `buildx` (remote driver). Its client
|
||||
# certificate reaches jobs as three Actions secrets — BUILDKIT_CA, BUILDKIT_CLIENT_CERT,
|
||||
# BUILDKIT_CLIENT_KEY — copied from cert-manager by infra/gitea/buildkit-client-secret.sh.
|
||||
# Pull requests get the client certificate (they must build) but never a registry credential.
|
||||
#
|
||||
# UNTIL platform/buildkit IS DEPLOYED (Flux applies it only after merge) THIS JOB FAILS, LOUDLY,
|
||||
# at its builder preflight. A skipped Gitea job reports `success`, so it is red on purpose.
|
||||
#
|
||||
# VERIFICATION is each Containerfile's `verify` stage, built before anything is pushed.
|
||||
#
|
||||
name: images
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- infra/devcontainer/Containerfile
|
||||
- infra/devcontainer/versions.env
|
||||
- infra/devcontainer/build-image.sh
|
||||
- infra/agent/**
|
||||
- conformance/incluster/Containerfile
|
||||
- conformance/incluster/conformance-clone
|
||||
- conformance/incluster/build-image.sh
|
||||
- .gitea/workflows/images.yaml
|
||||
- .gitea/workflows/scripts/images.sh
|
||||
- platform/buildkit/versions.env
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- infra/devcontainer/Containerfile
|
||||
- infra/devcontainer/versions.env
|
||||
- infra/devcontainer/build-image.sh
|
||||
- infra/agent/**
|
||||
- conformance/incluster/Containerfile
|
||||
- conformance/incluster/conformance-clone
|
||||
- conformance/incluster/build-image.sh
|
||||
- .gitea/workflows/images.yaml
|
||||
- .gitea/workflows/scripts/images.sh
|
||||
- platform/buildkit/versions.env
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
images:
|
||||
description: "Which images: 'all', or a space-separated subset of: toolbox agent-runner conformance-runner"
|
||||
required: false
|
||||
default: "all"
|
||||
|
||||
concurrency:
|
||||
# Never cancel a publish half-way: a pushed `sha-` tag with no matching `main` move is
|
||||
# recoverable (a rerun skips what is already published) but pointless to provoke.
|
||||
group: images-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
images:
|
||||
runs-on: ubuntu-latest
|
||||
# Measured on the PR that introduced this file — see its decisions entry. Generous because a
|
||||
# cold DinD store downloads the Fedora base and every pinned tool on each run.
|
||||
timeout-minutes: 75
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # the per-image change detection diffs against the base commit
|
||||
|
||||
# ── pull requests: build + verify, NO credential ───────────────────────────────────────
|
||||
- name: Build and verify (pull request — nothing is pushed)
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
MODE: verify
|
||||
IMAGES: auto
|
||||
PR_BASE_REF: ${{ github.base_ref }}
|
||||
# The BUILDER's client certificate (platform/buildkit). Not a registry credential:
|
||||
# a pull request can build, never push.
|
||||
BUILDKIT_CA: ${{ secrets.BUILDKIT_CA }}
|
||||
BUILDKIT_CLIENT_CERT: ${{ secrets.BUILDKIT_CLIENT_CERT }}
|
||||
BUILDKIT_CLIENT_KEY: ${{ secrets.BUILDKIT_CLIENT_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
BASE_SHA="$(git merge-base "origin/${PR_BASE_REF}" HEAD)"
|
||||
export BASE_SHA
|
||||
./.gitea/workflows/scripts/images.sh
|
||||
|
||||
# ── non-main dispatch: verify only ──────────────────────────────────────────────────────
|
||||
- name: Build and verify (dispatch off main — nothing is pushed)
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main'
|
||||
env:
|
||||
MODE: verify
|
||||
IMAGES: ${{ inputs.images == 'all' && 'toolbox agent-runner conformance-runner' || inputs.images }}
|
||||
BUILDKIT_CA: ${{ secrets.BUILDKIT_CA }}
|
||||
BUILDKIT_CLIENT_CERT: ${{ secrets.BUILDKIT_CLIENT_CERT }}
|
||||
BUILDKIT_CLIENT_KEY: ${{ secrets.BUILDKIT_CLIENT_KEY }}
|
||||
run: ./.gitea/workflows/scripts/images.sh
|
||||
|
||||
# ── main: publish ───────────────────────────────────────────────────────────────────────
|
||||
- name: Preflight - the push token must exist, and its absence must be LOUD
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
REGISTRY_PUSH_TOKEN: ${{ secrets.REGISTRY_PUSH_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${REGISTRY_PUSH_TOKEN:-}" ]; then
|
||||
echo "::error::REGISTRY_PUSH_TOKEN is not set on this repository." >&2
|
||||
cat >&2 <<'MSG'
|
||||
|
||||
Images cannot be published without a registry credential, and this step fails rather
|
||||
than skipping ON PURPOSE: a skipped Gitea job reports `success`, so skipping here would
|
||||
make "nothing was published" look exactly like "published". That is how this repo went
|
||||
a month with images that existed only on one workstation.
|
||||
|
||||
WHY NOT THE BUILT-IN ACTIONS TOKEN: Gitea 1.24.3 gives it no package scope at the
|
||||
registry's /v2/token exchange (routers/api/packages/container/container.go,
|
||||
Authenticate) — see the header of .gitea/workflows/images.yaml.
|
||||
|
||||
TO FIX: as an account with package WRITE on the Home-Network org (ideally a dedicated
|
||||
bot in a packages-only team), create an access token with scope `write:package` and
|
||||
nothing else, then add it to this repository as the Actions secret
|
||||
REGISTRY_PUSH_TOKEN. Optionally set the Actions variable REGISTRY_PUSH_USER to that
|
||||
account's name. Do not commit the token anywhere. Then re-run this workflow
|
||||
(workflow_dispatch on main, images: all).
|
||||
MSG
|
||||
exit 1
|
||||
fi
|
||||
echo "REGISTRY_PUSH_TOKEN is present (value not printed)."
|
||||
|
||||
- name: Build, verify and publish (main)
|
||||
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||
env:
|
||||
MODE: publish
|
||||
IMAGES: ${{ github.event_name == 'workflow_dispatch' && (inputs.images == 'all' && 'toolbox agent-runner conformance-runner' || inputs.images) || 'auto' }}
|
||||
BASE_SHA: ${{ github.event.before }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_PUSH_TOKEN }}
|
||||
REGISTRY_USER: ${{ vars.REGISTRY_PUSH_USER }}
|
||||
BUILDKIT_CA: ${{ secrets.BUILDKIT_CA }}
|
||||
BUILDKIT_CLIENT_CERT: ${{ secrets.BUILDKIT_CLIENT_CERT }}
|
||||
BUILDKIT_CLIENT_KEY: ${{ secrets.BUILDKIT_CLIENT_KEY }}
|
||||
run: ./.gitea/workflows/scripts/images.sh
|
||||
@@ -0,0 +1,206 @@
|
||||
# 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, scopes `write:repository`,
|
||||
# `read:user` and (since 2026-10-29) `read:package`. 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 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: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Preflight - the token must exist, and its absence must be LOUD
|
||||
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 with scopes
|
||||
`write:repository`, `read:user` and `read:package` (the last one lets Renovate read
|
||||
this repo's own images in the Gitea registry), 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)."
|
||||
|
||||
- name: Run Renovate
|
||||
uses: renovatebot/[email protected]
|
||||
with:
|
||||
# Pinned, and Renovate maintains this pin itself: renovate.json's enabledManagers
|
||||
# includes `github-actions`, and `helpers:pinGitHubActionDigests` is in its `extends`.
|
||||
# This is the one dependency in the repo whose updater is its own updater.
|
||||
# MUST match the file's real name. #79 renamed it to renovate.json5 and left this line
|
||||
# saying `renovate.json`; every scheduled run from 2026-10-18 then died in ~1s with
|
||||
# ENOENT before Renovate started, and nothing in the gate noticed. ci/lint.sh now
|
||||
# asserts that every `configurationFile:` here names a file that exists.
|
||||
configurationFile: renovate.json5
|
||||
token: ${{ secrets.RENOVATE_TOKEN }}
|
||||
# PINNED 2026-10-29. Unset, this action version runs Renovate `39` (action.yml
|
||||
# `renovate-version` default at v41.0.9), i.e. 39.264.0, and renovate.json5 uses
|
||||
# `managerFilePatterns` (>= 40.2.0): under 39.x validation fails with five errors and
|
||||
# the run stops at CONFIG_VALIDATION. #79 validated against `latest`, not this.
|
||||
renovate-version: 44.167.0
|
||||
env:
|
||||
# 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 }}"}]'
|
||||
|
||||
- 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"
|
||||
@@ -37,6 +37,14 @@ coverage/
|
||||
infra/tofu/backend.hcl
|
||||
infra/tofu/.r2.env
|
||||
|
||||
# Operator scratch, writable from inside the dev container (the repo is its only persistent
|
||||
# read-write mount). Holds things that must never be committed: a staged copy of the SOPS age or
|
||||
# WireGuard key, rendered VPN client profiles (infra/vpn-ingress/new-peer.sh writes here by
|
||||
# default). `infra/ansible/.gitignore` already does this for its own .cache/; this is the same
|
||||
# convention at the root. Do NOT rely on `*.key` alone to protect a staged key — that only covers
|
||||
# one filename shape, which is why this rule exists.
|
||||
.cache/
|
||||
|
||||
# Claude Code local scratch: agent worktrees + session state. Nothing here is tracked;
|
||||
# ignoring it keeps `git add -A` from staging embedded worktree repos and keeps the lint
|
||||
# stage (which scans untracked files) from tripping on .claude/worktrees/.
|
||||
|
||||
@@ -4,6 +4,125 @@ Read this before doing anything durable. It is the delivery contract for AI codi
|
||||
sessions and agents. The newest entry in `decisions/` is the current PM handoff and
|
||||
takes precedence on *what* to work on; this file governs *how* it lands.
|
||||
|
||||
**Start with §0.** It is first because it is the rule you can break before you have read
|
||||
anything else: the very first `git checkout -b` in the wrong directory has already done the
|
||||
damage.
|
||||
|
||||
## 0. Work in your OWN workspace. Never in the shared checkout.
|
||||
|
||||
Before you create a branch, edit a file, or run `git` anything: **make yourself a git
|
||||
worktree outside the repository root, and do all of your work there.** One workspace per
|
||||
agent run, no exceptions.
|
||||
|
||||
# from the shared checkout, ONCE, at the start of your run
|
||||
git fetch origin main
|
||||
git worktree add -b <type>/<slug> /path/outside/the/repo/wt-<slug> origin/main
|
||||
cd /path/outside/the/repo/wt-<slug> # everything after this happens HERE
|
||||
|
||||
`/path/outside/the/repo` means outside this repository's tree — your session scratchpad,
|
||||
or a sibling like `/var/home/keith/source/wt-<slug>`. Branch from **`origin/main`**, never
|
||||
from `main` and never from `HEAD`; see "stale base" below.
|
||||
|
||||
**The shared checkout is `/var/home/keith/source/home-network-k3s-impl`** — the operator's
|
||||
working copy, the one you are probably started in. In it, do **not**:
|
||||
|
||||
- **`git checkout` / `git switch` / `git checkout -b`.** Its HEAD is shared state; moving
|
||||
it moves the tree out from under whoever else is using it.
|
||||
- **edit, create or delete a file.** Including "just one doc", including a file you intend
|
||||
to move later.
|
||||
- **`git add` / `git commit` / `git stash` / `git restore`.** `git add -A` there stages
|
||||
whatever another agent left lying around; `git stash` *removes* their uncommitted work
|
||||
into a place they will not look.
|
||||
- **`git worktree remove` anything you did not create.**
|
||||
|
||||
Read-only commands there are fine, and the one write you do need is creating your own
|
||||
worktree.
|
||||
|
||||
### Why — measured in the shared checkout, 2026-10-05
|
||||
|
||||
| What was measured | Value |
|
||||
|---|---|
|
||||
| `checkout: moving` entries in its reflog | **107** — the tree has been yanked between branches 107 times |
|
||||
| the branch it was sitting on | `feat/pm-agent-issue-to-pr` — **not `main`** |
|
||||
| uncommitted trees left in it by other runs | **2** (`infra/agent/`, `platform/pm-agent/`), mtimes five minutes apart the same evening |
|
||||
| its local `main` vs `origin/main` | **two commits behind** (`4386d14` vs `22c94ef`) |
|
||||
| registered worktrees | **42**, of which **31** are on branches already merged to `main` |
|
||||
| gate scripts carrying an explicit `.claude/` exclusion | **9** |
|
||||
|
||||
Each row is a distinct way the shared checkout damages work:
|
||||
|
||||
- **Dirty tree, foreign files.** Two other runs' uncommitted directories were sitting in
|
||||
it. An agent that edits there and runs `git add -A` ships someone else's half-finished
|
||||
work inside its own PR, and the PR's author has no idea what those files are. This is not
|
||||
hypothetical and it is not historical: **eleven minutes after those two directories were
|
||||
measured, while this very section was being written, another run committed them** —
|
||||
`5334abe feat(pm-agent): ...`, 1,000+ lines across `infra/agent/`, `clusters/`, `catalog/`
|
||||
and `apps/` — onto the shared checkout's checked-out branch, moving its HEAD under anyone
|
||||
reading it. Had this rule's author been working in that tree rather than in a worktree,
|
||||
that commit and this one would have collided.
|
||||
- **Stale base.** Its `main` was two commits behind `origin/main`, so
|
||||
`git checkout -b x main` there branches off a tree that is already not what CI will merge
|
||||
into — the gate you run locally proves a base nobody else has. Worse, it was not even *on*
|
||||
`main`: a bare `git checkout -b x` would have branched off `feat/pm-agent-issue-to-pr` and
|
||||
silently inherited that work into your PR. `git fetch` first, and name `origin/main`
|
||||
explicitly as the start point.
|
||||
- **Shared HEAD.** 107 branch switches in one tree is 107 chances to switch it while
|
||||
another agent is mid-edit. The loser's changes are either carried onto the wrong branch
|
||||
or lost to a checkout conflict — and the symptom surfaces in *their* session, not yours.
|
||||
|
||||
### Put the workspace OUTSIDE the repo root
|
||||
|
||||
A worktree *inside* the tree (`.claude/worktrees/agent-*/` — which is where Claude Code's
|
||||
own default puts it) is a full second copy of the repo that every tree-walking check then
|
||||
finds. Measured 2026-09-12: `find`-based discovery reported **the same manifest under test
|
||||
once per worktree, up to 18 times**, and the L0 check comparing that listing against the
|
||||
tree failed on paths that are not authored surface at all
|
||||
(`platform/monitoring/tests/run.sh:115-123`). Nine gate scripts now carry an explicit
|
||||
`.claude/` prune for this reason — which means the hazard is *worked around*, not fixed,
|
||||
and the next check that walks the tree will hit it again. Keep the copy out of the tree and
|
||||
none of that applies.
|
||||
|
||||
### Clean up — and expect not to be able to
|
||||
|
||||
31 of the 42 registered worktrees are on branches already merged to `main`. A registered
|
||||
worktree **pins its branch**: while it exists, that branch cannot be deleted or checked out
|
||||
anywhere else. So a left-behind workspace is a lock on a branch name, and the cost lands on
|
||||
whoever next needs it.
|
||||
|
||||
Remove yours when you are done:
|
||||
|
||||
git worktree remove /path/outside/the/repo/wt-<slug>
|
||||
|
||||
Measured 2026-10-05: on a worktree **you created yourself, in the session scratchpad**, that
|
||||
command **succeeds** — rc 0, no prompt, with or without a branch checked out. The "worktree
|
||||
removal is sandbox-blocked" note in circulation is about the `.claude/worktrees/agent-*` ones
|
||||
belonging to *other* sessions, some of which are additionally `locked` on git's own terms. So:
|
||||
remove yours, and only yours. If removal is refused, say so in your handoff and leave the
|
||||
worktree registered — do **not** `rm -rf` the directory, which leaves a registered worktree
|
||||
pointing at nothing and still holding the branch.
|
||||
|
||||
If someone *else's* stale worktree is holding the branch you need, do not try to reclaim the
|
||||
branch name. Push a `rework/<pr-number>` branch to the PR's refspec instead.
|
||||
|
||||
### If you are already standing in the shared checkout with edits
|
||||
|
||||
Stop. Do not `git stash` (that moves other agents' work too). Create your worktree as
|
||||
above, redo the edits there, and leave the shared checkout's HEAD and working tree exactly
|
||||
as you found them.
|
||||
|
||||
### Gating from a worktree
|
||||
|
||||
Three known breakages, all with workarounds, all required every run: see §3, "Running the
|
||||
gate from a worktree".
|
||||
|
||||
### If you are writing a brief for another agent
|
||||
|
||||
The brief must name **its own explicit branch** and tell it to create **its own worktree**
|
||||
from `origin/main`. "Work on the current branch" and "continue in the checkout" are how
|
||||
branch sprawl and cross-contamination got here: an agent given no workspace uses the one it
|
||||
is started in, which is the shared one. Also carry §3's three gate workarounds into the
|
||||
brief — an agent that cannot gate will report rc 3 as green.
|
||||
|
||||
## 1. Never push to `main`. Ever.
|
||||
|
||||
Every durable change reaches `main` exactly one way:
|
||||
@@ -36,6 +155,36 @@ sensitive path, so the policy cannot be widened or narrowed without a human.
|
||||
|
||||
That stage is **fail-closed** — if it cannot evaluate, it blocks. Unlike `gitea-settings`, where a
|
||||
missing credential is a lock-out, here a skip would be a bypass.
|
||||
|
||||
**The administrator override (2026-10-01).** The rule above was unsatisfiable for the one human
|
||||
who runs this repo: Gitea refuses to let an author approve their own PR, and `jakman85` is the
|
||||
only collaborator — so a sensitive-path PR the operator opened could never be merged, *including
|
||||
the PR that would fix that.* An administrator may therefore waive the review by posting this on
|
||||
the PR:
|
||||
|
||||
/approve-override <reason>
|
||||
|
||||
**Post it as a PR *review* of type "Comment", not in the plain comment box.** CI's
|
||||
`SETTINGS_API_TOKEN` is minted `[read:repository, read:admin]`, and `/issues/{n}/comments` sits
|
||||
behind Gitea's *issue* scope — so an override typed into the comment box is **invisible to CI**,
|
||||
and the stage reports it as a missing approval rather than as a permission problem (measured on
|
||||
PR #38, 2026-10-02). `/pulls/{n}/reviews` is readable, so a review body works today. Both sources
|
||||
are accepted and the gate names which it used; the comment box starts working once
|
||||
`infra/gitea/set-ci-token.sh` is re-run to pick up `read:issue`.
|
||||
|
||||
The directive must **start a line**, the **reason is mandatory**, the poster's `admin`/`owner`
|
||||
permission is re-checked **live** when the gate runs, and it must be **newer than the
|
||||
head commit** — push again and you must override again. The overrider, the timestamp and the
|
||||
reason are printed in the gate log and the job summary. For an administrator, "a change to the
|
||||
gate cannot be gated by the gate" is therefore **advisory, not structural**; what is kept is an
|
||||
attributed record of each waiver. Note that the agent account `gitea-admin` also holds `owner`
|
||||
here, so an agent *can* clear this on its own PR — **do not**: posting that directive to unblock
|
||||
your own agent work is exactly the self-approval the stage exists to prevent. Get the operator
|
||||
to post it, or move the change out of the sensitive paths.
|
||||
|
||||
**`required_approvals` on `main` is `0`**, not 1 — the gate is the approver. That drops the
|
||||
approval *count* only: `block_admin_merge_override` and `enable_status_check` both remain `true`,
|
||||
so **a red gate is unmergeable by everyone, administrators included.**
|
||||
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
|
||||
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
|
||||
|
||||
@@ -53,12 +202,19 @@ Verified against the live Gitea on 2026-09-29:
|
||||
| `main` protected, direct push rejected | ✅ `protected: true`, `user_can_push: false` — **verified by a real rejected push**, not by the API's word |
|
||||
| PR blocked until the gate passes | ✅ `enable_status_check: true`, context `gate / gate (pull_request)` |
|
||||
| Gate runs on every PR | ✅ runner `incluster-dind` online; ~1m35s |
|
||||
| Approval required before merge | ✅ `required_approvals: 1` |
|
||||
| Gate cannot be overridden, by anyone | ✅ `block_admin_merge_override: true` |
|
||||
| Human approval required before merge | ⬜ **No — `required_approvals: 0` since 2026-10-01.** The gate is the approver; sensitive paths are gated by `ci/approval-policy.sh` instead. See §1 |
|
||||
|
||||
All four were FALSE as recently as 2026-09-29, while this repo's own log asserted they were
|
||||
The first three were FALSE as recently as 2026-09-29, while this repo's own log asserted they were
|
||||
true. They are true now because each was measured, and the push rejection was provoked rather
|
||||
than assumed. **Re-measure before relying on any of them** — see §2b.
|
||||
|
||||
The fourth row deliberately changed on 2026-10-01. `required_approvals: 1` was a **lock-out**, not
|
||||
a protection: with a single collaborator and Gitea refusing author self-approval, nothing could
|
||||
satisfy it (measured on PR #35 — green gate, zero reviews, unmergeable). It also contradicted the
|
||||
model already written into `ci/approval-policy.sh` and `ci/gate.sh`. Rationale and the live-change
|
||||
script: `infra/gitea/allow-admin-self-merge.sh`.
|
||||
|
||||
Still not covered: `vars.CLUSTER_AVAILABLE` is unset, so **L2/L3 conformance does not run in
|
||||
CI**. A green PR therefore proves the L0/L1 offline subset, not runtime behaviour.
|
||||
|
||||
@@ -110,15 +266,21 @@ Exit codes are a contract — do not flatten them:
|
||||
| 3 | UNDETERMINED — a stage did not run. **Never report this as green.** |
|
||||
|
||||
`spec-immutability` reaches rc 3 whenever the sibling spec repo is not checked out
|
||||
beside this one; that single skip is expected and tolerated. Any *other* skip is a
|
||||
real gap in your run.
|
||||
beside this one; that single skip is expected and tolerated — and from a worktree it is
|
||||
avoidable outright (§3's worktree list, item 3). `gitea-settings` is the other expected
|
||||
skip: it needs `SETTINGS_API_TOKEN`, which exists only in CI, so **a local run cannot
|
||||
return 0** — the honest best local result is rc 3 with `gitea-settings` as the only stage
|
||||
that did not run. Any *further* skip is a real gap in your run. Report the rc and name the
|
||||
skipped stages; never round rc 3 up.
|
||||
|
||||
**Never gate on the host.** `promtool` and `kustomize` are not on the host `$PATH`,
|
||||
so checks SKIP and you get a false rc 3. Only the pinned toolbox result counts.
|
||||
|
||||
### Running the gate from a git worktree
|
||||
### Running the gate from a worktree
|
||||
|
||||
Two unrelated failures, both with known workarounds — put both in any agent brief:
|
||||
This is the **normal** path, not an edge case — §0 requires you to be in a worktree, so
|
||||
every gate run you do hits all of these. Three known breakages, all with
|
||||
workarounds, all of them yours to apply every run — put them in any agent brief:
|
||||
|
||||
1. **`dev.sh` refuses to start:** `infra/ansible/.cache/prod.kubeconfig` is untracked
|
||||
and exists only in the main checkout. Do **not** copy it (it is a credential).
|
||||
@@ -129,10 +291,17 @@ Two unrelated failures, both with known workarounds — put both in any agent br
|
||||
the `ENGINE` hook — a shim that intercepts `run` and re-execs
|
||||
`podman run -v /path/to/main/.git:/path/to/main/.git:ro "$@"`. A traceback in
|
||||
`tracked_files()` is this bug, not a broken tree.
|
||||
3. **`spec-immutability` skips** — the skip called tolerated above is also *avoidable*. `dev.sh`
|
||||
derives the sibling spec path from its own `REPO_ROOT`, which in a worktree is your
|
||||
workspace, so `../home-network-k3s` does not resolve and it adds no mount. Mount the spec
|
||||
repo read-only in the same `ENGINE` shim and pass `-e SPEC_REPO=/home-network-k3s`: with
|
||||
both mounts the stage reports `pass`, not SKIPPED, and R-TEST-06 is actually covered
|
||||
(measured — `decisions/2026-10-01-render-selftest-runs-in-gate.md`).
|
||||
|
||||
## 4. Branch naming
|
||||
|
||||
Use `feat/`, `fix/`, `docs/`, `chore/`. Note that `.gitea/workflows/gate.yml`'s **push**
|
||||
Use `feat/`, `fix/`, `docs/`, `chore/`. **One branch, one workspace, one PR** — if you
|
||||
need a second branch, make a second worktree (§0); do not re-point an existing one. Note that `.gitea/workflows/gate.yml`'s **push**
|
||||
trigger lists `feature/**` — not `feat/**` — so branch pushes get no fast-feedback run.
|
||||
The `pull_request` trigger fires regardless of branch name, so the PR gate is unaffected.
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ specification** at [`../home-network-k3s`](../home-network-k3s).
|
||||
- **Done = a green, reproducible, requirement-tagged conformance test** — never "merged," never a
|
||||
manual demo (spec `docs/14`, `agents/definition-of-done.md`).
|
||||
- **No work on `main`.** Every change is a feature branch merged into `main` (spec `CONTRIBUTING.md`).
|
||||
- **No work in a shared checkout.** Every agent run gets its own git worktree, outside the repo
|
||||
root, branched from `origin/main`. This checkout is shared mutable state — see
|
||||
[`CLAUDE.md` §0](CLAUDE.md).
|
||||
- **No plaintext secrets** — SOPS-encrypted only (spec `docs/07`).
|
||||
- **Phase discipline** — only build what the active roadmap phase authorizes (spec `roadmap/phases.md`).
|
||||
|
||||
@@ -21,7 +24,8 @@ infra/ node provisioning: OpenTofu (Proxmox VE) + cloud-init + Ansible,
|
||||
bootstrap/ one-time cluster bootstrap: Flux install, SOPS/age key, host Gitea
|
||||
platform/ platform CONTROLLERS (Flux HelmReleases / operator bases): kyverno, cilium,
|
||||
longhorn, external-snapshotter, cert-manager, externaldns, gateway, coredns,
|
||||
vpn-egress, monitoring, cnpg, keycloak, oauth2-proxy, kubevirt
|
||||
vpn-egress, vpn-ingress, monitoring, cnpg, keycloak, oauth2-proxy, kubevirt
|
||||
(vpn-egress = pods OUT via PIA; vpn-ingress = remote clients IN via WireGuard)
|
||||
platform-config/ platform CONFIGS — the custom resources of the CRDs installed above
|
||||
(ClusterIssuers, Gateway, Kyverno policies, KubeVirt/CDI CRs, Keycloak CRs).
|
||||
Split out because CRs and the CRDs they use cannot apply in one pass.
|
||||
@@ -94,6 +98,20 @@ The running narrative — decisions, root causes, incidents, follow-ups — is
|
||||
|
||||
## Working on this repo
|
||||
|
||||
**First, get out of this checkout.** It is the operator's working copy and several agents
|
||||
share it; concurrent runs here have overwritten each other's branches and left each other's
|
||||
uncommitted files behind. One workspace per change:
|
||||
|
||||
```bash
|
||||
git fetch origin main
|
||||
git worktree add -b <type>/<slug> /path/outside/the/repo/wt-<slug> origin/main
|
||||
cd /path/outside/the/repo/wt-<slug>
|
||||
```
|
||||
|
||||
Outside the repo root specifically — a worktree under `.claude/worktrees/` is a second full
|
||||
copy that tree-walking gate checks then count twice. The rule, the measurements behind it, and
|
||||
the three gate workarounds a worktree needs are in [`CLAUDE.md` §0](CLAUDE.md) and §3.
|
||||
|
||||
Everything the gate needs is baked into the pinned dev container — **do not install the
|
||||
toolchain on the host**:
|
||||
|
||||
|
||||
@@ -191,6 +191,27 @@ spec:
|
||||
- port: '3000'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy in-cluster client ingress allow (R-NET-02): gitea-allow-pm-agent-ingress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: gitea-allow-pm-agent-ingress
|
||||
namespace: gitea
|
||||
labels:
|
||||
app.kubernetes.io/name: gitea
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: gitea
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: pm-agent
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '3000'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): gitea-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -100,6 +100,7 @@ spec:
|
||||
- --allowed-group=platform-admins
|
||||
- --skip-provider-button=true
|
||||
- --redirect-url=https://jackett.${BASE_DOMAIN}/oauth2/callback
|
||||
- --api-route=^/api/
|
||||
- --cookie-name=_oauth2_proxy_jackett
|
||||
- --cookie-domain=jackett.${BASE_DOMAIN}
|
||||
- --whitelist-domain=.${BASE_DOMAIN}
|
||||
@@ -243,6 +244,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): jackett-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: jackett-allow-monitoring-scrape
|
||||
namespace: jackett
|
||||
labels:
|
||||
app.kubernetes.io/name: jackett
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: jackett
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): jackett-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -14,10 +14,12 @@ resources:
|
||||
- nextcloud-db-backup-credentials-prod.sops.yaml
|
||||
- nextcloud-db-backup-credentials.sops.yaml
|
||||
- nextcloud-db.yaml
|
||||
- pihole-bootstrap-secret.sops.yaml
|
||||
- pihole-dns.yaml
|
||||
- pihole-oauth2-proxy-credentials.sops.yaml
|
||||
- pihole.yaml
|
||||
- plex-claim-secret.sops.yaml
|
||||
- plex-remote.yaml
|
||||
- plex.yaml
|
||||
- qbittorrent-bootstrap-secret.sops.yaml
|
||||
- qbittorrent-oauth2-proxy-credentials.sops.yaml
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# =================================================================================================
|
||||
# PiHole WEB/API ADMIN PASSWORD — the one piece of PiHole state that was NOT declared anywhere.
|
||||
# =================================================================================================
|
||||
# WHY THIS FILE EXISTS. Measured on live prod 2026-10-04, `pihole.toml` marks every non-default
|
||||
# setting with `### CHANGED`, and names the ones that came from the environment `### CHANGED (env)`.
|
||||
# The complete list was:
|
||||
#
|
||||
# dns.upstreams ### CHANGED (env) <- declared, catalog/pihole.yaml
|
||||
# dns.listeningMode ### CHANGED (env) <- declared, catalog/pihole.yaml
|
||||
# dns.rateLimit.count ### CHANGED (env) <- declared, catalog/pihole.yaml
|
||||
# misc.dnsmasq_lines ### CHANGED (env) <- declared, catalog/pihole.yaml
|
||||
# files.macvendor ### CHANGED <- set by the image itself, container path
|
||||
# webserver.api.pwhash ### CHANGED <- SET OUT OF BAND. Declared NOWHERE. This file.
|
||||
#
|
||||
# So exactly ONE operator-set value lived only on the Longhorn PVC: the admin password, as a
|
||||
# one-way BALLOON-SHA256 hash. Nobody could recover it (a hash does not reverse), nothing in git
|
||||
# produced it, and a rebuild from git would have come up with a DIFFERENT password — or none.
|
||||
#
|
||||
# THE REASON THIS BLOCKS HA, which is why it is fixed first. The next step is running more than one
|
||||
# PiHole replica, and a replica with a fresh PVC would come up with its own unrelated password. The
|
||||
# admin UI would then accept or reject the same credential depending on which pod answered. Declaring
|
||||
# it here makes every replica, and every rebuild, identical by construction.
|
||||
#
|
||||
# MECHANISM: `webserver.api.password` is a real, settable FTL config key — verified in the running
|
||||
# image, where `pihole setpassword` is itself just `setFTLConfigValue "webserver.api.password"` with
|
||||
# the comment "pihole-FTL will automatically hash the password". So FTL takes CLEARTEXT on this key
|
||||
# and writes the hash into `webserver.api.pwhash` itself. catalog/pihole.yaml therefore passes this
|
||||
# value as `FTLCONF_webserver_api_password`, and the hash on the PVC becomes a derived artefact
|
||||
# rather than the source of truth.
|
||||
#
|
||||
# DEFENCE IN DEPTH, NOT THE ONLY GATE. The admin UI is already behind the oauth2-proxy sidecar
|
||||
# (`--allowed-group=platform-admins`), so this password is a SECOND gate. It is kept rather than set
|
||||
# empty because the `pihole` Service still publishes port 80 (PiHole direct, bypassing the proxy on
|
||||
# :4180) and only the CiliumNetworkPolicy closes it — and the default-deny policy in this repo has a
|
||||
# documented drift history. An unauthenticated admin UI that can rewrite LAN DNS is not a good thing
|
||||
# to leave resting on one policy staying correct. Narrowing that Service port is tracked follow-up.
|
||||
#
|
||||
# ROTATION: `sops` this file in your own terminal, change the value, commit. Flux re-applies the
|
||||
# Secret, the pod restarts, FTL re-hashes. Do NOT `sops -d` it into a shared terminal or a log
|
||||
# (CLAUDE.md section 6); read it in an editor. The value below was generated randomly at authoring
|
||||
# time and has never been printed to a transcript.
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: pihole-bootstrap
|
||||
namespace: pihole
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
type: Opaque
|
||||
stringData:
|
||||
#ENC[AES256_GCM,data:TMOuRyJeb5IVn3lZPXY7lgh1KGxu7GY9+WIOLHpMB50JqzmM2tHIaWUWKTt1JtZoX/E5/Y7WCUTnfkJLhRBN2zV2MwWRBSLYVy5CMg7xV8PTLRZJYAVUYS5Trg==,iv:q+DpfT6PQfY5ZuNsmP7Ddl+PDLjSm89fJICAhYoW64k=,tag:axijaO3DaxIZgZ3LeK2hXA==,type:comment]
|
||||
webui-password: ENC[AES256_GCM,data:7FcK/bUW79M46f/MlouAmATKbMnoUTNuMUjtqJ5vpU4=,iv:I8DBFB39uXZV42Ie2IPwSefmMgdKEtiDknfc9Tu7h5E=,tag:L423784UrDU3TvylpHc6KQ==,type:str]
|
||||
sops:
|
||||
kms: []
|
||||
gcp_kms: []
|
||||
azure_kv: []
|
||||
hc_vault: []
|
||||
age:
|
||||
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
|
||||
enc: |
|
||||
-----BEGIN AGE ENCRYPTED FILE-----
|
||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB0MkRSNjZLZlpXYkcvaVJL
|
||||
cjlqb2wrcWREZnZLdGpsTHNPWEpseHlRZnpjCnJVM1FDQ2M2dXlaS1JBZ2k2V1BP
|
||||
Yi9QUDZSMzZsL09pTi93RTdjdGxtY2MKLS0tIDIrQTZIVWdhMGIxSCtlUFN2Mlho
|
||||
SWVybld4VjJyaHRSKzZZU0xsUUV2SEEKbDYHV/qBZVspK7DpBW3vYjRMJ/HmAN+9
|
||||
G9bOlNEp0jAtcNZEn6lpQ9ORgvmIrhJHoBQRLabdMWzNWSZsNn56tA==
|
||||
-----END AGE ENCRYPTED FILE-----
|
||||
lastmodified: "2026-10-04T06:01:27Z"
|
||||
mac: ENC[AES256_GCM,data:rhTMTOwLOtHXHgqI5M2ZW/6w+UpLpxBlTk7NoN0wmGSqOcE+OGrXA1DhkZrJweyQT0RSNSSBm/6QMztLP81eepOzdp2rEkwvbQPK/l6K9g16Ww9c1IatS3NQsKEB8AoMDx7jBQ6exXYgggPx3MJsNYbE+WyloQ96U9KPdrecAM8=,iv:n34v2zX9D5xsaR1PFCH94mNMiuRfhUyI2lseUCoqiTA=,tag:9YmN9nIVLXfsGYIb0bUl2w==,type:str]
|
||||
pgp: []
|
||||
encrypted_regex: ^(data|stringData)$
|
||||
version: 3.9.4
|
||||
+75
-29
@@ -35,34 +35,18 @@ spec:
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
---
|
||||
# PVC pihole-config (R-STATE)
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: pihole-config
|
||||
namespace: pihole
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
---
|
||||
# Deployment (R-PLAT-02)
|
||||
# StatefulSet (R-PLAT-02)
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: pihole
|
||||
namespace: pihole
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
type: Recreate
|
||||
replicas: 2
|
||||
serviceName: pihole
|
||||
podManagementPolicy: Parallel
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: pihole
|
||||
@@ -89,10 +73,22 @@ 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}
|
||||
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:
|
||||
name: pihole-bootstrap
|
||||
key: webui-password
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /data/config
|
||||
mountPath: /etc/pihole
|
||||
lifecycle:
|
||||
postStart:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- "# Assert the declared adlists in gravity.db. Idempotent: steady state writes nothing and\n# does NOT run a gravity rebuild.\nset -u\nGRAVITY=/etc/pihole/gravity.db\n\n# THE DECLARED SET \u2014 one URL per line, the source of truth for what PiHole blocks.\n# To add a list: add a line here, commit, let Flux reconcile. Nothing else to do.\nDECLARED='https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'\n\n# On a fresh PVC, FTL creates gravity.db a moment after the container starts. Wait, bounded,\n# and NEVER block or fail the start: a missing DB here must not crashloop DNS for the LAN.\ni=0\nwhile [ ! -s \"$GRAVITY\" ] && [ \"$i\" -lt 90 ]; do sleep 2; i=$(expr $i + 1); done\nif [ ! -s \"$GRAVITY\" ]; then\n echo \"pihole-adlists: gravity.db absent after 180s - leaving adlists untouched\" >&2\n exit 0\nfi\n\nsql() { pihole-FTL sqlite3 \"$GRAVITY\" \"$1\"; }\nCHANGED=0\n\n# 1) Every declared URL must be present AND enabled.\necho \"$DECLARED\" | while IFS= read -r url; do\n [ -n \"$url\" ] || continue\n printf '%s\\n' \"$url\"\ndone > /tmp/declared.txt\nwhile IFS= read -r url; do\n [ -n \"$url\" ] || continue\n # Single quotes inside a URL would break this SQL; adlist URLs do not contain them.\n have=$(sql \"select count(*) from adlist where address='$url';\")\n if [ \"$have\" = \"0\" ]; then\n echo \"pihole-adlists: adding $url\"\n sql \"insert into adlist (address,enabled,comment) values ('$url',1,'declared in catalog/pihole.yaml');\"\n CHANGED=1\n else\n en=$(sql \"select enabled from adlist where address='$url';\")\n if [ \"$en\" != \"1\" ]; then\n echo \"pihole-adlists: re-enabling $url\"\n sql \"update adlist set enabled=1 where address='$url';\"\n CHANGED=1\n fi\n fi\ndone < /tmp/declared.txt\n\n# 2) Anything enabled in the DB but NOT declared gets disabled (never deleted - see above).\nsql \"select address from adlist where enabled=1;\" | while IFS= read -r addr; do\n [ -n \"$addr\" ] || continue\n if ! grep -qxF \"$addr\" /tmp/declared.txt; then\n echo \"pihole-adlists: disabling undeclared $addr\"\n sql \"update adlist set enabled=0 where address='$addr';\"\n echo changed >> /tmp/adlist-changed\n fi\ndone\n[ -s /tmp/adlist-changed ] && CHANGED=1\nrm -f /tmp/adlist-changed /tmp/declared.txt\n\n# 3) Rebuild gravity ONLY if the declared set actually moved.\nif [ \"$CHANGED\" = \"1\" ]; then\n echo \"pihole-adlists: declared set changed - running gravity update\"\n pihole -g || echo \"pihole-adlists: gravity update failed; existing gravity.db retained\" >&2\nelse\n echo \"pihole-adlists: already matches the declared set - nothing to do\"\nfi\nexit 0\n"
|
||||
- name: oauth2-proxy
|
||||
image: quay.io/oauth2-proxy/oauth2-proxy:v7.8.1
|
||||
imagePullPolicy: IfNotPresent
|
||||
@@ -163,10 +159,39 @@ spec:
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
claimName: pihole-config
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
requiredDuringSchedulingIgnoredDuringExecution:
|
||||
- labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: pihole
|
||||
topologyKey: kubernetes.io/hostname
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: config
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 2Gi
|
||||
---
|
||||
# PodDisruptionBudget — keep one replica through a drain (availability.replicas)
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: pihole
|
||||
namespace: pihole
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: pihole
|
||||
---
|
||||
# Service
|
||||
apiVersion: v1
|
||||
@@ -207,6 +232,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): pihole-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: pihole-allow-monitoring-scrape
|
||||
namespace: pihole
|
||||
labels:
|
||||
app.kubernetes.io/name: pihole
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: pihole
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): pihole-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
@@ -269,11 +315,11 @@ spec:
|
||||
- name: pihole.slis
|
||||
rules:
|
||||
- alert: pihole-down
|
||||
expr: kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"} == 0 or absent(kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"})
|
||||
expr: kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"} == 0 or absent(kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"})
|
||||
for: 5m
|
||||
labels:
|
||||
severity: critical
|
||||
service: pihole
|
||||
annotations:
|
||||
summary: pihole is down
|
||||
description: 'pihole: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available).'
|
||||
description: 'pihole: its StatefulSet has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_statefulset_status_replicas_ready).'
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
# =================================================================================================
|
||||
# PLEX REMOTE ACCESS — raw tcp/32400 north-south exposure, and the policy that admits it.
|
||||
# =================================================================================================
|
||||
# HAND-WRITTEN, NOT RENDERED. A non-catalog app-tier unit anchored to the `plex` ServiceDefinition
|
||||
# and placed into wave 0 by `catalog/render.py --emit-waves` via its EXTRA_UNITS table — the same
|
||||
# mechanism gitea-actions and pihole-dns use. It is hand-written for the SAME reason those are:
|
||||
# schemas/servicedefinition.schema.json is `additionalProperties: false` and has NO field for a
|
||||
# type=LoadBalancer Service, for a pinned lbipam VIP, or for raw-TCP north-south exposure at all.
|
||||
# `ingressRoutes` renders HTTPRoutes on the shared Gateway, which is an L7 HTTPS terminator.
|
||||
#
|
||||
# WHY THE L7 GATEWAY IS NOT ENOUGH HERE — and it is NOT because the Gateway fails to serve Plex.
|
||||
# Measured 2026-10-03 through the Gateway VIP: `GET https://plex.home.keithjackson.dev/identity`
|
||||
# returned HTTP 200 with `claimed="1"` and the real machineIdentifier, and the protected endpoints
|
||||
# (/library/sections, /status/sessions, /:/prefs) all returned 401. The L7 path WORKS.
|
||||
#
|
||||
# The problem is DISCOVERY, not proxying. Plex apps (phone, TV, Roku) do not resolve
|
||||
# `plex.${BASE_DOMAIN}`; they ask plex.tv for the server's connection list. A server gets onto that
|
||||
# list one of two ways: it advertises a custom URL (`customConnections`, the ADVERTISE_IP env), or it
|
||||
# publishes a reachable public IP:port for its own tcp/32400. Measured in the live Preferences.xml on
|
||||
# 2026-10-03: `customConnections` ABSENT, `ManualPortMappingMode` ABSENT, while
|
||||
# `PublishServerOnPlexOnlineKey="1"` — the server was trying to publish and had nothing reachable to
|
||||
# publish, so remote clients saw NO connection at all. The operator chose the NATIVE path (this
|
||||
# file) over advertising the Gateway URL: Plex then terminates its own TLS with its
|
||||
# `*.plex.direct` certificate and video streams bypass the Envoy L7 hop entirely.
|
||||
#
|
||||
# VIP: ${PLEX_L4_VIP} (cluster-vars, R-ENV-02 — dev 192.168.1.57, prod 192.168.2.24), PINNED via
|
||||
# lbipam.cilium.io/ips out of the CiliumLoadBalancerIPPool `default-pool`
|
||||
# (${CILIUM_LB_POOL_START}..${CILIUM_LB_POOL_STOP}) and ARP-announced on the flat LAN by the existing
|
||||
# CiliumL2AnnouncementPolicy `default-l2` (platform-config/cilium/*). No new Cilium config is needed;
|
||||
# this Service just consumes the pool. PINNING IS NOT OPTIONAL: the address is typed into a WAN
|
||||
# port-forward on the SOHO router, which is hand-maintained and off-repo, so it can never move.
|
||||
# 192.168.2.24 verified FREE live 2026-10-03 — `default-pool` reported IPsTotal 11 / IPsUsed 4 /
|
||||
# IPsAvailable 7, and the only four allocations were .20 (cilium-gateway-shared), .21 (unifi-l4-*),
|
||||
# .22 (crafty-l4-*) and .23 (pihole-dns-*).
|
||||
#
|
||||
# ONE SERVICE, TCP ONLY. Unlike unifi/crafty/pihole-dns this needs no second Service and no
|
||||
# lbipam.cilium.io/sharing-key: Plex's remote access is tcp/32400 and nothing else. (Plex's
|
||||
# DLNA/GDM discovery uses UDP, but that is LAN-only multicast and is not part of remote access.)
|
||||
#
|
||||
# NO `loadBalancerSourceRanges`, DELIBERATELY — and this is the one place this file diverges from
|
||||
# its closest sibling. apps/wave-0/pihole-dns.yaml pins its source range to ${LAN_CIDR} because a
|
||||
# resolver must never answer the internet. This Service is the OPPOSITE case: admitting remote
|
||||
# clients from arbitrary internet addresses is its entire purpose, so there is no range to narrow
|
||||
# to. Plex authenticates every caller itself (measured above: 401 on every protected endpoint
|
||||
# without a token), which is what makes an unfiltered port defensible here.
|
||||
#
|
||||
# NO external-dns ANNOTATION, DELIBERATELY. unifi and crafty both carry
|
||||
# `external-dns.alpha.kubernetes.io/hostname` so external-dns publishes their VIP as a public
|
||||
# Cloudflare record. This Service must not, for pihole-dns's reason: ${PLEX_L4_VIP} is an RFC1918
|
||||
# address that is useless in public DNS. Remote clients get this server's address from plex.tv, not
|
||||
# from the zone. The existing public `plex.${BASE_DOMAIN}` record is published from the rendered
|
||||
# HTTPRoute via the gateway source and is NOT affected by this file — the web UI keeps working
|
||||
# through the Gateway exactly as before.
|
||||
#
|
||||
# -------------------------------------------------------------------------------------------------
|
||||
# externalTrafficPolicy: Cluster — inherited from crafty's 8-DAY LAN OUTAGE, not re-derived.
|
||||
# -------------------------------------------------------------------------------------------------
|
||||
# `Local` is the tempting choice because it preserves the client source IP, which this service
|
||||
# actually has a use for (see the allowedNetworks note below). It is still WRONG here, for the
|
||||
# reason platform/crafty/service-l4.yaml documents at length: Cilium's L2 announcer elects ONE lease
|
||||
# holder per Service and does NOT follow the backend pod, so with `Local` the VIP can be announced
|
||||
# by a node that has no Plex pod, which black-holes the port while every object still reports
|
||||
# healthy. Plex is a single-replica Deployment with `strategy: Recreate` pinned to wherever the
|
||||
# scheduler put it, so the mismatch is the normal case, not a rare one. Take the SNAT.
|
||||
#
|
||||
# KNOWN CONSEQUENCE OF THAT CHOICE, stated rather than discovered later: `Cluster` SNATs the client
|
||||
# to the ingress NODE's address (192.168.2.1x), which falls inside the `192.168.0.0/16` entry of
|
||||
# catalog/plex.yaml's ALLOWED_NETWORKS. Plex will therefore classify every REMOTE client as LAN.
|
||||
# Measured 2026-10-03 on the Gateway path, which SNATs the same way: Plex logged the request as
|
||||
# `[10.0.2.214:36040 (Allowed Network (WAN))]`. This is a METERING effect, not a security one —
|
||||
# authorization is unchanged (401s above), but Plex's remote-quality/bandwidth limits will not bind
|
||||
# on these streams because it believes they are local. Narrowing ALLOWED_NETWORKS cannot fix it
|
||||
# without also de-classifying genuine LAN clients, so it is accepted.
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: plex-l4-tcp
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
annotations:
|
||||
lbipam.cilium.io/ips: "${PLEX_L4_VIP}"
|
||||
spec:
|
||||
type: LoadBalancer
|
||||
externalTrafficPolicy: Cluster
|
||||
selector:
|
||||
app.kubernetes.io/name: plex
|
||||
ports:
|
||||
- name: plex-remote
|
||||
port: 32400
|
||||
targetPort: 32400
|
||||
protocol: TCP
|
||||
---
|
||||
# INGRESS allow for the raw port (R-NET-01/02).
|
||||
#
|
||||
# The rendered plex.yaml already carries `plex-allow-gateway-ingress` (fromEntities: ingress ->
|
||||
# 32400) and three reciprocal allows for sonarr/radarr/seerr. CiliumNetworkPolicies are ADDITIVE, so
|
||||
# this file adds the off-cluster case without touching those.
|
||||
#
|
||||
# `world` is Cilium's entity for anything outside the cluster, which is what a remote Plex client is.
|
||||
# `host` and `cluster` mirror platform/crafty/networkpolicy.yaml's game-port rule and are what make
|
||||
# this work under `externalTrafficPolicy: Cluster`: after SNAT the packet's source is the ingress
|
||||
# NODE, which Cilium classifies as `host` (same node) or `remote-node` (a different one) — NOT
|
||||
# `world`. Listing `world` alone would compile cleanly and then drop every remote stream, so all
|
||||
# four entities are named.
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: plex-allow-remote-32400
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: plex
|
||||
ingress:
|
||||
- fromEntities:
|
||||
- world
|
||||
- cluster
|
||||
- host
|
||||
- remote-node
|
||||
toPorts:
|
||||
- ports:
|
||||
- { port: "32400", protocol: TCP }
|
||||
+67
-1
@@ -77,7 +77,7 @@ spec:
|
||||
storageClassName: longhorn
|
||||
resources:
|
||||
requests:
|
||||
storage: 8Gi
|
||||
storage: 16Gi
|
||||
---
|
||||
# PVC plex-media (R-STATE)
|
||||
apiVersion: v1
|
||||
@@ -137,6 +137,20 @@ spec:
|
||||
secretKeyRef:
|
||||
name: plex-claim
|
||||
key: PLEX_CLAIM
|
||||
- name: ADVERTISE_IP
|
||||
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400
|
||||
- name: PLEX_TLS_CRT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: plex-vip-tls
|
||||
key: tls.crt
|
||||
optional: true
|
||||
- name: PLEX_TLS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: plex-vip-tls
|
||||
key: tls.key
|
||||
optional: true
|
||||
resources:
|
||||
limits:
|
||||
gpu.intel.com/i915: 1
|
||||
@@ -146,6 +160,14 @@ spec:
|
||||
- name: media
|
||||
mountPath: /media
|
||||
readOnly: true
|
||||
lifecycle:
|
||||
postStart:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- "# NOTE \u2014 LOAD-BEARING: SHELL VARIABLES HERE ARE WRITTEN BARE, NEVER BRACE-WRAPPED.\n# This whole script is rewritten by Flux postBuild envsubst BEFORE it ever runs, so a\n# brace-wrapped name is read as a cluster-vars knob: an undeclared one is a HARD\n# Kustomization failure under StrictPostBuildSubstitutions, and a declared one would be\n# replaced with its value. Shell vars are therefore `$PX`, `$TOKEN`, `$P12` \u2014 bare. Only\n# BASE_DOMAIN below is brace-wrapped, because it IS a knob and IS meant to be\n# substituted. Command substitution `$(cmd)` is left alone and needs no escaping. A shell\n# variable written with braces would be blanked or would fail the whole Kustomization \u2014\n# this repo has already lost a gitea OIDC bootstrap to exactly that mistake, and\n# catalog/render.py now refuses to emit it (which is how this very comment got caught).\n# DELIBERATELY NO `set -u`. The TLS env vars are declared `optional: true`, so when the\n# certificate has not issued yet they are genuinely UNSET, not empty \u2014 and under `set -u`\n# the first `[ -n \"$PLEX_TLS_CRT\" ]` would abort the script, which for a postStart hook\n# means kubelet KILLS THE CONTAINER. That is the precise failure the `optional` flag exists\n# to prevent, so turning it back on here would defeat the whole design. (The alternative,\n# a brace-wrapped `:-` default, would need envsubst escaping \u2014 the trap the note above\n# describes.) Every value below is explicitly guarded instead.\nPX=\"/config/Library/Application Support/Plex Media Server/Preferences.xml\"\nCERTDIR=/config/certs\nP12=$CERTDIR/plex-vip.p12\n# The PKCS#12 passphrase protects nothing and is deliberately not a secret: Plex needs it\n# in cleartext in customCertificateKey, and it and the bundle sit on the same PVC. It is a\n# container-format requirement, not an access control.\nP12PASS=plexvip\nLANNETS='10.0.0.0/8,192.168.0.0/16'\nCERTDOMAIN=plex-vip.${BASE_DOMAIN}\nAPI=http://127.0.0.1:32400\n\n# (1) Build the PKCS#12 bundle from the PEM the Secret supplies. Rebuilt on every start so\n# a cert-manager RENEWAL is picked up with no extra moving part.\nif [ -n \"$PLEX_TLS_CRT\" ] && [ -n \"$PLEX_TLS_KEY\" ]; then\n mkdir -p $CERTDIR && chmod 700 $CERTDIR\n printf '%s' \"$PLEX_TLS_CRT\" > $CERTDIR/tls.crt\n printf '%s' \"$PLEX_TLS_KEY\" > $CERTDIR/tls.key\n chmod 600 $CERTDIR/tls.key\n if openssl pkcs12 -export -out $P12.tmp -inkey $CERTDIR/tls.key \\\n -in $CERTDIR/tls.crt -passout pass:$P12PASS 2>/dev/null; then\n mv -f $P12.tmp $P12\n chown 1000:1000 $P12 2>/dev/null || true\n chmod 600 $P12\n echo \"[plex-settings] PKCS#12 bundle written\"\n else\n rm -f $P12.tmp\n echo \"[plex-settings] WARN: pkcs12 export failed; keeping any previous bundle\" >&2\n fi\n rm -f $CERTDIR/tls.key\nelse\n echo \"[plex-settings] WARN: TLS env empty; certificate not installed this start\" >&2\nfi\n\n# (2) Wait, bounded (~90s), for the local API. /identity needs no auth.\ni=0\nwhile [ \"$i\" -lt 45 ]; do\n if [ \"$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 $API/identity 2>/dev/null)\" = \"200\" ]; then\n break\n fi\n i=$(expr $i + 1); sleep 2\ndone\n\n# (3) The prefs API needs the server token. It already lives in Preferences.xml on this\n# PVC; it is never echoed here, and Plex redacts tokens in its own request log. An\n# UNCLAIMED server has none \u2014 a normal state on a fresh /config \u2014 so skip, do not fail.\nTOKEN=$(grep -o 'PlexOnlineToken=\"[^\"]*\"' \"$PX\" 2>/dev/null | head -1 | cut -d'\"' -f2)\nif [ -z \"$TOKEN\" ]; then\n echo \"[plex-settings] not claimed yet \u2014 skipping prefs, will retry next start\" >&2\n exit 0\nfi\n\n# Percent-encode what our values actually contain. Plex takes prefs as QUERY parameters on\n# a PUT, so a raw '/' or ',' would be read as structure rather than data.\nenc() {\n printf '%s' \"$1\" | sed -e 's|%|%25|g' -e 's|/|%2F|g' -e 's|,|%2C|g' \\\n -e 's|:|%3A|g' -e 's| |%20|g'\n}\nsetpref() {\n code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PUT\
|
||||
\ \\\n -H \"X-Plex-Token: $TOKEN\" \"$API/:/prefs?$1=$(enc \"$2\")\" 2>/dev/null)\n if [ \"$code\" = \"200\" ]; then\n echo \"[plex-settings] set $1\"\n else\n echo \"[plex-settings] WARN: $1 -> HTTP $code\" >&2\n fi\n}\n\n# (4) THE LAN DEFINITION \u2014 the reason this hook was written. Without it Plex has NO notion\n# of a local network and tags EVERY client remote. Measured 2026-10-04: 468 requests\n# logged \"Allowed Network (WAN)\" and ZERO \"(LAN)\", including Seerr from inside the\n# cluster, which is why the server showed as Remote to LAN clients. Note allowedNetworks\n# (the ALLOWED_NETWORKS env) only waives AUTH \u2014 it does NOT make a client local. Two\n# different settings; only one of them has an env hook.\nsetpref LanNetworksBandwidth \"$LANNETS\"\n\n# (5) THE CERTIFICATE, which is what makes the `https` in ADVERTISE_IP servable. Set only\n# if the bundle exists \u2014 pointing Plex at a missing file would break its TLS listener.\nif [ -s \"$P12\" ]; then\n setpref customCertificatePath \"$P12\"\n setpref customCertificateKey \"$P12PASS\"\n setpref customCertificateDomain \"$CERTDOMAIN\"\nelse\n echo \"[plex-settings] WARN: no bundle; leaving certificate prefs untouched\" >&2\nfi\nexit 0\n"
|
||||
volumes:
|
||||
- name: config
|
||||
persistentVolumeClaim:
|
||||
@@ -170,6 +192,28 @@ spec:
|
||||
port: 32400
|
||||
targetPort: 32400
|
||||
---
|
||||
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-radarr-32400
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: plex-allow-radarr-32400
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: plex
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
app.kubernetes.io/name: radarr
|
||||
k8s:io.kubernetes.pod.namespace: radarr
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '32400'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-seerr-32400
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
@@ -192,6 +236,28 @@ spec:
|
||||
- port: '32400'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): plex-allow-sonarr-32400
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: plex-allow-sonarr-32400
|
||||
namespace: plex
|
||||
labels:
|
||||
app.kubernetes.io/name: plex
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: plex
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
k8s:io.kubernetes.pod.namespace: sonarr
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '32400'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy gateway-ingress allow (R-NET-01/02): plex-allow-gateway-ingress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -319,6 +319,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): qbittorrent-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: qbittorrent-allow-monitoring-scrape
|
||||
namespace: qbittorrent
|
||||
labels:
|
||||
app.kubernetes.io/name: qbittorrent
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: qbittorrent
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): qbittorrent-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# promtool rule-unit tests for the wave-0 per-app `<app>-down` SLI rules.
|
||||
# Manifests under test: every PrometheusRule in apps/wave-0/ — gitea, jackett, pihole, plex,
|
||||
# qbittorrent (container/Deployment) and vm-demo (KubeVirt VMI). The harness discovers this file by
|
||||
# qbittorrent (container/Deployment), pihole (container/StatefulSet — `availability.replicas: 2`, so
|
||||
# its SLI reads kube_statefulset_status_replicas_ready; see schemas/renderer-contract.md
|
||||
# `### availability`) and vm-demo (KubeVirt VMI). The harness discovers this file by
|
||||
# the SUITE convention (`<dir>/tests/*.test.yaml`) and flattens those RENDERED manifests, so these
|
||||
# tests run against the exact expressions Flux applies, never a copy.
|
||||
#
|
||||
@@ -41,7 +43,7 @@ tests:
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
||||
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
||||
values: '1+0x20'
|
||||
@@ -81,7 +83,7 @@ tests:
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
||||
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
|
||||
values: '1+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
||||
values: '0+0x20'
|
||||
@@ -132,7 +134,7 @@ tests:
|
||||
values: '0+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="jackett", deployment="jackett"}'
|
||||
values: '0+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="pihole", deployment="pihole"}'
|
||||
- series: 'kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}'
|
||||
values: '0+0x20'
|
||||
- series: 'kube_deployment_status_replicas_available{namespace="plex", deployment="plex"}'
|
||||
values: '1+0x20'
|
||||
@@ -170,10 +172,10 @@ tests:
|
||||
severity: critical
|
||||
service: pihole
|
||||
namespace: pihole
|
||||
deployment: pihole
|
||||
statefulset: pihole
|
||||
exp_annotations:
|
||||
summary: "pihole is down"
|
||||
description: "pihole: its Deployment has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_deployment_status_replicas_available)."
|
||||
description: "pihole: its StatefulSet has no available/ready replica for 5m, or the signal is gone entirely (SLI: up, via kube_statefulset_status_replicas_ready)."
|
||||
- eval_time: 10m
|
||||
alertname: qbittorrent-down
|
||||
exp_alerts:
|
||||
|
||||
@@ -153,6 +153,8 @@ spec:
|
||||
- --allowed-group=platform-admins
|
||||
- --skip-provider-button=true
|
||||
- --redirect-url=https://radarr.${BASE_DOMAIN}/oauth2/callback
|
||||
- --api-route=^/api/
|
||||
- --api-route=^/signalr/
|
||||
- --cookie-name=_oauth2_proxy_radarr
|
||||
- --cookie-domain=radarr.${BASE_DOMAIN}
|
||||
- --whitelist-domain=.${BASE_DOMAIN}
|
||||
@@ -281,6 +283,28 @@ spec:
|
||||
- port: '9117'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy egress allow (R-NET-02): radarr-allow-plex-32400
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: radarr-allow-plex-32400
|
||||
namespace: radarr
|
||||
labels:
|
||||
app.kubernetes.io/name: radarr
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: radarr
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
app.kubernetes.io/name: plex
|
||||
k8s:io.kubernetes.pod.namespace: plex
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '32400'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): radarr-allow-seerr-7878
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
@@ -323,6 +347,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): radarr-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: radarr-allow-monitoring-scrape
|
||||
namespace: radarr
|
||||
labels:
|
||||
app.kubernetes.io/name: radarr
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: radarr
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): radarr-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -281,6 +281,28 @@ spec:
|
||||
- port: '9117'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy egress allow (R-NET-02): sonarr-allow-plex-32400
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: sonarr-allow-plex-32400
|
||||
namespace: sonarr
|
||||
labels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
egress:
|
||||
- toEndpoints:
|
||||
- matchLabels:
|
||||
app.kubernetes.io/name: plex
|
||||
k8s:io.kubernetes.pod.namespace: plex
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '32400'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy reciprocal ingress allow (R-NET-02): sonarr-allow-seerr-8989
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
@@ -323,6 +345,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: sonarr-allow-monitoring-scrape
|
||||
namespace: sonarr
|
||||
labels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -208,6 +208,19 @@ inClusterClients:
|
||||
namespace: flux-system # matched by NAMESPACE alone — the flux controllers' pod labels belong
|
||||
# to upstream's manifests, not to this platform's contract
|
||||
port: 3000 # ONLY the Gitea HTTP port — the same single port the Gateway allow opens
|
||||
# The PM agent service (platform/pm-agent/). Its dispatcher reads issues and posts comments
|
||||
# over this port, and every worker Job clones, pushes and opens pull requests over it. Like
|
||||
# Flux's source-controller above, it dials the ClusterIP DIRECTLY — deliberately, so the
|
||||
# agent's path to the repository depends on neither public DNS, nor Cloudflare, nor the
|
||||
# shared Gateway, nor ${BASE_DOMAIN}. And exactly as for Flux, that means this namespace's
|
||||
# own default-deny (R-NET-01) DROPS the connection unless it is declared here: without this
|
||||
# entry the dispatcher's egress allow is satisfied and the packet is still discarded at the
|
||||
# Gitea end, which presents as an authentication timeout rather than as a policy denial.
|
||||
# That is the 2026-09-13 lesson documented at length above, applied before it is relearned.
|
||||
- name: pm-agent
|
||||
namespace: pm-agent # matched by NAMESPACE alone, so the dispatcher Deployment and every
|
||||
# ephemeral worker Job are covered by the one declaration
|
||||
port: 3000 # ONLY the Gitea HTTP port
|
||||
auth:
|
||||
mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC" set; R-IDP-02)
|
||||
groups:
|
||||
|
||||
@@ -51,6 +51,23 @@ auth:
|
||||
# This grants no real privilege: platform-admins already maps to cluster-admin via
|
||||
# k8s-oidc-rbac.yaml (oidc:platform-admins), so a member can already exec into these pods.
|
||||
- platform-admins # operators of these services must not be locked out of them
|
||||
# Answer Jackett's OWN API with 401 instead of a 302 to Keycloak (schema 1.9.0; renders
|
||||
# --api-route, singular and repeatable, onto the oauth2-proxy sidecar).
|
||||
#
|
||||
# MEASURED on this pod's access log, 2026-09-30: on /api/ paths, 33 x 302 against 2 x 401 —
|
||||
# and 4 of 6 POSTs were answered BY THE PROXY and never reached Jackett (access-log upstream
|
||||
# field `-`, not `/`), including both `/api/v2.0/server/update` calls. Jackett's jQuery does
|
||||
# not send the exact `Accept: application/json` oauth2-proxy's AJAX guard requires, so the
|
||||
# 302 was followed invisibly, satisfied silently by the live Keycloak SSO session, and
|
||||
# resolved 200 carrying the dashboard's HTML where the JavaScript expected JSON. A browser
|
||||
# following a 302 also re-issues a POST as a GET and DROPS THE BODY, which is why "adding an
|
||||
# indexer silently does nothing" was the user-visible symptom and nothing logged a failure.
|
||||
#
|
||||
# SCOPE: `/api/` only. Jackett serves its UI at /UI/ and its static assets elsewhere, and a
|
||||
# browser never NAVIGATES to /api/ — so a plain page load is still 302'd into a silent SSO
|
||||
# sign-in, which is the behaviour that must not change.
|
||||
apiRoutes:
|
||||
- ^/api/
|
||||
update:
|
||||
channel: stable # R-UPD-01
|
||||
range: ">=0.22.0" # stay on the Jackett 0.22.x stable line
|
||||
|
||||
+235
-1
@@ -12,6 +12,42 @@ storage:
|
||||
- name: config
|
||||
size: 2Gi
|
||||
class: longhorn # replicated config state; DR-critical (docs/04 line 118; R-STATE-02/03)
|
||||
# THE PVC WAS MOUNTED SOMEWHERE PIHOLE NEVER WRITES, AND SO HELD NOTHING AT ALL. Without an
|
||||
# explicit mountPath the renderer defaults to /data/<name>, i.e. /data/config — but PiHole keeps
|
||||
# every piece of its state in /etc/pihole. MEASURED on live prod 2026-10-04: `/etc/pihole` (7.1 M
|
||||
# — gravity.db, pihole.toml, the query DB) was on the **overlay** filesystem, i.e. the
|
||||
# container's EPHEMERAL layer, while `/dev/longhorn/pvc-849edb86...` sat mounted at
|
||||
# /data/config holding `lost+found` and 24 K of nothing. Three consequences, all live until now:
|
||||
# * every restart silently discarded PiHole's whole state, re-downloading 72 k gravity domains
|
||||
# and regenerating the admin password hash;
|
||||
# * `backup.policy: daily-offcluster` below has been faithfully backing up an EMPTY volume,
|
||||
# which is worse than no backup because it reports success;
|
||||
# * it is also why phase 1's password/adlist declaration mattered more than it first appeared —
|
||||
# that state was not merely undeclared, it was ephemeral.
|
||||
# This is the same class of bug catalog/plex.yaml's `config` entry documents ("Without this the
|
||||
# PVC sat unused and Plex wrote to the container's EPHEMERAL layer").
|
||||
#
|
||||
# /etc/pihole IS THE IMAGE'S INTENDED MOUNT POINT, and an EMPTY volume there self-initialises.
|
||||
# Verified in the running image, not assumed:
|
||||
# * `/usr/bin/start.sh` (PID 1) calls `migrate_gravity`, whose `migrate_to_database()` creates
|
||||
# the gravity database when `[ ! -e "$gravityDBfile" ]` — the same branch the postStart hook
|
||||
# below relies on, and the branch that produced the current ephemeral copy.
|
||||
# * FTL writes `pihole.toml` itself from the `FTLCONF_*` env above (that is what the
|
||||
# `### CHANGED (env)` markers in it are).
|
||||
# * NO `podSecurityContext.fsGroup` is needed and adding one would be inert: `start.sh` line 5
|
||||
# is `chown -R pihole:pihole /etc/pihole/ /var/log/pihole/` and PID 1 runs as **uid 0**, so
|
||||
# the image fixes ownership of a fresh root-owned volume before anything else starts. Same
|
||||
# arrangement as plex's image managing its own /config ownership.
|
||||
# * the image's own default for `files.macvendor` was moved OUT of this directory (to
|
||||
# `/macvendor.db`), which is what a maintainer does to a path they expect to be a volume.
|
||||
#
|
||||
# WHY IT IS FIXED IN THIS BRANCH rather than left for its own: `availability.replicas: 2` renders
|
||||
# per-replica volumeClaimTemplates, and with the wrong mountPath those would be two 2 Gi Longhorn
|
||||
# volumes (times three Longhorn replicas each) holding nothing but `lost+found` while all state
|
||||
# stayed ephemeral — paying the cost of persistence and getting none of it. It also REMOVES this
|
||||
# change's main operational risk: with gravity.db persisted, a pod restart is cheap, instead of
|
||||
# every restart re-downloading 72 k domains inside a wave that has a 5 m timeout.
|
||||
mountPath: /etc/pihole
|
||||
dependencies: [] # default-deny: reaches only DNS; it PROVIDES DNS (docs/03; R-NET-01/02)
|
||||
# ------------------------------------------------------------------------------------------------
|
||||
# LAN SPLIT-HORIZON DNS — the half of R-DNS-03 that platform/coredns/coredns-custom.yaml defers to.
|
||||
@@ -98,8 +134,95 @@ env:
|
||||
# then silently wrong: FTL stores `["a","b"]` as ONE array element containing those literal
|
||||
# characters, writes that single bogus line into /etc/pihole/dnsmasq.conf and reports "0 invalid"
|
||||
# while resolution for the zone does not work. Confirmed both ways against the live image.
|
||||
# plex-vip -> ${PLEX_L4_VIP} (added 2026-10-04) is the LAN HALF of Plex's single advertised
|
||||
# hostname. Plex publishes ONE URL to plex.tv for both local and remote clients
|
||||
# (catalog/plex.yaml ADVERTISE_IP): on the LAN this answer points it at the tcp/32400
|
||||
# LoadBalancer, while Cloudflare answers the same name publicly with the WAN address, NATed to
|
||||
# the same VIP. WITHOUT THIS LINE the entry above answers it with ${GATEWAY_VIP} via the zone
|
||||
# wildcard, where NOTHING listens on 32400 — measured 2026-10-04, plex-vip.${BASE_DOMAIN}
|
||||
# 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}"
|
||||
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.
|
||||
#
|
||||
# `webserver.api.password` takes CLEARTEXT and FTL hashes it itself into `webserver.api.pwhash`.
|
||||
# Verified in the running image, not assumed: `pihole setpassword` is itself
|
||||
# `setFTLConfigValue "webserver.api.password" "$PASSWORD"`, commented upstream as "pihole-FTL will
|
||||
# automatically hash the password". So the BALLOON-SHA256 hash on the PVC is now a DERIVED artefact
|
||||
# and this is its source of truth. Rotating the Secret rotates the password.
|
||||
- name: FTLCONF_webserver_api_password
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: pihole-bootstrap
|
||||
key: webui-password
|
||||
availability:
|
||||
# TWO REPLICAS, ON DIFFERENT NODES. LAN DNS is a critical network service and it was ONE pod on
|
||||
# ONE node. Measured 2026-10-04, that cost more than "until it reschedules":
|
||||
# * the node running the pod dies -> ~40s to mark it NotReady, then `tolerationSeconds: 300`
|
||||
# before the pod is even evicted, then reschedule, then Longhorn must force-detach the RWO
|
||||
# volume from a dead node and reattach it. Roughly 6-10 MINUTES with no LAN resolution.
|
||||
# * the node holding the `pihole-dns-*` L2 lease dies -> Cilium re-elects an announcer, tens of
|
||||
# seconds. (These were DIFFERENT nodes: lease on k3s-prod-2, pod on k3s-prod-1. Two of three
|
||||
# nodes were single points of failure for LAN DNS, with wildly different blast radii.)
|
||||
# The Services already tolerate this: `externalTrafficPolicy: Cluster` on the DNS LoadBalancers
|
||||
# (apps/wave-0/pihole-dns.yaml) means the L2 lease holder forwards to ANY ready backend, so a
|
||||
# second replica is picked up with no change there.
|
||||
#
|
||||
# WHAT THIS DOES NOT FIX, and it is the bigger risk: LAN DNS still depends on the CLUSTER. etcd
|
||||
# quorum loss, a Cilium regression, a Longhorn problem or a bad reconcile takes DNS down
|
||||
# everywhere regardless of replica count. The only thing that removes that dependency is a
|
||||
# resolver outside the cluster carrying the same `address=` lines. Considered and not chosen
|
||||
# (operator decision, 2026-10-04); see decisions/2026-10-04-pihole-multi-replica.md.
|
||||
#
|
||||
# THE PREREQUISITE WAS DONE FIRST, and this field would have been actively harmful without it. A
|
||||
# replica gets a FRESH volume, and until 2026-10-04 PiHole's admin password and adlists lived only
|
||||
# on the PVC — so replica 1 would have come up SERVING DNS AND BLOCKING NOTHING, with a different
|
||||
# password, and answers would have varied by which pod replied. Both are now declared (the
|
||||
# password as a SOPS Secret -> FTLCONF_webserver_api_password; the adlists reconciled into
|
||||
# gravity.db by the postStart hook below), which is what makes a fresh replica identical to its
|
||||
# sibling. See decisions/2026-10-04-pihole-declare-state.md.
|
||||
#
|
||||
# WHY 2 AND NOT 3. Two tolerates the loss of any single node, which is the stated goal, and leaves
|
||||
# a spare node for the evicted replica to land on under the HARD anti-affinity the renderer emits.
|
||||
# Three would pin one replica per node with no spare, so a single node failure would leave a
|
||||
# replica permanently Pending and — `apps-wave-0` being `wait: true` — stall the app tier for as
|
||||
# long as the node was down. Two is the count that degrades gracefully here.
|
||||
replicas: 2
|
||||
ingressRoutes:
|
||||
- host: pihole.${BASE_DOMAIN}
|
||||
port: 80 # admin web UI, protected at the Gateway by forward-auth (docs/09)
|
||||
@@ -107,6 +230,117 @@ auth:
|
||||
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no native OIDC (docs/15; R-IDP-02)
|
||||
groups:
|
||||
- platform-admins # sensitive admin UI: authenticated AND in group (R-IDP-06)
|
||||
# NO `apiRoutes` HERE, DELIBERATELY. PiHole's admin UI serves no /api/ path, so the `^/api/`
|
||||
# that jackett and radarr need would match nothing on a good day — and a BROADER guess would
|
||||
# answer PiHole's own page loads with 401, leaving a logged-out operator on a bare error page
|
||||
# with no sign-in offered. This app is the reason auth.apiRoutes is per-service (schema 1.9.0)
|
||||
# rather than a renderer constant: the measurement that justifies the flag on two apps is
|
||||
# evidence about those two apps. Its oauth2-proxy log holds 0 unauthenticated requests across
|
||||
# the current pod's whole 23h lifetime (measured 2026-10-01), so there is nothing to act on.
|
||||
lifecycle:
|
||||
# LEVEL-TRIGGERED ADLIST ASSERTION, run on EVERY container start. Same pattern, and the same
|
||||
# reasoning, as catalog/qbittorrent.yaml's postStart: desired state lives in git and is
|
||||
# RE-ASSERTED unconditionally rather than set once by hand.
|
||||
#
|
||||
# WHY THIS IS NEEDED AT ALL — the blocklists were NOT reproducible from git. Measured on live prod
|
||||
# 2026-10-04: `gravity.db` held exactly ONE adlist (StevenBlack's hosts, enabled) and 72,234
|
||||
# gravity domains, and NOTHING in this repo named it. Worse, a FRESH database does not seed it:
|
||||
# the image's own schema template `/etc/.pihole/advanced/Templates/gravity.db.sql` creates the
|
||||
# `adlist` table and inserts NO rows into it (grepped in the running image — there is no
|
||||
# `INSERT INTO adlist` at all). So a rebuild from git, or a replica with a new PVC, would come up
|
||||
# SERVING DNS AND BLOCKING NOTHING, silently and with every object reporting healthy.
|
||||
#
|
||||
# WHY A postStart HOOK AND NOT A FILE. PiHole v6 keeps adlists as DB rows, not config. The
|
||||
# `/etc/pihole/adlists.list` file still exists in `gravity.sh` but is MIGRATION-ONLY: its import
|
||||
# sits inside `migrate_to_database()`, guarded by `if [ ! -e "$gravityDBfile" ]`, so it fires only
|
||||
# while CREATING a database and never again on an existing one. A ConfigMap mounted there would
|
||||
# therefore be silently ignored on every pod this cluster currently runs. There is also no
|
||||
# `pihole adlist` CLI subcommand (only allow/deny for domains), so SQL against the DB is the
|
||||
# actual interface. FTL ships an embedded shell (`pihole-FTL sqlite3`, SQLite 3.53.1) so no extra
|
||||
# tooling is needed in the image.
|
||||
#
|
||||
# IT RECONCILES, IT DOES NOT JUST ADD: adlists present in the DB but absent from DECLARED below are
|
||||
# DISABLED (not deleted — deleting cascades to `gravity` rows and would force a full re-download of
|
||||
# everything on a typo). So this list is the source of truth in both directions, and `pihole -g`
|
||||
# runs ONLY when something actually changed, which keeps the steady-state start free of a 70k-domain
|
||||
# rebuild.
|
||||
#
|
||||
# NO BRACED DOLLAR EXPANSION ANYWHERE IN THIS SCRIPT, DELIBERATELY — the same contract
|
||||
# catalog/qbittorrent.yaml documents at length. apps-wave-0 declares postBuild.substituteFrom
|
||||
# cluster-vars, so Flux runs envsubst over this whole manifest at reconcile time; a `${VAR}` form
|
||||
# would behave differently on the cluster than under a plain `kubectl apply` of the golden. Bare
|
||||
# `$VAR` and `$(cmd)` are untouched by envsubst and so behave identically however this is run.
|
||||
# `render.py`'s assert_substitution_safe refuses to emit a manifest that breaks this.
|
||||
postStart:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
# Assert the declared adlists in gravity.db. Idempotent: steady state writes nothing and
|
||||
# does NOT run a gravity rebuild.
|
||||
set -u
|
||||
GRAVITY=/etc/pihole/gravity.db
|
||||
|
||||
# THE DECLARED SET — one URL per line, the source of truth for what PiHole blocks.
|
||||
# To add a list: add a line here, commit, let Flux reconcile. Nothing else to do.
|
||||
DECLARED='https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts'
|
||||
|
||||
# On a fresh PVC, FTL creates gravity.db a moment after the container starts. Wait, bounded,
|
||||
# and NEVER block or fail the start: a missing DB here must not crashloop DNS for the LAN.
|
||||
i=0
|
||||
while [ ! -s "$GRAVITY" ] && [ "$i" -lt 90 ]; do sleep 2; i=$(expr $i + 1); done
|
||||
if [ ! -s "$GRAVITY" ]; then
|
||||
echo "pihole-adlists: gravity.db absent after 180s - leaving adlists untouched" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sql() { pihole-FTL sqlite3 "$GRAVITY" "$1"; }
|
||||
CHANGED=0
|
||||
|
||||
# 1) Every declared URL must be present AND enabled.
|
||||
echo "$DECLARED" | while IFS= read -r url; do
|
||||
[ -n "$url" ] || continue
|
||||
printf '%s\n' "$url"
|
||||
done > /tmp/declared.txt
|
||||
while IFS= read -r url; do
|
||||
[ -n "$url" ] || continue
|
||||
# Single quotes inside a URL would break this SQL; adlist URLs do not contain them.
|
||||
have=$(sql "select count(*) from adlist where address='$url';")
|
||||
if [ "$have" = "0" ]; then
|
||||
echo "pihole-adlists: adding $url"
|
||||
sql "insert into adlist (address,enabled,comment) values ('$url',1,'declared in catalog/pihole.yaml');"
|
||||
CHANGED=1
|
||||
else
|
||||
en=$(sql "select enabled from adlist where address='$url';")
|
||||
if [ "$en" != "1" ]; then
|
||||
echo "pihole-adlists: re-enabling $url"
|
||||
sql "update adlist set enabled=1 where address='$url';"
|
||||
CHANGED=1
|
||||
fi
|
||||
fi
|
||||
done < /tmp/declared.txt
|
||||
|
||||
# 2) Anything enabled in the DB but NOT declared gets disabled (never deleted - see above).
|
||||
sql "select address from adlist where enabled=1;" | while IFS= read -r addr; do
|
||||
[ -n "$addr" ] || continue
|
||||
if ! grep -qxF "$addr" /tmp/declared.txt; then
|
||||
echo "pihole-adlists: disabling undeclared $addr"
|
||||
sql "update adlist set enabled=0 where address='$addr';"
|
||||
echo changed >> /tmp/adlist-changed
|
||||
fi
|
||||
done
|
||||
[ -s /tmp/adlist-changed ] && CHANGED=1
|
||||
rm -f /tmp/adlist-changed /tmp/declared.txt
|
||||
|
||||
# 3) Rebuild gravity ONLY if the declared set actually moved.
|
||||
if [ "$CHANGED" = "1" ]; then
|
||||
echo "pihole-adlists: declared set changed - running gravity update"
|
||||
pihole -g || echo "pihole-adlists: gravity update failed; existing gravity.db retained" >&2
|
||||
else
|
||||
echo "pihole-adlists: already matches the declared set - nothing to do"
|
||||
fi
|
||||
exit 0
|
||||
update:
|
||||
channel: stable # R-UPD-01
|
||||
range: ">=2024.0.0"
|
||||
|
||||
+222
-9
@@ -72,14 +72,227 @@ env:
|
||||
secretKeyRef:
|
||||
name: plex-claim
|
||||
key: PLEX_CLAIM
|
||||
# ADVERTISE_IP ("Custom server access URLs") is DEFERRED, not accidentally omitted. It tells Plex
|
||||
# which URL to advertise to plex.tv for REMOTE clients. The only candidate is
|
||||
# https://plex.home.keithjackson.dev, but that host resolves via split-horizon DNS to the INTERNAL
|
||||
# Gateway VIP (conformance/tests/l2-behavioral/dns-internal-resolution), and both its public
|
||||
# reachability and whether the TLS-terminating Cilium Gateway proxies Plex's 32400 API correctly for
|
||||
# remote access are UNVERIFIED. Advertising a wrong/internal-only URL breaks remote access for ALL
|
||||
# clients, so per "prefer safety over completeness" it is left unset. FOLLOW-UP: once remote
|
||||
# reachability through the Gateway is confirmed, set ADVERTISE_IP: "https://plex.home.keithjackson.dev:443".
|
||||
# ADVERTISE_IP ("Custom server access URLs") — the connection Plex publishes to plex.tv.
|
||||
#
|
||||
# THE VALUE MUST BE RESOLVABLE BY EVERY CLIENT, NOT JUST BY CLIENTS THAT USE OUR DNS. That single
|
||||
# property is what this field keeps getting wrong, so it is the first thing to check before
|
||||
# changing it. A Plex client resolves the advertised host ITSELF, with whatever resolver it
|
||||
# happens to use — and on a real LAN that is frequently NOT PiHole: browsers ship DoH on by
|
||||
# default, phones have "private DNS", guest and IoT devices often take the router's DNS, and some
|
||||
# clients hard-code a public resolver.
|
||||
#
|
||||
# THAT IS NOT THEORETICAL — IT TOOK PLEX DOWN FOR THE WHOLE LAN ON 2026-10-05. This field briefly
|
||||
# advertised `plex-vip.${BASE_DOMAIN}`, a name that exists ONLY in PiHole's split-horizon answers
|
||||
# (catalog/pihole.yaml) and in no public zone. Clients that did not use PiHole could not resolve
|
||||
# it; the SOHO router answered the failed lookup with ITSELF, and every one of them landed on the
|
||||
# router's own web UI and reported a certificate error for `CN = %D`. Measured 2026-10-05: the
|
||||
# router serves that self-signed cert (`C = ZZ, O = %D342e7e6b, CN = %D`) on BOTH `192.168.2.1:443`
|
||||
# and the WAN address, while PiHole itself was answering perfectly — both replicas returned
|
||||
# `plex-vip -> ${PLEX_L4_VIP}` and `plex -> ${GATEWAY_VIP}`. The DNS was right; the DEPENDENCY on
|
||||
# it was the bug. Nothing Plex advertises listens on 443, which is how we know the clients had left
|
||||
# the Plex path entirely and were talking to the router.
|
||||
#
|
||||
# SO: `<dashed-ip>.<serverhash>.plex.direct`, WHICH IS RESOLVER-INDEPENDENT. This is Plex's own
|
||||
# mechanism and the reason it exists: `plex.direct` is a PUBLIC zone that returns the private
|
||||
# address embedded in the label, so EVERY resolver on earth — PiHole, the router, 1.1.1.1, a DoH
|
||||
# browser — answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no
|
||||
# split-horizon entry, no Cloudflare record, and no certificate work, because the wildcard cert
|
||||
# Plex already serves on 32400 covers exactly this name. Re-verified live 2026-10-05 with FULL
|
||||
# validation (no `curl -k`): resolves to 192.168.2.24 and `/identity` returns **200, TLS verified**.
|
||||
#
|
||||
# WHY NOT THE NICER HOSTNAME, since a readable name in our own zone is obviously more pleasant:
|
||||
# it cannot be made to work for clients that bypass PiHole. A public record for it would resolve
|
||||
# to the WAN address, and with no NAT hairpin on this router a LAN client sent there fails too —
|
||||
# so the hostname is correct ONLY for clients using PiHole, and wrong for the rest. There is no
|
||||
# configuration of that name that is right for both. `plex.direct` is right for both.
|
||||
# (The PiHole `plex-vip` answer is KEPT — it is harmless, costs nothing, and is genuinely useful
|
||||
# for reaching the VIP by name from a LAN shell. It is simply not what Plex advertises.)
|
||||
#
|
||||
# THE TWO EMBEDDED VALUES, and the honest cost of this choice. The dashed IP must track
|
||||
# ${PLEX_L4_VIP} and `<serverhash>` is this server's certificate identity — read it from the live
|
||||
# listener with
|
||||
# openssl s_client -connect <vip>:32400 </dev/null | openssl x509 -noout -subject
|
||||
# Nothing in this tree can derive either, and nothing checks that they still agree: envsubst
|
||||
# cannot dash an IP, so this is a literal. That fragility is real and was the reason the hostname
|
||||
# was tried in the first place — it is accepted here because an unresolvable name is a WORSE
|
||||
# failure (silent, LAN-wide, and it points users at the router) than a stale literal, which at
|
||||
# least fails loudly and only after someone changes the VIP.
|
||||
#
|
||||
# The image's /etc/cont-init.d/40-plex-first-run runs `setPref "customConnections"
|
||||
# "${ADVERTISE_IP}"` (line 127, verified in the running image), OUTSIDE the
|
||||
# PLEX_CLAIM/PlexOnlineToken guard, with its `/.firstRunComplete` marker on the container OVERLAY
|
||||
# fs rather than the /config PVC — so this is re-applied on EVERY pod start and git, not a web
|
||||
# form, owns the value.
|
||||
- name: ADVERTISE_IP
|
||||
value: https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400
|
||||
# THE TLS MATERIAL FOR THE NAME ABOVE, as PEM text in env rather than a mounted volume.
|
||||
# `plex-vip-tls` is issued by cert-manager from ClusterIssuer `letsencrypt-prod` for exactly
|
||||
# `plex-vip.${BASE_DOMAIN}` (apps/wave-0/plex-cert.yaml, same wave => same reconcile as this
|
||||
# workload). The postStart hook converts it to the PKCS#12 bundle Plex requires.
|
||||
#
|
||||
# WHY ENV AND NOT A VOLUME, which would be the conventional choice. MEASURED 2026-10-04:
|
||||
# `schemas/servicedefinition.schema.json` `storage` accepts only
|
||||
# [class, export, mountPath, name, readOnly, size, subPath] with `additionalProperties: false`,
|
||||
# `catalog/render.py` emits NO secret volumes, and no rendered app mounts a Secret as a volume.
|
||||
# Mounting one therefore needs a schema + renderer change; `env.valueFrom.secretKeyRef` is
|
||||
# already in the schema and already used by PLEX_CLAIM above. PEM is text, so it survives an env
|
||||
# var intact (a raw PKCS#12 would not — it is binary and carries NUL bytes).
|
||||
#
|
||||
# COST, STATED PLAINLY: the private key is readable from this container's environment
|
||||
# (/proc/1/environ, `kubectl exec env`) by anything that can already exec into the pod. That is
|
||||
# the same audience that can read the converted bundle under /config, so the marginal exposure is
|
||||
# small — but a secret VOLUME would still be the better shape, and adding secret-volume support
|
||||
# to the schema is the clean follow-up that removes this note.
|
||||
#
|
||||
# `optional: true` IS LOAD-BEARING, NOT DEFENSIVE CLUTTER (schema 1.11.0, added for this). The
|
||||
# Certificate is issued INTO THIS NAMESPACE, and the namespace is created by the rendered
|
||||
# apps/wave-0/plex.yaml itself — so the cert CANNOT be issued in an earlier tier, and the
|
||||
# Certificate and this Deployment land in the SAME reconcile. On a cold bootstrap the Secret is
|
||||
# therefore briefly absent while cert-manager completes a DNS01 challenge. With a HARD ref, Plex
|
||||
# would sit in CreateContainerConfigError until a certificate issued — and because `apps-wave-0`
|
||||
# is `wait: true` with `apps-wave-1` dependsOn it, a failed or slow issuance would STALL THE WHOLE
|
||||
# APP TIER (the same hazard the `hardware` block below documents for the GPU resource). Optional
|
||||
# instead means: no cert -> Plex still starts and serves, the postStart hook logs that the
|
||||
# certificate was not installed, and the next start after issuance converges. Plex falls back to
|
||||
# its own `*.plex.direct` certificate in the meantime, so the degraded state is "advertised URL
|
||||
# not yet servable over TLS", not "media server down".
|
||||
- name: PLEX_TLS_CRT
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: plex-vip-tls
|
||||
key: tls.crt
|
||||
optional: true
|
||||
- name: PLEX_TLS_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: plex-vip-tls
|
||||
key: tls.key
|
||||
optional: true
|
||||
lifecycle:
|
||||
# LEVEL-TRIGGERED PLEX SETTINGS, re-asserted on EVERY container start — the same pattern and the
|
||||
# same reasoning as catalog/pihole.yaml's and catalog/qbittorrent.yaml's postStart: desired state
|
||||
# lives in git and is re-asserted unconditionally rather than typed into a web form once. Typing
|
||||
# settings into the web form is precisely what took this service down on 2026-10-04.
|
||||
#
|
||||
# IT EXISTS BECAUSE THE IMAGE HAS NO ENV FOR THESE SETTINGS. Measured 2026-10-04 across the whole
|
||||
# init surface (/etc/cont-init.d, /etc/services.d, /plex-common.sh, /usr/local/bin): the image
|
||||
# calls setPref for exactly SIX prefs — MachineIdentifier, ProcessedMachineIdentifier,
|
||||
# PlexOnlineToken, customConnections, allowedNetworks, TranscoderTempDirectory — and supports NO
|
||||
# generic PLEX_PREFERENCE* hook of any kind. The two things we need (a LAN definition and a custom
|
||||
# certificate) are reachable ONLY through Plex's own API, so this hook is the only GitOps path.
|
||||
#
|
||||
# IT MUST NEVER FAIL. A postStart hook that exits non-zero makes kubelet KILL the container, so
|
||||
# every step is bounded and the script ends with an unconditional `exit 0`. A missing token, an
|
||||
# unissued certificate or an unreachable API degrades to "not applied this start" and is retried
|
||||
# on the next one; it never crashloops Plex.
|
||||
postStart:
|
||||
exec:
|
||||
command:
|
||||
- /bin/bash
|
||||
- -c
|
||||
- |
|
||||
# NOTE — LOAD-BEARING: SHELL VARIABLES HERE ARE WRITTEN BARE, NEVER BRACE-WRAPPED.
|
||||
# This whole script is rewritten by Flux postBuild envsubst BEFORE it ever runs, so a
|
||||
# brace-wrapped name is read as a cluster-vars knob: an undeclared one is a HARD
|
||||
# Kustomization failure under StrictPostBuildSubstitutions, and a declared one would be
|
||||
# replaced with its value. Shell vars are therefore `$PX`, `$TOKEN`, `$P12` — bare. Only
|
||||
# BASE_DOMAIN below is brace-wrapped, because it IS a knob and IS meant to be
|
||||
# substituted. Command substitution `$(cmd)` is left alone and needs no escaping. A shell
|
||||
# variable written with braces would be blanked or would fail the whole Kustomization —
|
||||
# this repo has already lost a gitea OIDC bootstrap to exactly that mistake, and
|
||||
# catalog/render.py now refuses to emit it (which is how this very comment got caught).
|
||||
# DELIBERATELY NO `set -u`. The TLS env vars are declared `optional: true`, so when the
|
||||
# certificate has not issued yet they are genuinely UNSET, not empty — and under `set -u`
|
||||
# the first `[ -n "$PLEX_TLS_CRT" ]` would abort the script, which for a postStart hook
|
||||
# means kubelet KILLS THE CONTAINER. That is the precise failure the `optional` flag exists
|
||||
# to prevent, so turning it back on here would defeat the whole design. (The alternative,
|
||||
# a brace-wrapped `:-` default, would need envsubst escaping — the trap the note above
|
||||
# describes.) Every value below is explicitly guarded instead.
|
||||
PX="/config/Library/Application Support/Plex Media Server/Preferences.xml"
|
||||
CERTDIR=/config/certs
|
||||
P12=$CERTDIR/plex-vip.p12
|
||||
# The PKCS#12 passphrase protects nothing and is deliberately not a secret: Plex needs it
|
||||
# in cleartext in customCertificateKey, and it and the bundle sit on the same PVC. It is a
|
||||
# container-format requirement, not an access control.
|
||||
P12PASS=plexvip
|
||||
LANNETS='10.0.0.0/8,192.168.0.0/16'
|
||||
CERTDOMAIN=plex-vip.${BASE_DOMAIN}
|
||||
API=http://127.0.0.1:32400
|
||||
|
||||
# (1) Build the PKCS#12 bundle from the PEM the Secret supplies. Rebuilt on every start so
|
||||
# a cert-manager RENEWAL is picked up with no extra moving part.
|
||||
if [ -n "$PLEX_TLS_CRT" ] && [ -n "$PLEX_TLS_KEY" ]; then
|
||||
mkdir -p $CERTDIR && chmod 700 $CERTDIR
|
||||
printf '%s' "$PLEX_TLS_CRT" > $CERTDIR/tls.crt
|
||||
printf '%s' "$PLEX_TLS_KEY" > $CERTDIR/tls.key
|
||||
chmod 600 $CERTDIR/tls.key
|
||||
if openssl pkcs12 -export -out $P12.tmp -inkey $CERTDIR/tls.key \
|
||||
-in $CERTDIR/tls.crt -passout pass:$P12PASS 2>/dev/null; then
|
||||
mv -f $P12.tmp $P12
|
||||
chown 1000:1000 $P12 2>/dev/null || true
|
||||
chmod 600 $P12
|
||||
echo "[plex-settings] PKCS#12 bundle written"
|
||||
else
|
||||
rm -f $P12.tmp
|
||||
echo "[plex-settings] WARN: pkcs12 export failed; keeping any previous bundle" >&2
|
||||
fi
|
||||
rm -f $CERTDIR/tls.key
|
||||
else
|
||||
echo "[plex-settings] WARN: TLS env empty; certificate not installed this start" >&2
|
||||
fi
|
||||
|
||||
# (2) Wait, bounded (~90s), for the local API. /identity needs no auth.
|
||||
i=0
|
||||
while [ "$i" -lt 45 ]; do
|
||||
if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 $API/identity 2>/dev/null)" = "200" ]; then
|
||||
break
|
||||
fi
|
||||
i=$(expr $i + 1); sleep 2
|
||||
done
|
||||
|
||||
# (3) The prefs API needs the server token. It already lives in Preferences.xml on this
|
||||
# PVC; it is never echoed here, and Plex redacts tokens in its own request log. An
|
||||
# UNCLAIMED server has none — a normal state on a fresh /config — so skip, do not fail.
|
||||
TOKEN=$(grep -o 'PlexOnlineToken="[^"]*"' "$PX" 2>/dev/null | head -1 | cut -d'"' -f2)
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "[plex-settings] not claimed yet — skipping prefs, will retry next start" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Percent-encode what our values actually contain. Plex takes prefs as QUERY parameters on
|
||||
# a PUT, so a raw '/' or ',' would be read as structure rather than data.
|
||||
enc() {
|
||||
printf '%s' "$1" | sed -e 's|%|%25|g' -e 's|/|%2F|g' -e 's|,|%2C|g' \
|
||||
-e 's|:|%3A|g' -e 's| |%20|g'
|
||||
}
|
||||
setpref() {
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X PUT \
|
||||
-H "X-Plex-Token: $TOKEN" "$API/:/prefs?$1=$(enc "$2")" 2>/dev/null)
|
||||
if [ "$code" = "200" ]; then
|
||||
echo "[plex-settings] set $1"
|
||||
else
|
||||
echo "[plex-settings] WARN: $1 -> HTTP $code" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
# (4) THE LAN DEFINITION — the reason this hook was written. Without it Plex has NO notion
|
||||
# of a local network and tags EVERY client remote. Measured 2026-10-04: 468 requests
|
||||
# logged "Allowed Network (WAN)" and ZERO "(LAN)", including Seerr from inside the
|
||||
# cluster, which is why the server showed as Remote to LAN clients. Note allowedNetworks
|
||||
# (the ALLOWED_NETWORKS env) only waives AUTH — it does NOT make a client local. Two
|
||||
# different settings; only one of them has an env hook.
|
||||
setpref LanNetworksBandwidth "$LANNETS"
|
||||
|
||||
# (5) THE CERTIFICATE, which is what makes the `https` in ADVERTISE_IP servable. Set only
|
||||
# if the bundle exists — pointing Plex at a missing file would break its TLS listener.
|
||||
if [ -s "$P12" ]; then
|
||||
setpref customCertificatePath "$P12"
|
||||
setpref customCertificateKey "$P12PASS"
|
||||
setpref customCertificateDomain "$CERTDOMAIN"
|
||||
else
|
||||
echo "[plex-settings] WARN: no bundle; leaving certificate prefs untouched" >&2
|
||||
fi
|
||||
exit 0
|
||||
# HARDWARE TRANSCODING — the host iGPU's render device (schema 1.8.0 `hardware`; R-PLAT-02).
|
||||
# INTENT ONLY, AND THE MECHANISM DELIBERATELY DOES NOT LIVE HERE. This says "this workload needs an
|
||||
# Intel Quick Sync render device"; it does NOT name a device path and it does NOT name a resource.
|
||||
@@ -138,7 +351,7 @@ exposure: public # edge port-forward + public DNS; clients + Seerr
|
||||
egress: internet # normal WAN egress (docs/03; R-NET-05)
|
||||
storage:
|
||||
- name: config
|
||||
size: 8Gi
|
||||
size: 16Gi
|
||||
class: longhorn # Plex DB/metadata: replicated app state on the 3-replica tier (docs/04; R-STATE-01/02)
|
||||
mountPath: /config # plexinc/pms-docker writes its identity/claim + "Library" DB under /config, NOT the
|
||||
# renderer default /data/config. Without this the PVC sat unused and Plex wrote to the
|
||||
|
||||
@@ -359,7 +359,7 @@ storage:
|
||||
# The live /config contents were copied onto each PVC before this landed,
|
||||
# so API keys and credentials other apps reference were preserved.
|
||||
# THE DOWNLOADS AREA — ONE mount of the SAME export sonarr/radarr/plex already consume
|
||||
# (192.168.2.102:/mnt/Storage/plex), narrowed by subPath to the `downloads` subtree and presented at
|
||||
# (192.168.2.30:/mnt/Storage/plex), narrowed by subPath to the `downloads` subtree and presented at
|
||||
# /downloads, which is precisely where this app is ALREADY configured to write.
|
||||
#
|
||||
# WHY subPath: downloads AND NOT THE EXPORT ROOT AT /media (the shape sonarr/radarr use):
|
||||
@@ -434,7 +434,7 @@ dependencies: [] # default-deny: reaches only DNS; arr reach it n
|
||||
# for weeks and neither has any CNP naming the NFS server or port 2049
|
||||
# (grep apps/wave-1/sonarr.yaml: none). The SAME fact is what keeps the VPN
|
||||
# egress gateway off this traffic (its selector is a podSelector), and
|
||||
# 192.168.2.102 is inside the 192.168.0.0/16 excludedCIDR besides — see
|
||||
# 192.168.2.30 is inside the 192.168.0.0/16 excludedCIDR besides — see
|
||||
# platform/vpn-egress/egress-steering.yaml, "BULK NFS".
|
||||
ingressRoutes:
|
||||
- host: qbittorrent.${BASE_DOMAIN}
|
||||
@@ -448,6 +448,12 @@ auth:
|
||||
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
|
||||
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
|
||||
- platform-admins # operators of these services must not be locked out of them
|
||||
# NO `apiRoutes` HERE: UNMEASURED, not cleared. qBittorrent's WebUI drives everything through
|
||||
# /api/v2/, so it is plausibly affected in the same way jackett is — but plausibility is what
|
||||
# put a renderer constant on the table in the first place. MEASURED 2026-10-01 on the live pod
|
||||
# (qbittorrent-5547bdb9bd-zq557, 0 restarts, 23h): 13,649 log lines, ZERO unauthenticated
|
||||
# requests, nothing but health probes. No traffic means no evidence either way. Add `^/api/`
|
||||
# when someone opens the WebUI and records the 302/401 split, as was done for jackett.
|
||||
update:
|
||||
channel: stable # R-UPD-01
|
||||
range: ">=5.0.0 <6.0.0" # stay on the qBittorrent 5.x stable line
|
||||
|
||||
@@ -100,6 +100,9 @@ dependencies:
|
||||
- to: jackett # arr → indexer proxy (docs/13 dependency graph; R-NET-02 explicit allow)
|
||||
port: 9117
|
||||
protocol: tcp
|
||||
- to: plex
|
||||
port: 32400 # arr -> plex: required since library is on NFS which does not provide file CRUD notifications
|
||||
protocol: tcp
|
||||
ingressRoutes:
|
||||
- host: radarr.${BASE_DOMAIN}
|
||||
port: 7878 # Radarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
||||
@@ -112,6 +115,32 @@ auth:
|
||||
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
|
||||
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
|
||||
- platform-admins # operators of these services must not be locked out of them
|
||||
# Answer Radarr's machine endpoints with 401 instead of a 302 to Keycloak (schema 1.9.0;
|
||||
# renders --api-route, singular and repeatable, onto the oauth2-proxy sidecar).
|
||||
#
|
||||
# `^/signalr/` — MEASURED ON THE LIVE POD, 2026-10-01, 23h after the cookie fix deployed
|
||||
# (radarr-7d55cbffb9-42hmh, 0 restarts): 232 unauthenticated requests in the pod's whole
|
||||
# lifetime and ALL 232 are the same call — `POST /signalr/messages/negotiate` from a browser
|
||||
# UA, answered 302 by the proxy with upstream `-`, i.e. Radarr never saw one of them. That is
|
||||
# a left-open tab whose session the cookie rename orphaned, polling SignalR forever, silently
|
||||
# redirected every time and NEVER re-prompting the operator. It is the reported symptom
|
||||
# ("reloading never fixes it and I am never re-prompted") still running in production on a
|
||||
# service nobody was watching. With 401 the UI's connection breaks LOUDLY, the operator
|
||||
# reloads, and `/` is outside both regexes so the reload signs them back in.
|
||||
#
|
||||
# `^/api/` — measured on radarr, but on the PRE-FIX pod and NOT by me: the 2026-09-30
|
||||
# diagnosis (decisions/2026-09-30-jackett-oauth2-proxy-session.md) recorded radarr's 156
|
||||
# unauthenticated requests as `/api/v3/command` (62), `/api/v3/queue/details` (46) and
|
||||
# `/signalr/messages/negotiate` (43). The current pod has logged ZERO /api/ traffic, so I did
|
||||
# not re-measure it; it is included because 108 of 156 measured rejections on this app were
|
||||
# /api/v3 XHRs, which is this app's own evidence rather than an analogy to another app.
|
||||
#
|
||||
# SCOPE: both are JSON/RPC namespaces a browser never NAVIGATES to. Radarr's UI is served at
|
||||
# `/` with assets under /Content/ and /MediaCover/, none of which match — so a plain page load
|
||||
# is still 302'd into a silent SSO sign-in.
|
||||
apiRoutes:
|
||||
- ^/api/
|
||||
- ^/signalr/
|
||||
update:
|
||||
channel: stable # R-UPD-01
|
||||
range: ">=5.0.0 <6.0.0" # stay on the Radarr v5 stable line
|
||||
|
||||
+580
-37
@@ -156,11 +156,17 @@ OAUTH2_COOKIE_REFRESH = "2m30s"
|
||||
# ready endpoints. The flag is confirmed present in the PINNED v7.8.1 binary (`--help`),
|
||||
# which matters because the shared proxy is on v7.15.3 and could not have vouched for it.
|
||||
#
|
||||
# THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED. A container port is not a
|
||||
# Service port: these sidecars' Services expose the app port and :4180, and each namespace's
|
||||
# default-deny baseline has `ingress: []`, so Prometheus can neither resolve nor reach this
|
||||
# port until a Service port (or PodMonitor), a monitoring-ingress allow and a ServiceMonitor
|
||||
# are added. Do not read this flag as "metrics are working".
|
||||
# THE SCRAPE WORKS AS OF 2026-10-05 — but it took THREE independent pieces, and this flag is
|
||||
# only the first. A container port is not a Service port, and each namespace's default-deny
|
||||
# baseline denies all ingress, so the flag alone bought nothing for 63.7h:
|
||||
# 1. this flag + a named `metrics` container port (PR #28)
|
||||
# 2. a monitor that targets POD ports, not Service ports — platform/oauth2-proxy/podmonitor.yaml
|
||||
# is a PodMonitor for exactly that reason (no Service exposes 44180)
|
||||
# 3. an ingress allowance from ns monitoring (PR #49, r_monitoring_ingress_policy below)
|
||||
# Verified on prod 2026-10-05: all 6 targets up == 1, scraping in 1-6ms.
|
||||
# STILL do not read this flag alone as "metrics are working" — 1 without 3 is precisely the state
|
||||
# that produced 5 x TargetDown for 63.7h. If you add a sidecar metrics port anywhere else, check
|
||||
# that r_monitoring_ingress_policy covers it.
|
||||
OAUTH2_PROXY_METRICS_PORT = 44180
|
||||
|
||||
# --- Hardware capability: `hardware.gpu: intel-quicksync` ------------------------------
|
||||
@@ -175,6 +181,45 @@ OAUTH2_PROXY_METRICS_PORT = 44180
|
||||
# prod nodes 2026-10-01: `.status.allocatable["gpu.intel.com/i915"] == 1`.
|
||||
QUICKSYNC_RESOURCE = "gpu.intel.com/i915"
|
||||
|
||||
# --- API routes: 401 instead of 302, PER APP ------------------------------------------
|
||||
# oauth2-proxy answers an unauthenticated request with a 302 to Keycloak. Its built-in AJAX
|
||||
# guard would send a 401 instead, but it fires only on an EXACT `Accept: application/json`,
|
||||
# which these apps' JavaScript does not consistently send. So an unauthenticated XHR gets a
|
||||
# 302, the browser follows it INVISIBLY, the live Keycloak SSO session satisfies it silently,
|
||||
# and the call resolves 200 carrying the app's HTML where the JavaScript expected JSON — no
|
||||
# prompt, no console error, nothing logged as a failure. On a WRITE it is worse: a browser
|
||||
# following a 302 re-issues a POST as a GET and DROPS THE BODY, so the operator's change is
|
||||
# discarded and the UI reports success. MEASURED, twice:
|
||||
# jackett 2026-09-30: 33 x 302 against 2 x 401 on /api/ paths, and 4 of 6 POSTs answered by
|
||||
# the proxy and never reaching the app (access-log upstream field `-`, not `/`).
|
||||
# radarr 2026-10-01, 23h after the cookie fix rolled: 232 x 302, EVERY ONE a
|
||||
# `POST /signalr/messages/negotiate` from the operator's own browser, upstream `-`.
|
||||
# A left-open tab whose cookie the rename orphaned, polling forever, silently
|
||||
# redirected every time, NEVER re-prompting. That is the reported symptom
|
||||
# ("reloading never fixes it and I am never re-prompted") still happening in
|
||||
# production, on a service nobody was looking at.
|
||||
# `--api-route=<regex>` makes matching paths answer 401.
|
||||
#
|
||||
# WHY THIS IS NOT A RENDERER CONSTANT, AND MUST NEVER BECOME ONE. The path shape is a property
|
||||
# of the APP, not of the forward-auth pattern: radarr's broken path is `/signalr/`, jackett's is
|
||||
# `/api/`, and PIHOLE SERVES NO `/api/` AT ALL. One constant would apply one app's path shape to
|
||||
# five, and the failure mode of a wrong one is not "no improvement" but "this service is
|
||||
# permanently broken": a path a BROWSER NAVIGATES to, answered 401, shows a logged-out operator
|
||||
# a bare error page and never offers a sign-in. So the routes are declared per service in
|
||||
# `auth.apiRoutes` (schema 1.9.0) and rendered one `--api-route` per entry — the flag is
|
||||
# repeatable, and it is SINGULAR: `--api-routes` does not exist in v7.8.1 and the proxy refuses
|
||||
# to start with it (verified against the pinned binary's own --help, 2026-10-01:
|
||||
# `--api-route strings return HTTP 401 instead of redirecting to authentication server...`).
|
||||
#
|
||||
# BREADTH RATCHET. Every entry must be anchored at `^/` and continue with literal path
|
||||
# characters. That rejects `^/`, `^/.*`, `.*`, an unanchored `/api/`, and any alternation — the
|
||||
# shapes that turn a page load into a 401. Alternation is refused rather than supported because
|
||||
# the flag is repeatable, so one regex per route costs nothing and stays readable. The same
|
||||
# pattern is in the schema (so ci/l0.sh's schema stage rejects it at the authoring surface) and
|
||||
# enforced HERE (so ci/render-golden.sh fails even if the schema stage is skipped) — a breadth
|
||||
# mistake must not need two independent gates to both be running.
|
||||
OAUTH2_API_ROUTE_PATTERN = re.compile(r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Defaults (renderer-contract.md "Default posture") — materialize BEFORE mapping.
|
||||
@@ -200,6 +245,12 @@ def apply_defaults(sd: dict) -> dict:
|
||||
if "mode" not in auth:
|
||||
auth["mode"] = "forward-auth" if s["ingressRoutes"] else "none"
|
||||
auth.setdefault("groups", [])
|
||||
# apiRoutes default posture is EMPTY, deliberately, and that is the whole point of the field
|
||||
# (schema 1.9.0): an app gets --api-route only where someone MEASURED that it serves a machine
|
||||
# API, so a newly-onboarded app renders no --api-route at all rather than inheriting another
|
||||
# app's path shape. See OAUTH2_API_ROUTE_PATTERN above for why a default of `^/api/` would be
|
||||
# wrong rather than merely generous.
|
||||
auth.setdefault("apiRoutes", [])
|
||||
s["auth"] = auth
|
||||
|
||||
update = dict(s.get("update") or {})
|
||||
@@ -227,7 +278,15 @@ def apply_defaults(sd: dict) -> dict:
|
||||
# object that actually runs on the cluster is not the one this SD renders (keycloak: the
|
||||
# Keycloak operator runs a StatefulSet, so a Deployment-shaped rule would `absent()` forever).
|
||||
lw = dict(obs.get("livenessWorkload") or {})
|
||||
lw.setdefault("kind", "VirtualMachineInstance" if s["kind"] == "vm" else "Deployment")
|
||||
# The default must track what is ACTUALLY rendered, or the `up` SLI silently dies: a
|
||||
# Deployment-shaped rule against a StatefulSet `absent()`s forever, which is an alert that can
|
||||
# never fire — the exact failure mode the keycloak override was added for. Deriving it from
|
||||
# `availability.replicas` means bumping the replica count cannot leave a stale rule behind.
|
||||
lw.setdefault(
|
||||
"kind",
|
||||
"VirtualMachineInstance" if s["kind"] == "vm"
|
||||
else ("StatefulSet" if _is_statefulset(s) else "Deployment"),
|
||||
)
|
||||
lw.setdefault("name", s["name"])
|
||||
obs["livenessWorkload"] = lw
|
||||
s["observability"] = obs
|
||||
@@ -430,6 +489,37 @@ def r_bulk_pvs(s: dict) -> list:
|
||||
return out
|
||||
|
||||
|
||||
def _replicas(s: dict) -> int:
|
||||
"""Declared replica count (schema 1.10.0 `availability.replicas`). Default 1."""
|
||||
return int((s.get("availability") or {}).get("replicas", 1) or 1)
|
||||
|
||||
|
||||
def _is_statefulset(s: dict) -> bool:
|
||||
"""True when this container workload must render a StatefulSet rather than a Deployment.
|
||||
|
||||
THE DECIDING CONSTRAINT IS THE VOLUME, not a preference. A longhorn storage entry renders
|
||||
ReadWriteOnce (r_pvcs), and one RWO PVC cannot be attached by pods on two different nodes --
|
||||
which is exactly what `availability.replicas > 1` plus the node anti-affinity below asks for.
|
||||
A 2-replica Deployment over a shared RWO claim does not degrade, it simply never schedules the
|
||||
second pod. A StatefulSet's volumeClaimTemplates give each replica its OWN claim, which is the
|
||||
only shape that satisfies both at once.
|
||||
|
||||
A replicas-1 service keeps rendering a Deployment so every pre-existing golden is byte-identical.
|
||||
"""
|
||||
return s["kind"] == "container" and _replicas(s) > 1
|
||||
|
||||
|
||||
def _sts_claim_volumes(s: dict) -> list:
|
||||
"""Storage entries that become per-replica volumeClaimTemplates on a StatefulSet.
|
||||
|
||||
Only the dynamically-provisioned (longhorn) entries. A `bulk` entry is a STATIC, pre-bound NFS
|
||||
PV over data that already exists outside the cluster and is shared read-only by every consumer
|
||||
(r_bulk_pvs) -- per-replica copies of it are neither possible nor wanted, so it stays a
|
||||
standalone PVC and an ordinary pod volume.
|
||||
"""
|
||||
return [v for v in s["storage"] if v.get("class", "longhorn") != "bulk"]
|
||||
|
||||
|
||||
def r_pvcs(s: dict) -> list:
|
||||
"""One PVC per storage entry (R-STATE-01/02/04).
|
||||
|
||||
@@ -437,8 +527,15 @@ def r_pvcs(s: dict) -> list:
|
||||
bulk -> the STATIC external NFS PV rendered by r_bulk_pvs, pre-bound by volumeName.
|
||||
"""
|
||||
out = []
|
||||
# On a StatefulSet the longhorn claims are NOT standalone objects — they are
|
||||
# volumeClaimTemplates, so each replica gets its own (see _is_statefulset). Emitting both would
|
||||
# leave an orphan PVC that nothing mounts and that `prune` would later delete out from under
|
||||
# nobody. `bulk` is unaffected and still rendered here.
|
||||
sts = _is_statefulset(s)
|
||||
for vol in s["storage"]:
|
||||
cls = vol.get("class", "longhorn")
|
||||
if sts and cls != "bulk":
|
||||
continue
|
||||
if cls == "bulk":
|
||||
# Mirror image of the PV: the same explicitly-empty class (so nothing dynamically
|
||||
# provisions), the same access mode, and volumeName completing the two-sided binding.
|
||||
@@ -519,6 +616,12 @@ def r_deployment(s: dict) -> dict:
|
||||
if vol.get("readOnly"):
|
||||
mount["readOnly"] = True
|
||||
volume_mounts.append(mount)
|
||||
# On a StatefulSet a longhorn volume is supplied by its volumeClaimTemplate, whose
|
||||
# metadata.name IS the pod volume name. Adding it to pod.volumes as well is a conflict
|
||||
# (duplicate volume name), so it is skipped here and emitted below. The MOUNT is unchanged —
|
||||
# the container cannot tell the difference, which is what keeps this transparent to the app.
|
||||
if _is_statefulset(s) and vol.get("class", "longhorn") != "bulk":
|
||||
continue
|
||||
volumes.append(
|
||||
{
|
||||
"name": vol["name"],
|
||||
@@ -616,20 +719,80 @@ def r_deployment(s: dict) -> dict:
|
||||
if ann:
|
||||
tmpl_meta["annotations"] = ann
|
||||
|
||||
spec = {"replicas": 1}
|
||||
# An app with persistent storage mounts a ReadWriteOnce Longhorn PVC. The default
|
||||
# RollingUpdate strategy tries to start the NEW pod before terminating the old one, but
|
||||
# the old pod still holds the RWO volume -> the new pod is stuck ContainerCreating and
|
||||
# the rollout deadlocks (single-replica app can never hand the volume over). Recreate
|
||||
# terminates the old pod first, freeing the PVC for the new one (R-STATE-01/02).
|
||||
if s["storage"]:
|
||||
sts = _is_statefulset(s)
|
||||
|
||||
# NODE ANTI-AFFINITY, and it is REQUIRED rather than preferred. `availability.replicas > 1`
|
||||
# exists to survive the loss of one node; `preferred` would let both replicas land on the same
|
||||
# node and quietly deliver none of that, with every object reporting healthy. The cost is real
|
||||
# and is accepted: with N replicas the workload needs N schedulable nodes, so on a 3-node
|
||||
# cluster losing TWO nodes leaves a replica Pending and — because `apps-wave-0` is `wait: true`
|
||||
# with `apps-wave-1` dependsOn it — STALLS THE APP TIER. That coincides with having already lost
|
||||
# etcd quorum on a 3-node control plane, i.e. the cluster is not reconciling anything at that
|
||||
# point anyway, so the stall is a symptom rather than a new failure.
|
||||
if sts:
|
||||
pod_spec["affinity"] = {
|
||||
"podAntiAffinity": {
|
||||
"requiredDuringSchedulingIgnoredDuringExecution": [
|
||||
{
|
||||
"labelSelector": {
|
||||
"matchLabels": {"app.kubernetes.io/name": s["name"]}
|
||||
},
|
||||
"topologyKey": "kubernetes.io/hostname",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
spec = {"replicas": _replicas(s)}
|
||||
if sts:
|
||||
# serviceName is a required StatefulSet field. It points at the service rendered by
|
||||
# r_service; that Service is a normal ClusterIP rather than headless, so per-pod DNS
|
||||
# (<pod>.<svc>) does NOT resolve — and nothing here needs it. These replicas are
|
||||
# interchangeable resolvers behind one VIP, not peers that address each other.
|
||||
spec["serviceName"] = s["name"]
|
||||
# Parallel, not the OrderedReady default. OrderedReady starts replica N+1 only after N is
|
||||
# Ready, so a cold start costs the sum of every replica's first-boot work instead of the
|
||||
# max — and `apps-wave-0` has `timeout: 5m`. There is no ordering requirement between
|
||||
# replicas to protect (no leader, no migration, no shared volume), so serialising buys
|
||||
# nothing and risks the wave timing out on a rebuild.
|
||||
spec["podManagementPolicy"] = "Parallel"
|
||||
elif s["storage"]:
|
||||
# An app with persistent storage mounts a ReadWriteOnce Longhorn PVC. The default
|
||||
# RollingUpdate strategy tries to start the NEW pod before terminating the old one, but
|
||||
# the old pod still holds the RWO volume -> the new pod is stuck ContainerCreating and
|
||||
# the rollout deadlocks (single-replica app can never hand the volume over). Recreate
|
||||
# terminates the old pod first, freeing the PVC for the new one (R-STATE-01/02).
|
||||
#
|
||||
# A StatefulSet needs none of this and cannot express it: each replica owns its own claim,
|
||||
# so a rolling update hands nothing over, and `strategy` is not a StatefulSet field at all.
|
||||
spec["strategy"] = {"type": "Recreate"}
|
||||
spec["selector"] = {"matchLabels": {"app.kubernetes.io/name": s["name"]}}
|
||||
spec["template"] = {"metadata": tmpl_meta, "spec": pod_spec}
|
||||
if sts:
|
||||
# Per-replica claims. metadata.name MUST equal the pod volume name the container mounts
|
||||
# (see the volumes loop above); the resulting PVCs are <name>-<sts>-<ordinal>, e.g.
|
||||
# config-pihole-0. Same tier and access mode a standalone longhorn PVC gets from r_pvcs —
|
||||
# this changes WHO owns the claim, never what it is.
|
||||
tmpls = []
|
||||
for vol in _sts_claim_volumes(s):
|
||||
vspec = {"accessModes": ["ReadWriteOnce"], "storageClassName": "longhorn"}
|
||||
if vol.get("size"):
|
||||
vspec["resources"] = {"requests": {"storage": vol["size"]}}
|
||||
tmpls.append(
|
||||
{
|
||||
"metadata": {
|
||||
"name": vol["name"],
|
||||
"labels": {"app.kubernetes.io/name": s["name"]},
|
||||
},
|
||||
"spec": vspec,
|
||||
}
|
||||
)
|
||||
if tmpls:
|
||||
spec["volumeClaimTemplates"] = tmpls
|
||||
|
||||
return {
|
||||
"apiVersion": "apps/v1",
|
||||
"kind": "Deployment",
|
||||
"kind": "StatefulSet" if sts else "Deployment",
|
||||
"metadata": {
|
||||
"name": s["name"],
|
||||
"namespace": s["name"],
|
||||
@@ -639,6 +802,35 @@ def r_deployment(s: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def r_pdb(s: dict):
|
||||
"""PodDisruptionBudget for a multi-replica workload (availability.replicas > 1).
|
||||
|
||||
minAvailable 1, not `maxUnavailable: 1`. The budget's job here is to stop a VOLUNTARY
|
||||
disruption — a node drain for maintenance, a cluster upgrade — from taking the last replica
|
||||
with it; `minAvailable: 1` says that whatever else happens, one must keep serving. It does
|
||||
NOT protect against involuntary disruption (a node dying), which is what the anti-affinity
|
||||
and the replica count are for.
|
||||
|
||||
Rendered only when replicas > 1: a PDB over a single-replica workload is actively harmful, as
|
||||
`minAvailable: 1` would then block every drain of its node forever.
|
||||
"""
|
||||
if not _is_statefulset(s):
|
||||
return None
|
||||
return {
|
||||
"apiVersion": "policy/v1",
|
||||
"kind": "PodDisruptionBudget",
|
||||
"metadata": {
|
||||
"name": s["name"],
|
||||
"namespace": s["name"],
|
||||
"labels": {"app.kubernetes.io/name": s["name"]},
|
||||
},
|
||||
"spec": {
|
||||
"minAvailable": 1,
|
||||
"selector": {"matchLabels": {"app.kubernetes.io/name": s["name"]}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def r_service(s: dict):
|
||||
ports = _service_ports(s)
|
||||
# A declared metrics endpoint becomes a NAMED Service port, because that name is the only
|
||||
@@ -1075,7 +1267,12 @@ def _oauth2_sidecar_container(s: dict) -> dict:
|
||||
|
||||
--cookie-name and --cookie-domain are DERIVED per app (see the block comment on those two
|
||||
args): every instance holds a different cookie-secret, so sharing one cookie name across the
|
||||
zone makes the instances evict each other's sessions."""
|
||||
zone makes the instances evict each other's sessions.
|
||||
|
||||
--api-route is DECLARED per app, from auth.apiRoutes, and is the one flag here that is
|
||||
neither a constant nor derivable: it names the paths THIS app serves as a machine API, and
|
||||
those differ per app (radarr `/signalr/`, jackett `/api/`, pihole none). An app that declares
|
||||
none renders no --api-route at all."""
|
||||
route = s["ingressRoutes"][0]
|
||||
host = route["host"]
|
||||
upstream_port = route["port"]
|
||||
@@ -1095,6 +1292,27 @@ def _oauth2_sidecar_container(s: dict) -> dict:
|
||||
args += [
|
||||
"--skip-provider-button=true",
|
||||
f"--redirect-url=https://{host}/oauth2/callback",
|
||||
]
|
||||
# ── API ROUTES: 401 instead of 302, ON THIS APP'S OWN PATHS ONLY ───────────────────────
|
||||
# One flag per declared route, in declared order. An app that declares NONE renders NO
|
||||
# --api-route, which is the correct and common case — see OAUTH2_API_ROUTE_PATTERN above for
|
||||
# the measurements, for why this cannot be a renderer constant, and for the breadth ratchet.
|
||||
# Validated here as well as in the schema so a breadth mistake is caught by render-golden
|
||||
# even on a run where the schema stage did not execute.
|
||||
for route in s["auth"].get("apiRoutes", []):
|
||||
if not OAUTH2_API_ROUTE_PATTERN.match(str(route)):
|
||||
raise SystemExit(
|
||||
f"render.py: {s['name']}: auth.apiRoutes entry {route!r} is not an anchored path "
|
||||
f"regex. Each entry must start with '^/' and continue with literal path "
|
||||
f"characters (e.g. '^/api/', '^/signalr/', '^/api/v3/.*'). Rejected shapes: '^/' "
|
||||
f"and '^/.*' (match every page load), an unanchored '/api/' (matches mid-path), "
|
||||
f"and alternation (the flag is repeatable — declare one entry per route). A "
|
||||
f"too-broad --api-route answers a PAGE LOAD with 401, so a logged-out operator "
|
||||
f"sees a bare error page and is never offered a sign-in: the service looks "
|
||||
f"permanently broken rather than merely unauthenticated."
|
||||
)
|
||||
args.append(f"--api-route={route}")
|
||||
args += [
|
||||
# ── COOKIE ISOLATION (fleet fix 2026-09-30; crafty's 2026-09-29 fix, generalized) ──────
|
||||
# EVERY oauth2-proxy on this platform — this sidecar, its four siblings, and the SHARED
|
||||
# forward-auth proxy (platform/oauth2-proxy) — is an INDEPENDENT instance holding its OWN
|
||||
@@ -1150,8 +1368,9 @@ def _oauth2_sidecar_container(s: dict) -> dict:
|
||||
f"--cookie-refresh={OAUTH2_COOKIE_REFRESH}",
|
||||
"--pass-access-token=true",
|
||||
"--set-xauthrequest=true",
|
||||
# Start the /metrics listener. Prerequisite only — not yet reachable by Prometheus;
|
||||
# see OAUTH2_PROXY_METRICS_PORT above for exactly what is still missing.
|
||||
# Start the /metrics listener. Scraped as of 2026-10-05, but only because
|
||||
# r_monitoring_ingress_policy also opens this port to ns monitoring — the flag alone was
|
||||
# inert for 63.7h. See OAUTH2_PROXY_METRICS_PORT above for the three pieces required.
|
||||
f"--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}",
|
||||
]
|
||||
secret = f"{s['name']}-oauth2-proxy"
|
||||
@@ -1329,12 +1548,54 @@ def r_monitoring_ingress_policy(s: dict):
|
||||
trap Longhorn's own NetworkPolicies set (platform/longhorn/networkpolicy-metrics.yaml exists
|
||||
for exactly this reason), and it is a THIRD independent way for a metrics SLI to be inert.
|
||||
|
||||
Rendered only alongside a declared metricsEndpoint, and it opens ONLY that port to ONLY the
|
||||
monitoring namespace: an allow for a port nothing serves would be a standing hole for no
|
||||
Rendered only alongside a port something actually serves, and it opens ONLY that port to ONLY
|
||||
the monitoring namespace: an allow for a port nothing serves would be a standing hole for no
|
||||
benefit.
|
||||
|
||||
TWO INDEPENDENT SOURCES OF A SERVED METRICS PORT, and this used to cover only the first:
|
||||
|
||||
1. `observability.metricsEndpoint` — the APP's own exporter. NOTE, measured 2026-10-04: no
|
||||
ServiceDefinition in this catalog declares one, so before the sidecar clause below this
|
||||
function had NEVER emitted a single object on any branch. It is correct but, so far,
|
||||
entirely latent — do not read its presence as evidence that scrape ingress is solved.
|
||||
|
||||
2. THE forward-auth GATE SIDECAR's own `/metrics` on 44180 (added 2026-10-04). Every
|
||||
`_needs_oauth2_sidecar` app gets `--metrics-address=0.0.0.0:{OAUTH2_PROXY_METRICS_PORT}`
|
||||
and a named `metrics` container port from `_oauth2_sidecar_container`, so the port is
|
||||
served whenever the sidecar exists — unconditionally, which is what makes it safe to
|
||||
open without a per-app declaration.
|
||||
|
||||
WHY CLAUSE 2 KEYS ON `_needs_oauth2_sidecar` AND NOT ON `observability.metrics`: it must match
|
||||
the condition the SCRAPE keys on, exactly. platform/oauth2-proxy/podmonitor.yaml selects pods
|
||||
by the `homelab.keithjackson.dev/auth-mode: forward-auth` POD LABEL and nothing else, so a
|
||||
target exists iff the sidecar exists. Keying the allowance on `observability.metrics` instead
|
||||
would let `metrics: false` produce a discovered-but-blocked target — the same "two halves
|
||||
authored apart" failure this function exists to prevent, just moved one field over.
|
||||
|
||||
THIS CLAUSE IS A BUG FIX, and the bug was live for 63.7h. Measured on prod 2026-10-04: all 6
|
||||
sidecar targets across jackett/radarr/sonarr/qbittorrent/pihole read `health=down`,
|
||||
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly 10.0s — the scrapeTimeout,
|
||||
i.e. packets DROPPED, not refused. Each app namespace ships a `default-deny` NetworkPolicy
|
||||
with `policyTypes: [Ingress, Egress]` and NO `ingress` key at all (deny-all-ingress), and the
|
||||
only ingress allowance is `<app>-allow-gateway-ingress`: `fromEntities: [ingress]` -> 4180.
|
||||
Prometheus matched no clause. 5 x `TargetDown` + 6 x `OAuth2ProxyGateUnobserved` had been
|
||||
re-notifying at ~22 emails/day since the PodMonitor landed on 2026-10-02.
|
||||
|
||||
WHY THE POLICY BELONGS HERE AND NOT IN platform/oauth2-proxy: podmonitor.yaml's header records
|
||||
the objection — a policy in that tier would name app namespaces created by the SIBLING
|
||||
apps-wave-0/1 tiers (measured Flux graph: `oauth2-proxy` and `apps-wave-0` both
|
||||
`dependsOn=[platform-config]`, no ordering between them), so it would apply on a running
|
||||
cluster and fail on a cold bootstrap. Rendered here it ships in the same tier that creates the
|
||||
namespace, which dissolves that ordering problem entirely.
|
||||
"""
|
||||
ep = s["observability"].get("metricsEndpoint")
|
||||
if not ep or not s["observability"]["metrics"]:
|
||||
obs = s["observability"]
|
||||
ep = obs.get("metricsEndpoint")
|
||||
ports: list[str] = []
|
||||
if ep and obs["metrics"]:
|
||||
ports.append(str(ep["port"]))
|
||||
if _needs_oauth2_sidecar(s):
|
||||
ports.append(str(OAUTH2_PROXY_METRICS_PORT))
|
||||
if not ports:
|
||||
return None
|
||||
return {
|
||||
"apiVersion": "cilium.io/v2",
|
||||
@@ -1352,7 +1613,7 @@ def r_monitoring_ingress_policy(s: dict):
|
||||
{"matchLabels": {"k8s:io.kubernetes.pod.namespace": MONITORING_NS}}
|
||||
],
|
||||
"toPorts": [
|
||||
{"ports": [{"port": str(ep["port"]), "protocol": "TCP"}]}
|
||||
{"ports": [{"port": p, "protocol": "TCP"} for p in ports]}
|
||||
],
|
||||
}
|
||||
],
|
||||
@@ -1746,7 +2007,13 @@ def render(sd: dict, image_automation: bool = True, inbound: list | None = None)
|
||||
)
|
||||
for pvc in r_pvcs(s):
|
||||
docs.append((f"PVC {pvc['metadata']['name']} (R-STATE)", pvc))
|
||||
docs.append(("Deployment (R-PLAT-02)", r_deployment(s)))
|
||||
wl = r_deployment(s)
|
||||
docs.append((f"{wl['kind']} (R-PLAT-02)", wl))
|
||||
pdb = r_pdb(s)
|
||||
if pdb:
|
||||
docs.append(
|
||||
("PodDisruptionBudget — keep one replica through a drain (availability.replicas)", pdb)
|
||||
)
|
||||
svc = r_service(s)
|
||||
if svc:
|
||||
docs.append(("Service", svc))
|
||||
@@ -1931,6 +2198,24 @@ EXTRA_UNITS: dict = {
|
||||
"pihole-dns.yaml",
|
||||
],
|
||||
},
|
||||
# Plex's remote-access exposure: the raw tcp/32400 LoadBalancer Service on a pinned VIP, plus the
|
||||
# CiliumNetworkPolicy that admits off-cluster clients to it (apps/wave-0/plex-remote.yaml).
|
||||
# `above: False` co-locates it in plex's OWN wave — these are the plex pod's own Service and its
|
||||
# own policy, applied in the same wave as the Deployment they select, exactly as the rendered
|
||||
# Service and CNPs in plex.yaml are. Nothing in a higher wave gates on it.
|
||||
#
|
||||
# It is a non-catalog unit for the same reason pihole-dns is: the ServiceDefinition schema cannot
|
||||
# express a LoadBalancer Service or a pinned lbipam VIP (additionalProperties is false, and
|
||||
# `ingressRoutes` only renders HTTPRoutes on the L7 Gateway, which cannot carry Plex's raw port).
|
||||
# Plex's own settings stay in the catalog as env on catalog/plex.yaml; the only thing living
|
||||
# outside it is the exposure the schema has no word for.
|
||||
"plex-remote": {
|
||||
"anchor": "plex",
|
||||
"above": False,
|
||||
"files": [
|
||||
"plex-remote.yaml",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -2177,6 +2462,132 @@ def _selftest() -> int:
|
||||
check("Namespace" in kinds, "container: missing Namespace")
|
||||
check("NetworkPolicy" in kinds, "container: missing default-deny NetworkPolicy")
|
||||
check("Deployment" in kinds, "container: missing Deployment")
|
||||
check(
|
||||
"PodDisruptionBudget" not in kinds,
|
||||
"replicas-1 container must render NO PodDisruptionBudget (minAvailable 1 over a single "
|
||||
"replica blocks every drain of its node forever)",
|
||||
)
|
||||
|
||||
# 1b) availability.replicas (schema 1.10.0). The DEFAULT path must be untouched, and the >1
|
||||
# path must change workload kind, claim ownership, spreading, budget AND the SLI metric
|
||||
# together — each of these has a silent-failure mode if it is the one that gets forgotten.
|
||||
base_av = {
|
||||
"name": "av",
|
||||
"source": {"image": "nginx:1"},
|
||||
"storage": [{"name": "config", "size": "1Gi"}],
|
||||
"observability": {"metrics": True, "slis": ["up"]},
|
||||
}
|
||||
one = render({**base_av, "availability": {"replicas": 1}})
|
||||
one_kinds = [d[1]["kind"] for d in one]
|
||||
check(
|
||||
"Deployment" in one_kinds and "StatefulSet" not in one_kinds,
|
||||
"availability.replicas=1 must still render a Deployment (back-compat)",
|
||||
)
|
||||
check(
|
||||
"PersistentVolumeClaim" in one_kinds,
|
||||
"availability.replicas=1 must still render a standalone PVC",
|
||||
)
|
||||
check(
|
||||
"PodDisruptionBudget" not in one_kinds,
|
||||
"availability.replicas=1 must render no PodDisruptionBudget",
|
||||
)
|
||||
|
||||
many = render({**base_av, "availability": {"replicas": 2}})
|
||||
many_kinds = [d[1]["kind"] for d in many]
|
||||
check(
|
||||
"StatefulSet" in many_kinds and "Deployment" not in many_kinds,
|
||||
"availability.replicas>1 must render a StatefulSet, not a Deployment (a shared RWO claim "
|
||||
"cannot be attached on two nodes, so a multi-replica Deployment never schedules)",
|
||||
)
|
||||
check(
|
||||
"PersistentVolumeClaim" not in many_kinds,
|
||||
"availability.replicas>1 must NOT also render a standalone longhorn PVC — it would be an "
|
||||
"orphan that nothing mounts",
|
||||
)
|
||||
sts = next(d[1] for d in many if d[1]["kind"] == "StatefulSet")
|
||||
check(sts["spec"].get("replicas") == 2, "StatefulSet must carry the declared replica count")
|
||||
check(
|
||||
"strategy" not in sts["spec"],
|
||||
"StatefulSet must not carry `strategy` (not a StatefulSet field; Recreate is a "
|
||||
"Deployment-only workaround for handing over one RWO volume)",
|
||||
)
|
||||
check(
|
||||
sts["spec"].get("podManagementPolicy") == "Parallel",
|
||||
"StatefulSet must set podManagementPolicy=Parallel — OrderedReady serialises first-boot "
|
||||
"work across replicas and apps-wave-0 has a 5m timeout",
|
||||
)
|
||||
check(sts["spec"].get("serviceName") == "av", "StatefulSet must set serviceName")
|
||||
tmpls = sts["spec"].get("volumeClaimTemplates") or []
|
||||
check(
|
||||
[t["metadata"]["name"] for t in tmpls] == ["config"],
|
||||
"StatefulSet volumeClaimTemplate name must equal the pod volume name the container mounts, "
|
||||
"or the pod gets a duplicate/unresolvable volume",
|
||||
)
|
||||
check(
|
||||
tmpls and tmpls[0]["spec"]["accessModes"] == ["ReadWriteOnce"]
|
||||
and tmpls[0]["spec"]["storageClassName"] == "longhorn",
|
||||
"StatefulSet claim template must keep the same tier/access mode as the standalone PVC",
|
||||
)
|
||||
sts_vols = {v["name"] for v in sts["spec"]["template"]["spec"].get("volumes", [])}
|
||||
check(
|
||||
"config" not in sts_vols,
|
||||
"a claim-template volume must NOT also appear in pod.volumes (duplicate volume name)",
|
||||
)
|
||||
mounts = sts["spec"]["template"]["spec"]["containers"][0].get("volumeMounts") or []
|
||||
check(
|
||||
any(m["name"] == "config" for m in mounts),
|
||||
"the container must still mount the volume by name on a StatefulSet (transparent to the app)",
|
||||
)
|
||||
aff = (sts["spec"]["template"]["spec"].get("affinity") or {}).get("podAntiAffinity") or {}
|
||||
req = aff.get("requiredDuringSchedulingIgnoredDuringExecution") or []
|
||||
check(
|
||||
req and req[0].get("topologyKey") == "kubernetes.io/hostname",
|
||||
"availability.replicas>1 must render REQUIRED per-node anti-affinity — `preferred` can put "
|
||||
"every replica on one node and deliver none of the intended node-failure tolerance",
|
||||
)
|
||||
pdbs = [d[1] for d in many if d[1]["kind"] == "PodDisruptionBudget"]
|
||||
check(len(pdbs) == 1, "availability.replicas>1 must render exactly one PodDisruptionBudget")
|
||||
check(
|
||||
pdbs and pdbs[0]["spec"].get("minAvailable") == 1,
|
||||
"PodDisruptionBudget must be minAvailable=1 (keep one replica serving through a drain)",
|
||||
)
|
||||
pr_many = next((d[1] for d in many if d[1]["kind"] == "PrometheusRule"), None)
|
||||
check(
|
||||
pr_many is not None
|
||||
and "kube_statefulset_status_replicas_ready"
|
||||
in pr_many["spec"]["groups"][0]["rules"][0]["expr"],
|
||||
"the up SLI must follow the rendered workload kind — a Deployment-shaped rule against a "
|
||||
"StatefulSet absent()s forever and can never fire",
|
||||
)
|
||||
|
||||
# A bulk volume is shared external NFS: it must stay a standalone PVC even on a StatefulSet.
|
||||
mixed = render(
|
||||
{
|
||||
**base_av,
|
||||
"availability": {"replicas": 2},
|
||||
"storage": [
|
||||
{"name": "config", "size": "1Gi"},
|
||||
{"name": "media", "class": "bulk", "export": "/mnt/x", "size": "1Ti"},
|
||||
],
|
||||
}
|
||||
)
|
||||
m_sts = next(d[1] for d in mixed if d[1]["kind"] == "StatefulSet")
|
||||
check(
|
||||
[t["metadata"]["name"] for t in m_sts["spec"]["volumeClaimTemplates"]] == ["config"],
|
||||
"only longhorn volumes become claim templates; a bulk share must not be per-replica",
|
||||
)
|
||||
check(
|
||||
any(
|
||||
d[1]["kind"] == "PersistentVolumeClaim"
|
||||
and d[1]["metadata"]["name"] == "av-media"
|
||||
for d in mixed
|
||||
),
|
||||
"a bulk PVC must still be rendered standalone on a StatefulSet",
|
||||
)
|
||||
check(
|
||||
"media" in {v["name"] for v in m_sts["spec"]["template"]["spec"].get("volumes", [])},
|
||||
"a bulk volume must still appear in pod.volumes on a StatefulSet",
|
||||
)
|
||||
|
||||
ns = next(d[1] for d in docs if d[1]["kind"] == "Namespace")
|
||||
check(
|
||||
@@ -2732,15 +3143,27 @@ def _selftest() -> int:
|
||||
"auth": {"mode": "forward-auth", "groups": ["media-users"]},
|
||||
}
|
||||
)
|
||||
fa_dep = next(d[1] for d in fa_docs if d[1]["kind"] == "Deployment")
|
||||
fa_containers = fa_dep["spec"]["template"]["spec"]["containers"]
|
||||
fa_dep = next((d[1] for d in fa_docs if d[1]["kind"] == "Deployment"), None)
|
||||
check(fa_dep is not None,
|
||||
"a forward-auth app rendered NO Deployment, so there is no pod to gate at all")
|
||||
fa_containers = (((fa_dep or {}).get("spec") or {}).get("template") or {}) \
|
||||
.get("spec", {}).get("containers") or []
|
||||
check(
|
||||
len(fa_containers) == 2
|
||||
and fa_containers[0]["name"] == "fa"
|
||||
and fa_containers[1]["name"] == "oauth2-proxy",
|
||||
"forward-auth app must render the app container FIRST + an oauth2-proxy sidecar SECOND",
|
||||
f"forward-auth app must render the app container FIRST + an oauth2-proxy sidecar SECOND; "
|
||||
f"rendered {[c.get('name') for c in fa_containers]!r}",
|
||||
)
|
||||
side = fa_containers[1]
|
||||
# EVERY assertion below reads `side`, so an absent sidecar must NOT be allowed to raise here.
|
||||
# The whole point of the None-tolerant lookups in this block is that a missing sidecar (or a
|
||||
# missing flag on it) reports as NAMED ASSERTIONS rather than as a traceback: an exception
|
||||
# escaping _selftest() exits non-zero with the right verdict and ZERO diagnostics, and it
|
||||
# hides every other assertion the same mutation broke. Measured by Desmond Oyelaran while
|
||||
# wiring this selftest into the render-golden stage: deleting the --cookie-name emission gave
|
||||
# rc 1 via an uncaught StopIteration and printed not one `SELFTEST FAIL:` line. Provoked
|
||||
# again here with the sidecar removed entirely, which raised IndexError at this line.
|
||||
side = fa_containers[1] if len(fa_containers) > 1 else {"args": [], "env": [], "ports": []}
|
||||
check(
|
||||
"--upstream=http://127.0.0.1:9117" in side["args"],
|
||||
"sidecar upstream must be the co-located app UI over http on 127.0.0.1:<uiport>",
|
||||
@@ -2809,12 +3232,101 @@ def _selftest() -> int:
|
||||
"sidecar must declare BOTH the http and metrics container ports — an undeclared port "
|
||||
"cannot be named by a Service port or a PodMonitor, so the flag alone is inert",
|
||||
)
|
||||
# API ROUTES (schema 1.9.0). Three properties, and the FIRST is the one that keeps the field
|
||||
# honest: an app that declares nothing must render NO --api-route. `fa` above declares none.
|
||||
check(
|
||||
not any(a.startswith("--api-route") for a in side["args"]),
|
||||
"a forward-auth app that declares no auth.apiRoutes must render NO --api-route. A "
|
||||
"default or a renderer constant would answer PiHole's page loads with 401 — PiHole "
|
||||
"serves no /api/ at all — so the service would look permanently broken to a "
|
||||
"logged-out operator instead of signing them in",
|
||||
)
|
||||
# Same `next(..., None)` discipline as _cookie_name() below, for the same reason: if the
|
||||
# sidecar stops being rendered at all, this must report as a named assertion rather than as a
|
||||
# StopIteration that swallows every other SELFTEST FAIL line in the run.
|
||||
_faapi_dep = next(
|
||||
(
|
||||
d[1]
|
||||
for d in render(
|
||||
{
|
||||
"name": "faapi",
|
||||
"source": {"image": "a:1"},
|
||||
"ingressRoutes": [{"host": "faapi.home.keithjackson.dev", "port": 9119}],
|
||||
"auth": {
|
||||
"mode": "forward-auth",
|
||||
"groups": ["media-users"],
|
||||
"apiRoutes": ["^/api/", "^/signalr/"],
|
||||
},
|
||||
}
|
||||
)
|
||||
if d[1]["kind"] == "Deployment"
|
||||
),
|
||||
None,
|
||||
)
|
||||
fa_api_side = next(
|
||||
(
|
||||
c
|
||||
for c in ((_faapi_dep or {}).get("spec", {}).get("template", {})
|
||||
.get("spec", {}).get("containers") or [])
|
||||
if c.get("name") == "oauth2-proxy"
|
||||
),
|
||||
None,
|
||||
)
|
||||
check(
|
||||
fa_api_side is not None,
|
||||
"a forward-auth app declaring auth.apiRoutes rendered NO oauth2-proxy sidecar, so the "
|
||||
"routes have nowhere to land and the app is not gated at all (R-IDP-02)",
|
||||
)
|
||||
check(
|
||||
fa_api_side is not None
|
||||
and [a for a in fa_api_side["args"] if a.startswith("--api-route")]
|
||||
== ["--api-route=^/api/", "--api-route=^/signalr/"],
|
||||
"a declared auth.apiRoutes must render as one repeatable --api-route per entry, in "
|
||||
"declared order, and NOTHING else. Note the flag is SINGULAR: --api-routes does not "
|
||||
"exist in v7.8.1 and the proxy refuses to start with it",
|
||||
)
|
||||
# BREADTH. The renderer must REFUSE a regex that would match a page load, not merely
|
||||
# discourage one, because the schema stage can be skipped and render-golden cannot.
|
||||
for _bad in ("^/", "^/.*", ".*", "/api/", "^/api/|^/"):
|
||||
try:
|
||||
render(
|
||||
{
|
||||
"name": "fabad",
|
||||
"source": {"image": "a:1"},
|
||||
"ingressRoutes": [{"host": "fabad.home.keithjackson.dev", "port": 9120}],
|
||||
"auth": {
|
||||
"mode": "forward-auth",
|
||||
"groups": ["media-users"],
|
||||
"apiRoutes": [_bad],
|
||||
},
|
||||
}
|
||||
)
|
||||
_refused = False
|
||||
except SystemExit:
|
||||
_refused = True
|
||||
check(
|
||||
_refused,
|
||||
f"the renderer ACCEPTED auth.apiRoutes entry {_bad!r}. A too-broad --api-route "
|
||||
f"answers a plain page load with 401, so a logged-out operator sees a bare error "
|
||||
f"page and is never offered a sign-in — the service looks permanently broken "
|
||||
f"rather than merely unauthenticated, which is a worse failure than the 302 bug "
|
||||
f"this flag exists to fix",
|
||||
)
|
||||
for _good in ("^/api/", "^/signalr/", "^/api/v3/.*", "^/api/v2.0/server/update$"):
|
||||
check(
|
||||
bool(OAUTH2_API_ROUTE_PATTERN.match(_good)),
|
||||
f"the breadth ratchet rejected the legitimate route {_good!r}. A ratchet that "
|
||||
f"blocks real API prefixes is a ratchet someone eventually deletes",
|
||||
)
|
||||
|
||||
# Two forward-auth apps must not be able to collide. Renders a SECOND one and compares, so a
|
||||
# future refactor to a constant (or to a per-service table with a copy-paste slip) fails here
|
||||
# rather than silently in a browser six weeks later.
|
||||
fa2_side = next(
|
||||
c
|
||||
for c in next(
|
||||
# Same None-tolerant shape as the `faapi` fixture above and for the same reason: a missing
|
||||
# sidecar must surface as a named assertion, never as a traceback that suppresses the
|
||||
# collected SELFTEST FAIL lines.
|
||||
_fa2_dep = next(
|
||||
(
|
||||
d[1]
|
||||
for d in render(
|
||||
{
|
||||
@@ -2825,24 +3337,55 @@ def _selftest() -> int:
|
||||
}
|
||||
)
|
||||
if d[1]["kind"] == "Deployment"
|
||||
)["spec"]["template"]["spec"]["containers"]
|
||||
if c["name"] == "oauth2-proxy"
|
||||
),
|
||||
None,
|
||||
)
|
||||
fa2_side = next(
|
||||
(
|
||||
c
|
||||
for c in ((_fa2_dep or {}).get("spec", {}).get("template", {})
|
||||
.get("spec", {}).get("containers") or [])
|
||||
if c.get("name") == "oauth2-proxy"
|
||||
),
|
||||
{"args": []},
|
||||
)
|
||||
|
||||
# `next(..., None)` — the DEFAULT IS LOAD-BEARING, not defensive style. With a bare
|
||||
# `next(genexpr)` this raises StopIteration the moment a sidecar renders no --cookie-name,
|
||||
# which is precisely the mutation this assertion exists to catch. The traceback then escapes
|
||||
# _selftest() before its collected `SELFTEST FAIL:` lines are printed, so the run exits
|
||||
# non-zero with the RIGHT VERDICT AND NO DIAGNOSTICS — and it masks every other assertion the
|
||||
# same mutation broke. Measured by Desmond Oyelaran while wiring the selftest into the
|
||||
# render-golden stage: deleting the --cookie-name emission gave rc 1 via an uncaught
|
||||
# StopIteration and suppressed all of the messages. A missing flag must report AS AN
|
||||
# ASSERTION, with its name in the output, which is what the None + explicit check below do.
|
||||
def _cookie_name(container):
|
||||
return next(a for a in container["args"] if a.startswith("--cookie-name="))
|
||||
return next((a for a in container["args"] if a.startswith("--cookie-name=")), None)
|
||||
|
||||
_fa_cn, _fa2_cn = _cookie_name(side), _cookie_name(fa2_side)
|
||||
check(
|
||||
_cookie_name(side) != _cookie_name(fa2_side),
|
||||
_fa_cn is not None and _fa2_cn is not None,
|
||||
f"a forward-auth sidecar rendered NO --cookie-name at all (fa={_fa_cn!r}, "
|
||||
f"fa2={_fa2_cn!r}). Omitting the flag is not neutral: oauth2-proxy then claims its "
|
||||
f"built-in default `_oauth2_proxy`, which every other instance on the platform also "
|
||||
f"claims, so they overwrite and ClearSession-delete each other's sessions",
|
||||
)
|
||||
check(
|
||||
_fa_cn != _fa2_cn,
|
||||
"two forward-auth apps rendered IDENTICAL cookie names — they would overwrite each "
|
||||
"other's session on every sign-in (the 2026-09-29 crafty defect, fleet-wide)",
|
||||
)
|
||||
# Index-safe for the same reason as `side` above: an absent sidecar has no env, and an
|
||||
# IndexError here would exit non-zero with no diagnostics at all.
|
||||
_fa_env = (side.get("env") or [{}])[0]
|
||||
check(
|
||||
side["env"][0]["valueFrom"]["secretKeyRef"]["name"] == "fa-oauth2-proxy",
|
||||
(((_fa_env.get("valueFrom") or {}).get("secretKeyRef") or {}).get("name")
|
||||
== "fa-oauth2-proxy"),
|
||||
"sidecar creds must come by name from the per-app <app>-oauth2-proxy Secret (R-IDP-04)",
|
||||
)
|
||||
fa_svc = next(d[1] for d in fa_docs if d[1]["kind"] == "Service")
|
||||
fa_svc_ports = {p["port"] for p in fa_svc["spec"]["ports"]}
|
||||
fa_svc = next((d[1] for d in fa_docs if d[1]["kind"] == "Service"), None)
|
||||
check(fa_svc is not None, "a forward-auth app must render a Service for the Gateway to reach")
|
||||
fa_svc_ports = {p["port"] for p in (((fa_svc or {}).get("spec") or {}).get("ports") or [])}
|
||||
check(
|
||||
fa_svc_ports == {9117, 4180},
|
||||
"forward-auth Service must expose the app port (east-west) AND :4180 (gateway->sidecar)",
|
||||
|
||||
@@ -100,6 +100,9 @@ dependencies:
|
||||
- to: jackett # arr → indexer proxy (docs/13 dependency graph; R-NET-02 explicit allow)
|
||||
port: 9117
|
||||
protocol: tcp
|
||||
- to: plex # arr -> plex: required since library is on NFS which does not provide file CRUD notifications
|
||||
port: 32400
|
||||
protocol: tcp
|
||||
ingressRoutes:
|
||||
- host: sonarr.${BASE_DOMAIN}
|
||||
port: 8989 # Sonarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
||||
@@ -112,6 +115,14 @@ auth:
|
||||
# via k8s-oidc-rbac.yaml). Without it, an empty media-users locks every operator out with an
|
||||
# immediate 403 at the oauth2-proxy callback — observed on prod 2026-09-12.
|
||||
- platform-admins # operators of these services must not be locked out of them
|
||||
# NO `apiRoutes` HERE, AND NOT BECAUSE IT NEEDS NONE — because nothing measures that it does.
|
||||
# Sonarr is nearly the same codebase as Radarr, which is exactly what makes `^/api/` +
|
||||
# `^/signalr/` a tempting guess and still a guess. MEASURED 2026-10-01 on the live pod
|
||||
# (sonarr-ddf485775-8ckwt, 0 restarts, 23h): its oauth2-proxy log contains 13,643 lines and
|
||||
# ZERO unauthenticated requests — no /api/, no /signalr/, nothing but health probes. Nobody
|
||||
# has opened this UI since the cookie fix rolled, so the measurement is empty rather than
|
||||
# negative: it is evidence of no TRAFFIC, not evidence of no need. Add the routes when someone
|
||||
# reproduces the 302-on-XHR here, with the numbers.
|
||||
update:
|
||||
channel: stable # R-UPD-01
|
||||
range: ">=4.0.0 <5.0.0" # stay on the Sonarr v4 stable line
|
||||
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/usr/bin/env python3
|
||||
"""ci/approval-override.py — select the admin-override on a PR, if there is a valid one.
|
||||
|
||||
This script decides NOTHING about authority. It parses a PR's comments and reviews and returns
|
||||
the single override *candidate* that ci/approval-policy.sh should then try to authorise against
|
||||
the live Gitea permission API. Keeping the two apart is the point:
|
||||
|
||||
* parsing is pure, offline, and therefore TESTABLE (ci/tests/approval-override.test.sh);
|
||||
* authorisation needs the API and must stay in the stage that fails closed.
|
||||
|
||||
A candidate is a comment OR a review whose body contains, AT THE START OF A LINE, the directive
|
||||
followed by a non-empty reason:
|
||||
|
||||
/approve-override the gate cannot gate itself and I am the only human reviewer
|
||||
|
||||
TWO SOURCES, AND WHY THAT IS NOT BELT-AND-BRACES. Measured 2026-10-02 on PR #38: the override was
|
||||
posted as an ordinary PR comment, was well-formed, was newer than the head commit — and the stage
|
||||
still blocked, because CI could not READ it. `SETTINGS_API_TOKEN` is minted with scopes
|
||||
`[read:repository, read:admin]`, and Gitea puts /issues/{n}/comments behind the *issue* scope, so
|
||||
the request came back as an error OBJECT rather than an array. `/pulls/{n}/reviews` is under
|
||||
read:repository and was readable in the same run. So:
|
||||
|
||||
* a REVIEW body works with the token CI has today (leave a review of type "Comment");
|
||||
* a COMMENT body works once the token carries read:issue (see infra/gitea/set-ci-token.sh).
|
||||
|
||||
Accepting both means the hatch is not hostage to one API scope. A source that cannot be read
|
||||
contributes no candidates and is reported on stderr; it can only cause a false BLOCK, never a
|
||||
false pass, which is why degrading it is safe and blocking on it was not necessary.
|
||||
|
||||
WHY EACH RULE IS HERE
|
||||
|
||||
* START OF A LINE, not "anywhere in the body". Otherwise quoting someone else's override
|
||||
("you could just /approve-override this") re-arms it. Lines beginning with '>' are DROPPED
|
||||
rather than unquoted, for exactly that reason.
|
||||
* A REASON IS MANDATORY. A bare directive records that a review was waived but not why, which
|
||||
is the difference between an audit trail and a rubber stamp.
|
||||
* STALE OVERRIDES ARE DISCARDED. An override earned at commit A must not silently cover commit
|
||||
B. Any candidate created at or before the head commit's timestamp is dropped — the same
|
||||
semantics Gitea's own `dismiss_stale_approvals` applies to approvals. Push new commits and
|
||||
you must re-override.
|
||||
* A PENDING REVIEW IS NOT SUBMITTED. It is a private draft visible only to its author, so it is
|
||||
not an act of anything and is dropped.
|
||||
* THE LATEST CANDIDATE WINS, across both sources, so a later override supersedes an earlier one.
|
||||
|
||||
Output: one TSV line (login, timestamp, source, reason) on stdout, or nothing at all when there is
|
||||
no valid candidate. Never exits non-zero for "no override" — absence is a normal answer and the
|
||||
caller must not be able to confuse it with a parse crash.
|
||||
|
||||
Input, all via env so nothing lands in `ps`:
|
||||
COMMENTS_JSON the Gitea /issues/{n}/comments array (or an error object; or absent)
|
||||
REVIEWS_JSON the Gitea /pulls/{n}/reviews array (or an error object; or absent)
|
||||
HEAD_TIME ISO-8601 timestamp of the PR head commit
|
||||
DIRECTIVE the override directive, e.g. /approve-override
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
def parse_ts(value):
|
||||
"""Gitea emits RFC3339. Return an aware datetime, or None if it is unparseable.
|
||||
|
||||
Unparseable is NOT 'treat as fresh': the caller drops such a candidate, because a
|
||||
timestamp we cannot read is a staleness check we cannot perform.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
# Python < 3.11 cannot read a trailing 'Z' in fromisoformat.
|
||||
if text.endswith(("Z", "z")):
|
||||
text = text[:-1] + "+00:00"
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def extract_reason(body, directive):
|
||||
"""Return the reason from the first line that STARTS with the directive, else None."""
|
||||
pattern = re.compile(
|
||||
r"^[ \t]*" + re.escape(directive) + r"(?=[ \t:]|$)[ \t:]*(.*)$"
|
||||
)
|
||||
for raw in (body or "").splitlines():
|
||||
# A quoted line is somebody repeating an override, not casting one.
|
||||
if raw.lstrip().startswith(">"):
|
||||
continue
|
||||
match = pattern.match(raw)
|
||||
if match:
|
||||
reason = match.group(1).strip()
|
||||
return reason or None
|
||||
return None
|
||||
|
||||
|
||||
def load_source(env_name, label):
|
||||
"""Return a list of items for one source, or [] with a stderr note if it is unusable."""
|
||||
raw = os.environ.get(env_name)
|
||||
if not raw or not raw.strip():
|
||||
return []
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (ValueError, TypeError):
|
||||
print(
|
||||
"approval-override: %s is not valid JSON — ignoring that source" % label,
|
||||
file=sys.stderr,
|
||||
)
|
||||
return []
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
# An error object, typically {"message": "token does not have ... scope"}. Say which,
|
||||
# because "no override found" and "could not read the overrides" look identical from
|
||||
# the outside and the first wasted a real debugging session.
|
||||
detail = ""
|
||||
if isinstance(parsed, dict):
|
||||
detail = str(parsed.get("message") or parsed.get("errors") or "")[:200]
|
||||
print(
|
||||
"approval-override: %s is not a list (Gitea returned %s%s) — ignoring that source"
|
||||
% (label, type(parsed).__name__, ": " + detail if detail else ""),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def candidates_from(items, source, directive, head_time):
|
||||
"""Yield (when, login, reason, raw_when, source) for every valid override in one source."""
|
||||
found = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
login = ((item.get("user") or {}).get("login") or "").strip()
|
||||
reason = extract_reason(item.get("body"), directive)
|
||||
if not login or not reason:
|
||||
continue
|
||||
# A PENDING review is an unsubmitted private draft — not an act of anything.
|
||||
state = str(item.get("state") or "").upper()
|
||||
if source == "review" and state == "PENDING":
|
||||
print(
|
||||
"approval-override: dropping PENDING (unsubmitted) review by '%s'" % login,
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
raw_when = (
|
||||
item.get("submitted_at") or item.get("created_at") or item.get("updated_at") or ""
|
||||
)
|
||||
when = parse_ts(raw_when)
|
||||
if when is None:
|
||||
print(
|
||||
"approval-override: dropping %s override by '%s' — unreadable timestamp %r"
|
||||
% (source, login, raw_when),
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
# An override must post-date the commit it covers.
|
||||
if head_time is not None and when <= head_time:
|
||||
print(
|
||||
"approval-override: dropping STALE %s override by '%s' (%s) — not newer than "
|
||||
"head commit (%s). Re-post it to override the current commit."
|
||||
% (source, login, when.isoformat(), head_time.isoformat()),
|
||||
file=sys.stderr,
|
||||
)
|
||||
continue
|
||||
found.append((when, login, reason, raw_when, source))
|
||||
return found
|
||||
|
||||
|
||||
def main():
|
||||
directive = os.environ.get("DIRECTIVE", "/approve-override").strip()
|
||||
head_time = parse_ts(os.environ.get("HEAD_TIME", ""))
|
||||
|
||||
found = []
|
||||
found += candidates_from(
|
||||
load_source("COMMENTS_JSON", "COMMENTS_JSON"), "comment", directive, head_time
|
||||
)
|
||||
found += candidates_from(
|
||||
load_source("REVIEWS_JSON", "REVIEWS_JSON"), "review", directive, head_time
|
||||
)
|
||||
|
||||
if not found:
|
||||
return 0
|
||||
|
||||
# Latest wins, across both sources: a later override supersedes an earlier one.
|
||||
when, login, reason, raw_when, source = max(found, key=lambda c: c[0])
|
||||
# Keep the reason on one line — this becomes a TSV field.
|
||||
reason = " ".join(reason.split())
|
||||
print("%s\t%s\t%s\t%s" % (login, raw_when, source, reason))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+161
-4
@@ -25,10 +25,45 @@
|
||||
# true: if this cannot evaluate, an unreviewed change to the gate itself could merge. A skip is a
|
||||
# BYPASS, not a gap. So: cannot determine -> BLOCK.
|
||||
#
|
||||
# THE ADMIN OVERRIDE (added 2026-10-01), and the lock-out it clears.
|
||||
#
|
||||
# The rule above was UNSATISFIABLE for the one human who runs this repo. Gitea refuses to let a
|
||||
# pull request's author approve their own PR, and `jakman85` is the only collaborator — so "an
|
||||
# APPROVED review from someone who is not its author" is a condition the sole administrator can
|
||||
# never meet. Every sensitive-path PR they opened was permanently unmergeable, INCLUDING the PR
|
||||
# that would fix it. Measured 2026-10-01: PR #35, gate green in 1m41s, zero reviews, blocked.
|
||||
#
|
||||
# So an administrator may waive the review by COMMENTING on the PR:
|
||||
#
|
||||
# /approve-override <reason>
|
||||
#
|
||||
# Authority comes from the LIVE Gitea permission API, never from the comment — the commenter must
|
||||
# hold a permission listed in `.override.authorized_permissions` (admin/owner) at the moment the
|
||||
# gate runs, so an old comment from a since-demoted account is worth nothing. The directive must
|
||||
# start a line, a reason is mandatory, and the comment must POST-DATE the head commit: an override
|
||||
# earned at commit A does not cover commit B. Choosing the candidate comment is the job of the
|
||||
# pure, offline, unit-tested ci/approval-override.py; AUTHORISING it is the job of this stage,
|
||||
# which fails closed.
|
||||
#
|
||||
# WHAT THIS COSTS, STATED PLAINLY. For an administrator, "a change to the gate cannot be gated by
|
||||
# the gate" stops being structural and becomes advisory. That is the unavoidable consequence of
|
||||
# there being one human; the alternative is a repository its owner cannot change. What is bought
|
||||
# back is a RECORD — the waiver is an explicit act, attributed to an account, carrying a stated
|
||||
# reason, in the PR history and in this stage's output. A silent bypass keyed on "is the author an
|
||||
# admin?" would have been less code and is exactly the fail-open shape this repo keeps finding.
|
||||
#
|
||||
# AND THE PART THAT IS NOT COMFORTABLE. Authorisation is by permission ALONE, with no allowlist of
|
||||
# human accounts (operator decision, 2026-10-01, taken with this consequence on the table). The
|
||||
# AGENT account `gitea-admin` also reports permission `owner` here, so an agent can clear the
|
||||
# sensitive-path review on its own PR by posting the directive — and agents author most PRs in
|
||||
# this repo, so that is the common case, not an edge case. To narrow it without touching this
|
||||
# logic, add an `override_admins` list to infra/gitea/approval-policy.json: the check below
|
||||
# already requires membership whenever that key is present.
|
||||
#
|
||||
# EXIT CODES
|
||||
# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and a
|
||||
# non-author APPROVED review is present
|
||||
# 1 sensitive paths touched without such a review, OR the policy could not be evaluated
|
||||
# 0 no sensitive paths touched (gate alone is sufficient), OR sensitive paths touched and
|
||||
# either a non-author APPROVED review or a valid admin override is present
|
||||
# 1 sensitive paths touched with neither, OR the policy could not be evaluated
|
||||
set -euo pipefail
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
|
||||
@@ -65,6 +100,17 @@ api() { curl -sS --max-time 20 -H "Authorization: token ${TOKEN}" "$@"; }
|
||||
FILES_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/files?limit=1000" || true)"
|
||||
PR_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}" || true)"
|
||||
REVIEWS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/pulls/${PR}/reviews" || true)"
|
||||
# PR conversation comments live under the ISSUES endpoint. `/pulls/{n}/comments` returns review
|
||||
# (diff) comments ONLY, so an override posted in the normal comment box would never appear in it.
|
||||
# Verified against this Gitea (1.24.3) rather than assumed.
|
||||
#
|
||||
# This one is NOT in the must-parse loop below, and that is deliberate. /issues/{n}/comments sits
|
||||
# behind Gitea's *issue* token scope, which `SETTINGS_API_TOKEN` does not carry (it is minted
|
||||
# `[read:repository, read:admin]`) — so it returns an error OBJECT, not an array. Blocking on that
|
||||
# turned "CI cannot read your override" into a message that said "you did not post one", and cost a
|
||||
# real debugging session on PR #38. It is handled explicitly, and loudly, further down: an
|
||||
# unreadable source can only cause a false BLOCK, never a false pass.
|
||||
COMMENTS_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/issues/${PR}/comments?limit=1000" || true)"
|
||||
for v in FILES_JSON PR_JSON REVIEWS_JSON; do
|
||||
jq -e . >/dev/null 2>&1 <<<"${!v}" || {
|
||||
err "could not read ${v} from Gitea for PR #${PR} — BLOCKING rather than assuming."
|
||||
@@ -107,11 +153,122 @@ if [ -n "$APPROVER" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
# No non-author approval exists. Before refusing, look for an administrator's explicit
|
||||
# override. See the header for why this hatch exists and what it costs.
|
||||
# ─────────────────────────────────────────────────────────────────────────────────────────
|
||||
OVERRIDE_ENABLED="$(jq -r '.override.enabled // false' "$DECL")"
|
||||
DIRECTIVE="$(jq -r '.override.comment_directive // "/approve-override"' "$DECL")"
|
||||
HEAD_SHA="$(jq -r '.head.sha // empty' <<<"$PR_JSON")"
|
||||
|
||||
if [ "$OVERRIDE_ENABLED" = "true" ]; then
|
||||
# The head commit's timestamp anchors staleness. Read it from GITEA, not from the local
|
||||
# tree: CI checks out a merge commit, whose date is not the head commit's date.
|
||||
HEAD_TIME=""
|
||||
if [ -n "$HEAD_SHA" ]; then
|
||||
HEAD_COMMIT_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/git/commits/${HEAD_SHA}" || true)"
|
||||
HEAD_TIME="$(jq -r '.commit.committer.date // .created // empty' <<<"${HEAD_COMMIT_JSON:-null}" 2>/dev/null || true)"
|
||||
fi
|
||||
if [ -z "$HEAD_TIME" ]; then
|
||||
err "could not read the head commit's timestamp for PR #${PR} — BLOCKING."
|
||||
err " Without it an override cannot be aged, and an unaged override is one that silently"
|
||||
err " covers commits nobody waived review on."
|
||||
summary "- approval-policy: **BLOCKED — head commit timestamp unreadable** (cannot age an override)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Is the comments source actually readable? Say so plainly if not — a scope problem and "you
|
||||
# never posted an override" are indistinguishable from the outside, and conflating them is what
|
||||
# made PR #38 look like a code bug.
|
||||
if ! jq -e 'type == "array"' >/dev/null 2>&1 <<<"${COMMENTS_JSON:-}"; then
|
||||
SCOPE_MSG="$(jq -r '.message // empty' <<<"${COMMENTS_JSON:-null}" 2>/dev/null || true)"
|
||||
warn "cannot read PR COMMENTS from Gitea${SCOPE_MSG:+ — ${SCOPE_MSG}}."
|
||||
warn " /issues/${PR}/comments needs the *issue* token scope; SETTINGS_API_TOKEN is minted"
|
||||
warn " [read:repository, read:admin] (infra/gitea/set-ci-token.sh). An override posted as an"
|
||||
warn " ordinary PR COMMENT is therefore invisible to CI — post it as a REVIEW of type"
|
||||
warn " 'Comment' instead, which /pulls/${PR}/reviews exposes under read:repository."
|
||||
summary "- approval-policy: note — PR comments unreadable (token lacks the issue scope); use a **review** to override"
|
||||
COMMENTS_JSON='[]'
|
||||
fi
|
||||
|
||||
# Pure, offline, unit-tested selection: which comment or review, if any, is a live override?
|
||||
OVERRIDE_TSV="$(COMMENTS_JSON="$COMMENTS_JSON" REVIEWS_JSON="$REVIEWS_JSON" \
|
||||
HEAD_TIME="$HEAD_TIME" DIRECTIVE="$DIRECTIVE" \
|
||||
python3 "${CI_DIR}/approval-override.py")" || {
|
||||
err "ci/approval-override.py failed — BLOCKING rather than assuming there is no override."
|
||||
summary "- approval-policy: **BLOCKED — override parser failed**"
|
||||
exit 1; }
|
||||
|
||||
if [ -n "$OVERRIDE_TSV" ]; then
|
||||
OV_USER="$(cut -f1 <<<"$OVERRIDE_TSV")"
|
||||
OV_WHEN="$(cut -f2 <<<"$OVERRIDE_TSV")"
|
||||
OV_SRC="$(cut -f3 <<<"$OVERRIDE_TSV")"
|
||||
OV_WHY="$(cut -f4- <<<"$OVERRIDE_TSV")"
|
||||
|
||||
# AUTHORITY IS MEASURED LIVE, never inferred from the comment or from a name in git.
|
||||
PERM_JSON="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/collaborators/${OV_USER}/permission" || true)"
|
||||
OV_PERM="$(jq -r '.permission // empty' <<<"${PERM_JSON:-null}" 2>/dev/null || true)"
|
||||
if [ -z "$OV_PERM" ]; then
|
||||
err "'${OV_USER}' posted ${DIRECTIVE}, but their permission on ${GITEA_REPO} could not be"
|
||||
err " read — BLOCKING. An override whose authority is unverified is not an override."
|
||||
summary "- approval-policy: **BLOCKED — cannot verify \`${OV_USER}\`'s permission**"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Authorised when the LIVE permission is one of the declared ones and — only if an
|
||||
# `override_admins` allowlist exists at all — the account is on it. No allowlist means
|
||||
# permission alone, which is the operator's standing decision; adding the key narrows
|
||||
# this with no change to any code.
|
||||
AUTHORISED="$(DECL="$DECL" OV_PERM="$OV_PERM" OV_USER="$OV_USER" python3 -c '
|
||||
import json, os
|
||||
d = json.load(open(os.environ["DECL"])).get("override", {})
|
||||
perms = d.get("authorized_permissions") or ["admin", "owner"]
|
||||
allow = d.get("override_admins")
|
||||
ok_perm = os.environ["OV_PERM"] in perms
|
||||
ok_list = True if allow is None else os.environ["OV_USER"] in allow
|
||||
print("yes" if (ok_perm and ok_list) else "no")
|
||||
')"
|
||||
|
||||
if [ "$AUTHORISED" = "yes" ]; then
|
||||
if [ "$OV_USER" = "$AUTHOR" ]; then
|
||||
warn "ADMIN OVERRIDE — SELF-WAIVED. '${OV_USER}' authored this PR and waived the"
|
||||
warn " non-author review requirement on their own change. Permitted (permission"
|
||||
warn " '${OV_PERM}'), deliberate, and shouted about here precisely because it is not free."
|
||||
else
|
||||
warn "ADMIN OVERRIDE by '${OV_USER}' (permission '${OV_PERM}'); author is '${AUTHOR}'."
|
||||
fi
|
||||
warn " via: PR ${OV_SRC}"
|
||||
warn " when: ${OV_WHEN} (head commit ${HEAD_SHA:0:8}, ${HEAD_TIME})"
|
||||
warn " reason: ${OV_WHY}"
|
||||
warn " review waived on: $(printf '%s ' $SENSITIVE)"
|
||||
ok "sensitive paths cleared by administrator override — this stage does not block."
|
||||
SELFNOTE=""; [ "$OV_USER" = "$AUTHOR" ] && SELFNOTE=", **self-waived as author**"
|
||||
summary "- approval-policy: **OVERRIDDEN by \`${OV_USER}\`** (permission \`${OV_PERM}\`, via PR ${OV_SRC}${SELFNOTE})"
|
||||
summary " - reason: ${OV_WHY}"
|
||||
summary " - review waived on: $(printf '\`%s\` ' $SENSITIVE)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
err "'${OV_USER}' posted ${DIRECTIVE} but is NOT authorised to override: live permission is"
|
||||
err " '${OV_PERM}'. The authorised set is declared in infra/gitea/approval-policy.json."
|
||||
summary "- approval-policy: **BLOCKED — \`${OV_USER}\` may not override** (permission \`${OV_PERM}\`)"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
err "This PR changes the pipeline or the security posture it rests on, and has NO approving"
|
||||
err " review from anyone other than its author ('${AUTHOR}')."
|
||||
err " A change to the gate cannot be gated by the gate — that is the one thing CI cannot"
|
||||
err " honestly approve for itself. Get a human review, or move the change out of:"
|
||||
printf ' %s\n' $SENSITIVE >&2
|
||||
err " The list lives in infra/gitea/approval-policy.json, with a reason per entry."
|
||||
summary "- approval-policy: **BLOCKED — sensitive paths need a non-author approval**"
|
||||
if [ "$OVERRIDE_ENABLED" = "true" ]; then
|
||||
err " Or, if you hold admin/owner on this repository, post this on the PR:"
|
||||
err " ${DIRECTIVE} <reason>"
|
||||
err " as a REVIEW of type 'Comment' (not the plain comment box — CI's token cannot read"
|
||||
err " /issues/${PR}/comments without the issue scope; see infra/gitea/set-ci-token.sh)."
|
||||
err " The reason is mandatory, the directive must START a line, and it must be NEWER than"
|
||||
err " head commit ${HEAD_SHA:0:8} — push again and you must override again."
|
||||
fi
|
||||
summary "- approval-policy: **BLOCKED — needs a non-author approval, or an admin \`${DIRECTIVE}\` comment**"
|
||||
exit 1
|
||||
+15
@@ -27,3 +27,18 @@ 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"
|
||||
+54
-1
@@ -1,5 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/render-golden.sh — Stage: render==golden reproducibility gate.
|
||||
# ci/render-golden.sh — Stage: the renderer is trustworthy.
|
||||
# (1) catalog/render.py passes its OWN output-contract self-test, and
|
||||
# (2) every committed apps/ golden reproduces byte-for-byte from the canonical render.
|
||||
# Two independent assertions about one subject, catalog/render.py. (1) was added
|
||||
# 2026-10-01 because nothing in ci/ or .gitea/ had ever invoked `--selftest`; see the
|
||||
# block above the invocation for why it lives here and not in a conformance check.
|
||||
#
|
||||
# WHY: the committed apps/ tree is a set of GOLDENS produced by ONE canonical
|
||||
# invocation of catalog/render.py. If a golden is hand-edited, or a catalog
|
||||
@@ -54,6 +59,54 @@ if [ ! -f "$RENDER_PY" ]; then
|
||||
summary "- render-golden: **SKIPPED** (renderer missing)"
|
||||
exit 3
|
||||
fi
|
||||
# --- The renderer's OWN self-test, before anything is compared ---------------
|
||||
# `catalog/render.py --selftest` is ~106 executed assertions on the renderer's output
|
||||
# contract (default-deny covers both directions, the Kyverno namespace hook label, a valid
|
||||
# auth-mode, the oauth2-proxy sidecar's cookie-name/-domain/-refresh flags, the
|
||||
# placeholder-substitution guard, the observability refusals...). Until now NOTHING in ci/
|
||||
# or .gitea/ invoked it — measured by grep on 2026-09-30 and recorded as the top follow-up
|
||||
# of decisions/2026-09-30-oauth2-proxy-cookie-collision-fleet.md — so every one of those
|
||||
# assertions was dev-time only and a merge gate could not tell a sound renderer from a
|
||||
# gutted one.
|
||||
#
|
||||
# WHY HERE, AND NOT IN A CONFORMANCE CHECK. The lead was
|
||||
# conformance/tests/l0-static/auditability-proofs-producible/check.sh, which drives
|
||||
# `conformance/lib/{record_run,status}.py --selftest` "because nothing else drives them".
|
||||
# That loop is NOT the right hook: that check serves R-SEC-03 and its subject is the
|
||||
# EVIDENCE CHAIN (can a forged or stale run become a proof?). The renderer is not part of
|
||||
# that chain, and hanging it there would make the check's name and its requirement mapping
|
||||
# a lie. This stage's subject already IS catalog/render.py — it exists to assert that the
|
||||
# renderer's output is trustworthy — so the renderer's own contract test belongs in it.
|
||||
#
|
||||
# It runs BEFORE the catalog/empty-catalog exit-3 below on purpose: the self-test is
|
||||
# deterministic and catalog-independent (it renders synthetic ServiceDefinitions in
|
||||
# memory), so an empty catalog must not be able to skip it.
|
||||
#
|
||||
# render-golden and the self-test are INDEPENDENT assertions, which is why both are needed:
|
||||
# render-golden catches golden-vs-renderer drift but is satisfied by ANY renderer whose
|
||||
# output was re-rendered, including one that just lost a contract invariant and had all ten
|
||||
# goldens regenerated in the same commit. The self-test catches exactly that.
|
||||
#
|
||||
# FAILURE IS A HARD FAIL, NOT A SKIP: the renderer is source in this repo, so "could not
|
||||
# run its self-test" has no legitimate environmental cause once python3 and render.py are
|
||||
# both present (both asserted above). ci/gate.sh maps this stage's rc 1 to BLOCKED.
|
||||
log "Stage: catalog/render.py --selftest (the renderer's own output-contract assertions)"
|
||||
selftest_out="$(mktemp)"
|
||||
if "$PYTHON" "$RENDER_PY" --selftest >"$selftest_out" 2>&1; then
|
||||
ok "render.py --selftest passed"
|
||||
summary "- render-selftest: **passed** (\`catalog/render.py --selftest\`)"
|
||||
else
|
||||
err "catalog/render.py --selftest FAILED — the renderer violates its own output contract."
|
||||
err " Every golden under apps/ is produced by this renderer, so the goldens below cannot"
|
||||
err " be trusted even if they reproduce byte-for-byte. Reproduce with:"
|
||||
err " ${PYTHON} catalog/render.py --selftest"
|
||||
sed 's/^/ /' "$selftest_out" >&2 || true
|
||||
summary "- render-selftest: **FAILED** (\`catalog/render.py --selftest\` — renderer contract violated)"
|
||||
rm -f "$selftest_out"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$selftest_out"
|
||||
|
||||
if [ ! -d "$CATALOG_DIR" ] || ! compgen -G "${CATALOG_DIR}/*.yaml" >/dev/null 2>&1; then
|
||||
skip "no catalog ServiceDefinitions under '${CATALOG_DIR}' — nothing to render."
|
||||
summary "- render-golden: **SKIPPED** (empty catalog)"
|
||||
|
||||
Executable
+232
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci/tests/approval-override.test.sh — proves ci/approval-override.py selects the override it
|
||||
# claims to select, and REJECTS the ones it claims to reject.
|
||||
#
|
||||
# This parser decides whether a human review requirement gets waived. Every rule in it exists
|
||||
# because the obvious implementation ("does the body contain the directive?") is wrong in a way
|
||||
# that is invisible until abused: a quoted directive would re-arm an override, a bare directive
|
||||
# would waive review with no stated reason, and an override posted against commit A would go on
|
||||
# covering commit B forever. A guard is worth nothing until somebody has watched it refuse, which
|
||||
# is the same reasoning as ci/tests/decisions-log.test.sh one layer up.
|
||||
#
|
||||
# The parser is pure and offline BY DESIGN — authorisation (the live permission lookup) lives in
|
||||
# ci/approval-policy.sh, which cannot be tested without Gitea. That split is what makes this file
|
||||
# possible, and it is also why a green run here does NOT prove the stage authorises correctly.
|
||||
set -uo pipefail
|
||||
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SUT="${CI_DIR}/approval-override.py"
|
||||
|
||||
PASS=0 FAIL=0
|
||||
ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); }
|
||||
bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); }
|
||||
|
||||
HEAD_TIME="2026-10-01T12:00:00Z"
|
||||
AFTER="2026-10-01T12:30:00Z"
|
||||
BEFORE="2026-10-01T11:30:00Z"
|
||||
|
||||
# run <comments-json> -> stdout of the parser (stderr is diagnostic only)
|
||||
run() {
|
||||
COMMENTS_JSON="$1" REVIEWS_JSON="" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \
|
||||
python3 "$SUT" 2>/dev/null
|
||||
}
|
||||
|
||||
# run_rev <reviews-json> — the same, but through the REVIEWS source.
|
||||
run_rev() {
|
||||
COMMENTS_JSON="" REVIEWS_JSON="$1" HEAD_TIME="${2:-$HEAD_TIME}" DIRECTIVE="${3:-/approve-override}" \
|
||||
python3 "$SUT" 2>/dev/null
|
||||
}
|
||||
|
||||
# comment <login> <created_at> <body>
|
||||
comment() {
|
||||
python3 -c '
|
||||
import json, sys
|
||||
print(json.dumps([{"user": {"login": sys.argv[1]}, "created_at": sys.argv[2], "body": sys.argv[3]}]))
|
||||
' "$1" "$2" "$3"
|
||||
}
|
||||
|
||||
expect_none() {
|
||||
local label="$1" out
|
||||
out="$(run "$2")"
|
||||
if [ -z "$out" ]; then ok "$label"; else bad "$label (got: ${out})"; fi
|
||||
}
|
||||
|
||||
expect_user() {
|
||||
local label="$1" json="$2" want="$3" out got
|
||||
out="$(run "$json")"
|
||||
got="$(cut -f1 <<<"$out")"
|
||||
if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted ${want}, got '${got}')"; fi
|
||||
}
|
||||
|
||||
expect_reason() {
|
||||
local label="$1" json="$2" want="$3" out got
|
||||
out="$(run "$json")"
|
||||
got="$(cut -f4- <<<"$out")"
|
||||
if [ "$got" = "$want" ]; then ok "$label"; else bad "$label (wanted '${want}', got '${got}')"; fi
|
||||
}
|
||||
|
||||
# check <label> <actual> <wanted>
|
||||
check() {
|
||||
if [ "$2" = "$3" ]; then ok "$1"; else bad "$1 (wanted '${3}', got '${2}')"; fi
|
||||
}
|
||||
|
||||
# review <login> <submitted_at> <state> <body>
|
||||
review() {
|
||||
python3 -c '
|
||||
import json, sys
|
||||
print(json.dumps([{"user": {"login": sys.argv[1]}, "submitted_at": sys.argv[2],
|
||||
"state": sys.argv[3], "body": sys.argv[4]}]))
|
||||
' "$1" "$2" "$3" "$4"
|
||||
}
|
||||
|
||||
echo "== ci/approval-override.py"
|
||||
|
||||
# --- the happy path -------------------------------------------------------------------
|
||||
expect_user "a directive with a reason, posted after the head commit, is selected" \
|
||||
"$(comment jakman85 "$AFTER" '/approve-override sole admin, gate is green')" jakman85
|
||||
|
||||
expect_reason "the reason is returned verbatim" \
|
||||
"$(comment jakman85 "$AFTER" '/approve-override sole admin, gate is green')" \
|
||||
'sole admin, gate is green'
|
||||
|
||||
# --- absence is a normal answer, not an error -----------------------------------------
|
||||
expect_none "no comments at all" '[]'
|
||||
expect_none "an ordinary comment" "$(comment jakman85 "$AFTER" 'looks good to me')"
|
||||
|
||||
# --- a reason is MANDATORY ------------------------------------------------------------
|
||||
expect_none "a bare directive (no reason) is refused" \
|
||||
"$(comment jakman85 "$AFTER" '/approve-override')"
|
||||
expect_none "a directive followed by only whitespace is refused" \
|
||||
"$(comment jakman85 "$AFTER" '/approve-override ')"
|
||||
|
||||
# --- the directive must START a line --------------------------------------------------
|
||||
expect_none "the directive mid-sentence does not count" \
|
||||
"$(comment jakman85 "$AFTER" 'you could just /approve-override this one')"
|
||||
expect_none "a QUOTED directive does not re-arm an override" \
|
||||
"$(comment jakman85 "$AFTER" '> /approve-override quoting someone else')"
|
||||
expect_user "leading whitespace before the directive is fine" \
|
||||
"$(comment jakman85 "$AFTER" ' /approve-override indented but mine')" jakman85
|
||||
expect_user "the directive on a later line of a longer comment counts" \
|
||||
"$(comment jakman85 "$AFTER" 'some preamble
|
||||
/approve-override the real reason here')" jakman85
|
||||
|
||||
# --- staleness: an override must post-date the commit it waives review on -------------
|
||||
expect_none "an override OLDER than the head commit is dropped as stale" \
|
||||
"$(comment jakman85 "$BEFORE" '/approve-override stale, predates the push')"
|
||||
expect_none "an override exactly AT the head commit time is dropped (strictly newer)" \
|
||||
"$(comment jakman85 "$HEAD_TIME" '/approve-override same instant')"
|
||||
|
||||
# --- malformed input is never a free pass --------------------------------------------
|
||||
expect_none "an unreadable timestamp drops the candidate" \
|
||||
"$(comment jakman85 'not-a-date' '/approve-override bad clock')"
|
||||
expect_none "a comment with no author is dropped" \
|
||||
'[{"user": {}, "created_at": "2026-10-01T12:30:00Z", "body": "/approve-override no login"}]'
|
||||
expect_none "invalid JSON yields no override (and never a traceback into stdout)" \
|
||||
'this is not json'
|
||||
expect_none "a JSON object instead of a list yields no override" \
|
||||
'{"user": "nope"}'
|
||||
|
||||
# --- latest wins ----------------------------------------------------------------------
|
||||
TWO='[
|
||||
{"user": {"login": "early"}, "created_at": "2026-10-01T12:10:00Z", "body": "/approve-override first"},
|
||||
{"user": {"login": "latest"}, "created_at": "2026-10-01T12:40:00Z", "body": "/approve-override second"}
|
||||
]'
|
||||
expect_user "the LATEST override supersedes an earlier one" "$TWO" latest
|
||||
expect_reason "and brings its own reason" "$TWO" second
|
||||
|
||||
# --- offsets, not just Z --------------------------------------------------------------
|
||||
# Gitea emits RFC3339 WITH AN OFFSET (measured: 2026-10-01T23:09:08-05:00), not a trailing Z.
|
||||
# A parser that only handles 'Z' silently drops every real override, so this case is load-bearing.
|
||||
expect_user "an RFC3339 offset timestamp (-05:00), as Gitea actually emits, parses" \
|
||||
'[{"user": {"login": "jakman85"}, "created_at": "2026-10-01T08:30:00-05:00", "body": "/approve-override offset form"}]' \
|
||||
jakman85
|
||||
expect_none "and an offset timestamp that is genuinely earlier is still stale" \
|
||||
'[{"user": {"login": "jakman85"}, "created_at": "2026-10-01T06:00:00-05:00", "body": "/approve-override offset, stale"}]'
|
||||
|
||||
# --- the directive is configurable ----------------------------------------------------
|
||||
out="$(run "$(comment jakman85 "$AFTER" '/admin-waive because policy says so')" "$HEAD_TIME" '/admin-waive')"
|
||||
if [ "$(cut -f1 <<<"$out")" = "jakman85" ]; then
|
||||
ok "a custom directive from the declaration is honoured"
|
||||
else
|
||||
bad "a custom directive from the declaration is honoured (got '${out}')"
|
||||
fi
|
||||
expect_none "the default directive does not match a custom-directive comment" \
|
||||
"$(comment jakman85 "$AFTER" '/admin-waive because policy says so')"
|
||||
|
||||
# --- no staleness anchor: do not invent one -------------------------------------------
|
||||
out="$(COMMENTS_JSON="$(comment jakman85 "$BEFORE" '/approve-override no anchor')" \
|
||||
HEAD_TIME="" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||
if [ "$(cut -f1 <<<"$out")" = "jakman85" ]; then
|
||||
ok "with no HEAD_TIME the staleness test is skipped, not faked (caller blocks instead)"
|
||||
else
|
||||
bad "with no HEAD_TIME the candidate should still be returned (got '${out}')"
|
||||
fi
|
||||
|
||||
# --- the REVIEWS source ---------------------------------------------------------------
|
||||
# Load-bearing, not redundant. Measured on PR #38: SETTINGS_API_TOKEN is minted
|
||||
# [read:repository, read:admin], /issues/{n}/comments needs the ISSUE scope, so a well-formed
|
||||
# override in the comment box came back as an error object and was invisible. /pulls/{n}/reviews
|
||||
# IS readable with that token, so a review body is the route that works today.
|
||||
check "a review body carries an override" \
|
||||
"$(cut -f1 <<<"$(run_rev "$(review jakman85 "$AFTER" COMMENT '/approve-override via a review')")")" \
|
||||
jakman85
|
||||
check "the source is reported as 'review'" \
|
||||
"$(cut -f3 <<<"$(run_rev "$(review jakman85 "$AFTER" COMMENT '/approve-override via a review')")")" \
|
||||
review
|
||||
check "the source is reported as 'comment' for a comment" \
|
||||
"$(cut -f3 <<<"$(run "$(comment jakman85 "$AFTER" '/approve-override via a comment')")")" \
|
||||
comment
|
||||
|
||||
out="$(run_rev "$(review jakman85 "$BEFORE" COMMENT '/approve-override stale review')")"
|
||||
check "a stale review override is dropped too" "${out:-<none>}" "<none>"
|
||||
|
||||
out="$(run_rev "$(review jakman85 "$AFTER" PENDING '/approve-override unsubmitted draft')")"
|
||||
check "a PENDING (unsubmitted) review is NOT an act of anything" "${out:-<none>}" "<none>"
|
||||
|
||||
check "an APPROVED review carrying the directive still counts as an override" \
|
||||
"$(cut -f1 <<<"$(run_rev "$(review jakman85 "$AFTER" APPROVED '/approve-override belt and braces')")")" \
|
||||
jakman85
|
||||
|
||||
out="$(run_rev "$(review jakman85 "$AFTER" COMMENT 'ordinary review prose, no directive')")"
|
||||
check "an ordinary review body is not an override" "${out:-<none>}" "<none>"
|
||||
|
||||
# --- an unreadable source degrades to 'no candidates', never to a pass ----------------
|
||||
# The real-world shape: Gitea answers with {"message": "token does not have ... scope"}.
|
||||
ERR_OBJ='{"message":"token does not have at least one of required scope(s): [read:issue]"}'
|
||||
out="$(COMMENTS_JSON="$ERR_OBJ" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
|
||||
DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||
check "an error OBJECT from the comments API yields no override" "${out:-<none>}" "<none>"
|
||||
|
||||
out="$(COMMENTS_JSON="$ERR_OBJ" \
|
||||
REVIEWS_JSON="$(review jakman85 "$AFTER" COMMENT '/approve-override reviews still work')" \
|
||||
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||
check "an unreadable COMMENTS source does not suppress a valid REVIEW override" \
|
||||
"$(cut -f1 <<<"$out")" jakman85
|
||||
|
||||
# and it must SAY so, or a scope problem reads as 'you never posted one' (it did, on PR #38)
|
||||
errout="$(COMMENTS_JSON="$ERR_OBJ" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
|
||||
DIRECTIVE=/approve-override python3 "$SUT" 2>&1 >/dev/null)"
|
||||
case "$errout" in
|
||||
*"not a list"*read:issue*) ok "the diagnostic names the scope Gitea complained about" ;;
|
||||
*) bad "the diagnostic should quote Gitea's message (got: ${errout})" ;;
|
||||
esac
|
||||
|
||||
# --- latest wins ACROSS the two sources ----------------------------------------------
|
||||
out="$(COMMENTS_JSON="$(comment early "2026-10-01T12:10:00Z" '/approve-override from a comment')" \
|
||||
REVIEWS_JSON="$(review later "2026-10-01T12:50:00Z" COMMENT '/approve-override from a review')" \
|
||||
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||
check "the latest override wins across both sources (review newer)" "$(cut -f1 <<<"$out")" later
|
||||
check "and its source is labelled" "$(cut -f3 <<<"$out")" review
|
||||
|
||||
out="$(COMMENTS_JSON="$(comment later "2026-10-01T12:50:00Z" '/approve-override from a comment')" \
|
||||
REVIEWS_JSON="$(review early "2026-10-01T12:10:00Z" COMMENT '/approve-override from a review')" \
|
||||
HEAD_TIME="$HEAD_TIME" DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||
check "the latest override wins across both sources (comment newer)" "$(cut -f1 <<<"$out")" later
|
||||
check "and that source is labelled too" "$(cut -f3 <<<"$out")" comment
|
||||
|
||||
# --- both sources absent entirely -----------------------------------------------------
|
||||
out="$(COMMENTS_JSON="" REVIEWS_JSON="" HEAD_TIME="$HEAD_TIME" \
|
||||
DIRECTIVE=/approve-override python3 "$SUT" 2>/dev/null)"
|
||||
check "no sources at all yields no override, and no crash" "${out:-<none>}" "<none>"
|
||||
|
||||
printf '\n %d passed, %d failed\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -42,7 +42,16 @@ cp "${REAL_CI_DIR}/gate.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env
|
||||
# 15 of 47 assertions failed on `main` for that reason alone, including every one that expects a
|
||||
# PASS/UNDETERMINED/NOT-APPLICABLE headline. The suite was measuring the missing stub, not the
|
||||
# decision logic. `decisions-log` is added here in the same change that adds it to gate.sh.
|
||||
for stage in spec-immutability decisions-log lint l0 render-golden l1 gitea-settings conformance; do
|
||||
#
|
||||
# AND IT HAPPENED AGAIN, with `approval-policy` (fixed 2026-10-01). Same shape, same cost: the
|
||||
# stage was added to gate.sh by PR #17, never added here, so 15 of 50 assertions failed on `main`
|
||||
# — measured on 0c7e8dd before touching anything, to be sure it was not the change under test.
|
||||
# The note above was written about the previous instance and did not prevent the next one, which
|
||||
# says the comment is not the fix. THE FIX IS TO DERIVE THE LIST: it now comes from gate.sh's own
|
||||
# `run_stage` calls, so a stage added there can no longer be silently missing here.
|
||||
mapfile -t STAGES < <(sed -n 's/^run_stage[[:space:]]\+\([a-z0-9-]\+\).*/\1/p' "${REAL_CI_DIR}/gate.sh")
|
||||
[ "${#STAGES[@]}" -ge 8 ] || { echo "could not parse stage names out of gate.sh (got ${#STAGES[@]})" >&2; exit 1; }
|
||||
for stage in "${STAGES[@]}"; do
|
||||
cat > "${CI}/${stage}.sh" <<'STUB'
|
||||
#!/usr/bin/env bash
|
||||
n="$(basename "$0" .sh)"; v="STAGE_RC_${n//-/_}"
|
||||
|
||||
@@ -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
|
||||
@@ -100,7 +100,9 @@ data:
|
||||
# platform/keycloak/postgres-cluster.yaml but is rendered by platform-config, which is the ONLY
|
||||
# tier that lists it), crafty (CRAFTY_L4_VIP), apps-wave-0 (nextcloud-db + semaphore-db:
|
||||
# S3_BACKUP_ENDPOINT, CNPG_BACKUP_PATH; pihole + pihole-dns: PIHOLE_DNS_VIP, LAN_CIDR, plus
|
||||
# GATEWAY_VIP/UNIFI_L4_VIP/CRAFTY_L4_VIP read BY pihole to author its LAN split-horizon answers),
|
||||
# GATEWAY_VIP/UNIFI_L4_VIP/CRAFTY_L4_VIP read BY pihole to author its LAN split-horizon answers;
|
||||
# plex-remote + pihole: PLEX_L4_VIP),
|
||||
# vpn-ingress (WG_CLIENT_CIDR),
|
||||
# velero (S3_BACKUP_ENDPOINT, S3_BACKUP_REGION,
|
||||
# VELERO_BACKUP_BUCKET, VELERO_BACKUP_PREFIX), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an
|
||||
# UNSUBSTITUTED path would ship the literal string into the cluster.
|
||||
@@ -175,6 +177,26 @@ data:
|
||||
# dev -> 192.168.1.56 (base default; next free after crafty .55 in dev's .53-.60 pool)
|
||||
# prod -> 192.168.2.23 (next free after crafty .22 in prod's .20-.30 pool; see clusters/prod)
|
||||
PIHOLE_DNS_VIP: "192.168.1.56" # PiHole: authoritative LAN DNS for ${BASE_DOMAIN}
|
||||
# Plex's remote-access listener: raw tcp/32400 for REMOTE Plex clients (apps/wave-0/plex-remote.yaml).
|
||||
# A FOURTH pinned L4 VIP, for the usual reason — the `shared` Gateway is an L7 HTTPS terminator and
|
||||
# cannot carry Plex's own TLS. Plex clients do not resolve `plex.${BASE_DOMAIN}`; they fetch this
|
||||
# server's address from plex.tv, so the Gateway's L7 path (which still serves the web UI, and was
|
||||
# measured working) cannot put the server on a client's connection list at all.
|
||||
#
|
||||
# PINNED, AND THE PIN IS LOAD-BEARING OFF-REPO: the SOHO router forwards WAN tcp/32400 to this
|
||||
# address by hand. A VIP taken from the pool head would silently move on a reschedule and break
|
||||
# remote access with nothing in-cluster looking wrong. See runbooks/plex-remote-access.md.
|
||||
#
|
||||
# dev -> 192.168.1.57 (base default; next free after pihole-dns .56 in dev's .53-.60 pool)
|
||||
# prod -> 192.168.2.24 (next free after pihole-dns .23 in prod's .20-.30 pool; see clusters/prod)
|
||||
PLEX_L4_VIP: "192.168.1.57" # Plex: tcp/32400 remote access (NOT the web UI, which is L7)
|
||||
# SECOND CONSUMER since 2026-10-04: catalog/pihole.yaml reads this knob to answer
|
||||
# plex-vip.${BASE_DOMAIN} on the LAN, which is the local half of the single hostname Plex
|
||||
# advertises to plex.tv. Without that per-host answer the zone wildcard sends the name to
|
||||
# ${GATEWAY_VIP}, where nothing listens on 32400. So this value is now typed into THREE places
|
||||
# that must agree: this knob, the WAN port-forward on the SOHO router (off-repo), and PiHole's
|
||||
# answer -- and only the first two were true before.
|
||||
|
||||
|
||||
# The FLAT LAN this cluster's nodes and LB VIPs live on, in CIDR form. Consumed as the ingress
|
||||
# peer of the PiHole DNS CiliumNetworkPolicy (apps/wave-0/pihole-dns.yaml): a resolver reachable
|
||||
@@ -186,27 +208,54 @@ data:
|
||||
# prod -> 192.168.2.0/24 (prod LAN; nodes .10/.11/.12)
|
||||
LAN_CIDR: "192.168.1.0/24" # ingress scope for the PiHole DNS listener
|
||||
|
||||
# --- Remote-access VPN client subnet (platform/vpn-ingress, WireGuard) --------------------------
|
||||
# The tunnel subnet handed to REMOTE VPN clients. `.1` is the server (the hostNetwork DaemonSet,
|
||||
# on every node); `.2`-`.254` are devices, allocated one /32 each in
|
||||
# platform/vpn-ingress/peers.yaml. Consumed twice in the pod's config-assembly script: to derive
|
||||
# the server's own address, and as the `-s` match of the MASQUERADE rule that makes LAN replies
|
||||
# routable.
|
||||
#
|
||||
# IT IS A KNOB RATHER THAN A LITERAL FOR ONE REASON: it is the only address range in this cluster
|
||||
# that must be checked against EVERY OTHER range at once, so it belongs where those are declared.
|
||||
# 10.90.0.0/24 was chosen to collide with none of them:
|
||||
# 10.0.0.0/16 Cilium cluster-pool pod CIDR (${CLUSTER_POD_CIDR})
|
||||
# 10.43.0.0/16 k3s service CIDR (measured live 2026-10-05: kubernetes svc
|
||||
# clusterIP 10.43.0.1)
|
||||
# 10.72.192.0/18 the PIA tunnel's pushed addresses (platform/vpn-egress)
|
||||
# 192.168.1.0/24 dev LAN / pfSense LAN (${LAN_CIDR} base default)
|
||||
# 192.168.2.0/24 prod LAN (${LAN_CIDR} on prod)
|
||||
#
|
||||
# NO PER-ENVIRONMENT OVERRIDE, and that is deliberate rather than an omission: unlike every VIP
|
||||
# above, this range is NOT drawn from the LAN. It is private to the tunnel, so the same value is
|
||||
# correct in both environments and a prod patch would be a no-op that implies otherwise.
|
||||
#
|
||||
# IF YOU CHANGE IT, every already-issued client profile breaks (its `Address` is from this range)
|
||||
# and must be re-rendered — so treat it as fixed once the first device is enrolled.
|
||||
WG_CLIENT_CIDR: "10.90.0.0/24" # remote VPN clients; .1 = server. NOT a LAN range
|
||||
|
||||
# --- Bulk (external NFS) storage host (docs/04 bulk tier) ---------------------------------------
|
||||
# THE address of the Proxmox host whose ZFS datasets back every `class: bulk` volume: the media
|
||||
# library (Storage/plex, 8.33 T — plex, sonarr, radarr) and the legacy Nextcloud archive
|
||||
# THE address of the NAS whose ZFS datasets back every `class: bulk` volume: the media library
|
||||
# (Storage/plex, 8.33 T — plex, sonarr, radarr, qbittorrent) and the legacy Nextcloud archive
|
||||
# (Storage/nextcloud, 270 G). The renderer emits it into each static NFS PersistentVolume's
|
||||
# `spec.nfs.server` (catalog/render.py, BULK_NFS_SERVER); the export PATH is per-dataset and is
|
||||
# declared in the ServiceDefinition, because the path is identical in every environment and only
|
||||
# the ADDRESS is not.
|
||||
#
|
||||
# WHY THIS IS A KNOB AND NOT A LITERAL — the two clusters are on OPPOSITE SIDES of pfSense.
|
||||
# The storage host has exactly one interface, vmbr0 192.168.1.9/24, on pfSense's LAN.
|
||||
# dev nodes are 192.168.1.40/.41/.42 — the SAME L2 segment. They reach it directly, and one
|
||||
# NFS export to them already exists on that host (/mnt/Storage/k8s-volumes in
|
||||
# /etc/exports), so this path is proven, not theoretical. Base default = the dev value,
|
||||
# per the convention of the LAN block above.
|
||||
# prod nodes are 192.168.2.10/.11/.12 — pfSense's WAN. tcp/2049 does NOT traverse that today;
|
||||
# 22 and 8006 answer only because pfSense port-forwards them via 192.168.2.102. Opening
|
||||
# the storage path is a PENDING OPERATOR DECISION with two candidate outcomes that give
|
||||
# DIFFERENT values here: 192.168.2.102 (another port-forward) or 192.168.1.9 (a route).
|
||||
# So prod holds a loud TBD-PROD-* placeholder — see clusters/prod/kustomization.yaml for the
|
||||
# blast radius of shipping it unset, which is NOT the usual "fails to apply".
|
||||
BULK_NFS_SERVER: "192.168.1.9"
|
||||
# IT IS NO LONGER A CROSS-NETWORK KNOB (2026-10-07). This used to read "the two clusters are on
|
||||
# OPPOSITE SIDES of pfSense": the datasets lived on a Proxmox host whose only interface was
|
||||
# vmbr0 192.168.1.9/24 on pfSense's LAN, which prod (192.168.2.0/24, pfSense's WAN) could reach
|
||||
# only through a DNAT at 192.168.2.102. Both pools were physically moved onto a standalone
|
||||
# TrueNAS at 192.168.2.30 — prod's OWN /24 — so there is no crossing to arrange and no candidate
|
||||
# values to choose between. Measured 2026-10-07: 192.168.2.102:2049 no longer answers;
|
||||
# 192.168.2.30:2049 and :111 do. There is now exactly ONE storage host and one correct value;
|
||||
# this stays a knob only because the renderer reads it per-environment.
|
||||
#
|
||||
# *** CHANGING THIS VALUE IS NOT A PLAIN RECONCILE. *** A bound PersistentVolume's spec.nfs is
|
||||
# immutable — measured by server dry-run 2026-10-07:
|
||||
# spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is immutable after creation
|
||||
# so Flux's apply FAILS rather than updating, and the pods keep their old mount. The PVs must be
|
||||
# deleted and recreated; the operator sequence is docs/BULK-NFS-STORAGE.md §10.
|
||||
BULK_NFS_SERVER: "192.168.2.30"
|
||||
|
||||
# Off-cluster S3 backup target (R-STATE-03, R-IDP-05) — the on-Proxmox MinIO, plain HTTP, path-style.
|
||||
# Consumed by BOTH backup paths: every CNPG Cluster's barmanObjectStore.endpointURL (keycloak-db,
|
||||
|
||||
@@ -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
|
||||
@@ -71,4 +71,32 @@ resources:
|
||||
# controller must NEVER gate the platform chain; same non-gating rationale as headlamp/oauth2-proxy.
|
||||
# Carries velero-cloud.sops.yaml, so clusters/dev adds a SOPS decryption patch for it (by name).
|
||||
- backup.yaml # Flux Kustomization -> ./platform/velero (dependsOn platform-config)
|
||||
# WireGuard remote-access VPN (north-south INGRESS, udp/51820) — POST-platform tier
|
||||
# (dependsOn platform-config, wait:false). NOT the same thing as platform/vpn-egress, which sends
|
||||
# pods OUT through PIA; this lets remote CLIENTS IN. Non-gating is load-bearing rather than
|
||||
# conventional here: its DaemonSet cannot start until the operator creates the out-of-band
|
||||
# `wg-server-key` Secret, so in the gating `platform` tier an unfinished manual step would have
|
||||
# stalled every apps wave. Carries no SOPS secret, so neither overlay adds a decryption patch.
|
||||
- vpn-ingress.yaml # Flux Kustomization -> ./platform/vpn-ingress (dependsOn platform-config)
|
||||
# PM agent — reads Gitea issues and runs one worker agent per assignment that authors a
|
||||
# change, gates it and opens a pull request. POST-APP tier (dependsOn apps-wave-0,
|
||||
# wait:false): what it needs is GITEA, not Keycloak or the Gateway, and it must never gate
|
||||
# anything — it CrashLoops by design while its credentials are still placeholders. Carries
|
||||
# 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)
|
||||
@@ -0,0 +1,57 @@
|
||||
---
|
||||
# Flux Kustomization: the PM agent service — reconciles ./platform/pm-agent.
|
||||
#
|
||||
# POST-APP TIER (dependsOn apps-wave-0). What this service actually depends on is GITEA: the
|
||||
# dispatcher authenticates to it at startup, reads issue threads from it, and every worker
|
||||
# clones and pushes to it. Gitea is in apps-wave-0, so that is the dependency — not
|
||||
# platform-config, which brings up the Keycloak realm, the wildcard certificate, the Gateway
|
||||
# and the L2 pool. This service has no UI, serves no traffic, takes no ingress and holds no
|
||||
# certificate, so none of those are prerequisites and claiming them would be a lie that
|
||||
# delays its reconcile behind four things it does not use.
|
||||
#
|
||||
# NON-GATING (wait:false), and here that is not merely prudent — the pod is EXPECTED to be
|
||||
# unhealthy for a while:
|
||||
# * the committed credentials are PLACEHOLDERS, so until the operator replaces them the
|
||||
# dispatcher fails its Gitea authentication at startup and CrashLoops, by design
|
||||
# (platform/pm-agent/pm-agent-credentials.sops.yaml.example);
|
||||
# * the image digest is likewise a placeholder until the operator pushes the image, so
|
||||
# before that the pod cannot even pull.
|
||||
# Either of those inside a wait:true tier would stall everything downstream of it. wait:false
|
||||
# guarantees this service's health can NEVER gate anything, even if some future resource is
|
||||
# mistakenly pointed dependsOn it. Same non-gating leaf rationale as
|
||||
# headlamp/homepage/crafty/backup — with a stronger reason than any of them.
|
||||
#
|
||||
# PRUNE: true. The whole namespace is disposable — the dispatcher keeps no durable state (the
|
||||
# Gitea issue thread is its state) and worker Jobs are ephemeral by construction. So removing
|
||||
# this service from git really should remove it from the cluster, with nothing to orphan.
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: pm-agent
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
prune: true
|
||||
wait: false
|
||||
dependsOn:
|
||||
- name: apps-wave-0 # Gitea: the repository this service reads issues from and pushes to
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./platform/pm-agent
|
||||
# cluster-vars is substituted for parity with every other tier. NOTE for anyone editing
|
||||
# platform/pm-agent/: envsubst runs over the WHOLE rendered manifest, comments included, so
|
||||
# a `${...}` meant for a shell would be replaced with the empty string before the container
|
||||
# starts (the hazard catalog/README.md documents at length — it silently killed gitea's and
|
||||
# nextcloud's OIDC registration for days). Nothing under platform/pm-agent/ uses a `${}`
|
||||
# token today, and the two scripts that DO contain shell live in the image, not in a
|
||||
# manifest, which is one more reason they are baked in rather than mounted from a ConfigMap.
|
||||
postBuild:
|
||||
substituteFrom:
|
||||
- kind: ConfigMap
|
||||
name: cluster-vars
|
||||
# SOPS decryption for pm-agent-credentials.sops.yaml is layered per-environment by the
|
||||
# overlay (clusters/prod and clusters/dev patch THIS Kustomization by name), identical to
|
||||
# crafty/headlamp/oauth2-proxy/backup.
|
||||
@@ -0,0 +1,59 @@
|
||||
---
|
||||
# Flux Kustomization: WireGuard remote-access VPN — reconciles ./platform/vpn-ingress.
|
||||
#
|
||||
# POST-PLATFORM TIER (dependsOn platform-config), NON-GATING (wait:false). Not a line in
|
||||
# platform/kustomization.yaml, for the standard reason plus one specific to this component:
|
||||
#
|
||||
# * STANDARD: a leaf must never sit in the gating `platform` tier. While `platform` is not-Ready
|
||||
# NOTHING downstream reconciles — the incident behind the headlamp/oauth2-proxy/homepage moves
|
||||
# (DECISIONS-LOG: "Headlamp gating stall").
|
||||
# * SPECIFIC, AND THE REASON THIS IS NOT A JUDGEMENT CALL: this DaemonSet cannot start until the
|
||||
# operator replaces the `REPLACE-ME` placeholder in platform/vpn-ingress/wg-server-key.sops.yaml
|
||||
# with a real key (runbooks/vpn-remote-access.md step 2). That is the correct fail-closed state — an unenrolled VPN must not come up — but in
|
||||
# the gating tier it would have been a self-inflicted platform-wide outage ON MERGE, with the
|
||||
# cause (a missing manual step) invisible from any Flux condition.
|
||||
#
|
||||
# WHY dependsOn platform-config AND NOT cilium. This component needs no Gateway, no TLS cert, no
|
||||
# Keycloak and no LB-IPAM pool — it has no HTTPRoute and no LoadBalancer Service, and hostNetwork
|
||||
# means it does not even need a Cilium endpoint. It could legally reconcile much earlier. It is
|
||||
# pinned behind platform-config anyway so that the ONE thing remote clients actually consume
|
||||
# through it — split-horizon DNS and the shared Gateway — exists by the time the tunnel does.
|
||||
# A tunnel that comes up before the things on the far side of it is a support call, not a feature.
|
||||
#
|
||||
# wait:false: nothing in the graph dependsOn this, and its pods are legitimately not-Ready whenever
|
||||
# the server key is absent or the `wireguard` kernel module has not been loaded on a node. Neither
|
||||
# condition should be able to gate anything else, even if a future resource is mistakenly pointed
|
||||
# at it.
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: vpn-ingress
|
||||
namespace: flux-system
|
||||
spec:
|
||||
interval: 10m
|
||||
retryInterval: 2m
|
||||
timeout: 5m
|
||||
prune: true
|
||||
wait: false
|
||||
dependsOn:
|
||||
- name: platform-config
|
||||
sourceRef:
|
||||
kind: GitRepository
|
||||
name: flux-system
|
||||
path: ./platform/vpn-ingress
|
||||
# REQUIRED, not boilerplate: platform/vpn-ingress/daemonset.yaml renders ${WG_CLIENT_CIDR} into
|
||||
# the config-assembly script. Without this block that token becomes the EMPTY STRING and the
|
||||
# Kustomization still reports Ready (Flux's substitution fails OPEN — ci:
|
||||
# flux-postbuild-substitution), leaving an initContainer that derives a server address of ".1/"
|
||||
# and a MASQUERADE rule with no source. Both fail loudly at pod start, but the Kustomization
|
||||
# would look healthy.
|
||||
postBuild:
|
||||
substituteFrom:
|
||||
- kind: ConfigMap
|
||||
name: cluster-vars
|
||||
# SOPS decryption is layered per-environment by the overlay (clusters/dev and clusters/prod each
|
||||
# patch THIS Kustomization by name), identical to headlamp/crafty/velero — this path carries
|
||||
# platform/vpn-ingress/wg-server-key.sops.yaml, the WireGuard server private key. The `sops-age`
|
||||
# Secret is created out of band and referenced by NAME only. Without the overlay patch the Secret
|
||||
# applies cleanly full of ENC[...] and only the tunnel is broken, so the patch is as load-bearing
|
||||
# as the encrypted file itself.
|
||||
@@ -122,6 +122,43 @@ patches:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
# vpn-ingress (the WireGuard remote-access VPN, clusters/base/vpn-ingress.yaml) carries
|
||||
# platform/vpn-ingress/wg-server-key.sops.yaml — the WireGuard SERVER private key, the one
|
||||
# secret in that component. Without this stanza kustomize hands Flux the raw ciphertext and the
|
||||
# Secret applies full of ENC[...]: the APPLY SUCCEEDS and every Flux condition stays green, and
|
||||
# the only symptom is a VPN that no client can handshake with. The DaemonSet's init container
|
||||
# validates the value with `wg pubkey` specifically so that state is a named CrashLoop instead.
|
||||
# The `sops-age` Secret is out-of-band (name only).
|
||||
- patch: |-
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: vpn-ingress
|
||||
namespace: flux-system
|
||||
spec:
|
||||
decryption:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
|
||||
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
|
||||
# account and the Anthropic subscription token from `claude setup-token`. Without this
|
||||
# stanza kustomize hands Flux the raw SOPS document and the Secret applies full of
|
||||
# ciphertext: the APPLY SUCCEEDS, and the only symptom is a dispatcher that cannot
|
||||
# authenticate to Gitea — so this stanza is as load-bearing as the file it decrypts (same
|
||||
# silent-failure shape as the apps-vms note below/above). The `sops-age` Secret is created
|
||||
# out-of-band (name only).
|
||||
- patch: |-
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: pm-agent
|
||||
namespace: flux-system
|
||||
spec:
|
||||
decryption:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
# apps-vms (the KubeVirt VM tier, clusters/base/apps-vms.yaml) gained its FIRST encrypted Secret
|
||||
# on 2026-09-14: apps/vms/unifi/unifi-uos-installer.sops.yaml, the operator's UOS installer URL,
|
||||
# which 30-virtualmachine.yaml mounts into the guest as a disk. Without this stanza kustomize
|
||||
@@ -242,15 +279,15 @@ patches:
|
||||
- op: replace
|
||||
path: /data/CRAFTY_L4_VIP
|
||||
value: "192.168.1.55"
|
||||
# Bulk (external NFS) storage host: the Proxmox host itself, on the SAME LAN as dev's nodes
|
||||
# (192.168.1.40/.41/.42), which already hold an NFS export from it (/mnt/Storage/k8s-volumes).
|
||||
# Restated verbatim rather than inherited, per this overlay's "supply every knob" convention.
|
||||
# Dev is therefore the environment in which the bulk manifests can actually be VALIDATED
|
||||
# end-to-end before prod's network path exists — provided the additive `zfs set sharenfs=`
|
||||
# change described in docs/BULK-NFS-STORAGE.md is applied for the dev node addresses.
|
||||
# Bulk (external NFS) storage host. THIS VALUE IS INERT — dev is decommissioned, its nodes
|
||||
# (192.168.1.40/.41/.42) no longer exist, and the Proxmox host this used to name (192.168.1.9)
|
||||
# went with the pools it held. It is restated per this overlay's "supply every knob"
|
||||
# convention, not because anything consumes it. It carries the real address so that no file
|
||||
# in the tree renders a host that is gone; it has NOT been proven from a dev node, because
|
||||
# there is no dev node to prove it from.
|
||||
- op: replace
|
||||
path: /data/BULK_NFS_SERVER
|
||||
value: "192.168.1.9"
|
||||
value: "192.168.2.30"
|
||||
# Off-cluster backup target: the on-Proxmox MinIO (plain HTTP, path-style) that every CNPG
|
||||
# barmanObjectStore and Velero's backupStorageLocation write to.
|
||||
#
|
||||
|
||||
@@ -113,6 +113,43 @@ patches:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
# vpn-ingress (the WireGuard remote-access VPN, clusters/base/vpn-ingress.yaml) carries
|
||||
# platform/vpn-ingress/wg-server-key.sops.yaml — the WireGuard SERVER private key, the one
|
||||
# secret in that component. Without this stanza kustomize hands Flux the raw ciphertext and the
|
||||
# Secret applies full of ENC[...]: the APPLY SUCCEEDS and every Flux condition stays green, and
|
||||
# the only symptom is a VPN that no client can handshake with. The DaemonSet's init container
|
||||
# validates the value with `wg pubkey` specifically so that state is a named CrashLoop instead.
|
||||
# The `sops-age` Secret is out-of-band (name only).
|
||||
- patch: |-
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: vpn-ingress
|
||||
namespace: flux-system
|
||||
spec:
|
||||
decryption:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
# pm-agent (the Program Manager agent service, clusters/base/pm-agent.yaml) carries
|
||||
# pm-agent-credentials.sops.yaml — the Gitea write token for the dedicated non-admin bot
|
||||
# account and the Anthropic subscription token from `claude setup-token`. Without this
|
||||
# stanza kustomize hands Flux the raw SOPS document and the Secret applies full of
|
||||
# ciphertext: the APPLY SUCCEEDS, and the only symptom is a dispatcher that cannot
|
||||
# authenticate to Gitea — so this stanza is as load-bearing as the file it decrypts (same
|
||||
# silent-failure shape as the apps-vms note below/above). The `sops-age` Secret is created
|
||||
# out-of-band (name only).
|
||||
- patch: |-
|
||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||
kind: Kustomization
|
||||
metadata:
|
||||
name: pm-agent
|
||||
namespace: flux-system
|
||||
spec:
|
||||
decryption:
|
||||
provider: sops
|
||||
secretRef:
|
||||
name: sops-age
|
||||
# apps-vms (the KubeVirt VM tier, clusters/base/apps-vms.yaml) gained its FIRST encrypted Secret
|
||||
# on 2026-09-14: apps/vms/unifi/unifi-uos-installer.sops.yaml, the operator's UOS installer URL,
|
||||
# which 30-virtualmachine.yaml mounts into the guest as a disk. Without this stanza kustomize
|
||||
@@ -476,6 +513,26 @@ patches:
|
||||
- op: replace
|
||||
path: /data/PIHOLE_DNS_VIP
|
||||
value: "192.168.2.23"
|
||||
# Plex's remote-access listener VIP — raw tcp/32400 for REMOTE Plex clients
|
||||
# (apps/wave-0/plex-remote.yaml). 192.168.2.24 is the NEXT FREE address in the prod pool
|
||||
# 192.168.2.20-.30, verified live 2026-10-03: the CiliumLoadBalancerIPPool `default-pool`
|
||||
# reported IPsTotal 11 / IPsUsed 4 / IPsAvailable 7, and the only four allocations are .20
|
||||
# (cilium-gateway-shared), .21 (unifi-l4-tcp/-udp), .22 (crafty-l4-tcp/-udp) and .23
|
||||
# (pihole-dns-tcp/-udp). It is inside the pool block the operator already asserted sits outside
|
||||
# the SOHO router's DHCP scope, and it is not a node address (.10/.11/.12) or the
|
||||
# control-plane VIP (.9).
|
||||
#
|
||||
# UNLIKE .23, THIS ONE IS ALSO A WAN PORT-FORWARD TARGET. The router forwards WAN tcp/32400
|
||||
# here, by hand, off-repo — so changing this value is a two-place edit and the router is the
|
||||
# half that CI cannot check. runbooks/plex-remote-access.md is the procedure.
|
||||
#
|
||||
# NOT ARP-PROBED. .23 was verified free with `arping` from a LAN host before it was claimed;
|
||||
# for .24 only the in-cluster pool accounting above was checked, so an off-cluster squatter on
|
||||
# this address would not have been caught. Probe before trusting it if remote access comes up
|
||||
# with a conflict.
|
||||
- op: replace
|
||||
path: /data/PLEX_L4_VIP
|
||||
value: "192.168.2.24"
|
||||
# The prod flat LAN. This is the 192.168.2.0/24 segment the SOHO ROUTER at 192.168.2.1 owns —
|
||||
# NOT pfSense's 192.168.1.0/24, which is a different network and not in the LAN-DNS path.
|
||||
# It scopes the ingress allow for PiHole's DNS listener to LAN clients only.
|
||||
@@ -483,60 +540,48 @@ patches:
|
||||
path: /data/LAN_CIDR
|
||||
value: "192.168.2.0/24"
|
||||
# ------------------------------------------------------------------------------------------
|
||||
# BULK (EXTERNAL NFS) STORAGE HOST — *** NOT YET DECIDED. THIS PLACEHOLDER IS LOAD-BEARING. ***
|
||||
# BULK (EXTERNAL NFS) STORAGE HOST — the TrueNAS at 192.168.2.30.
|
||||
# ------------------------------------------------------------------------------------------
|
||||
# Backs every `class: bulk` volume: the 8.33 T media library (Storage/plex — plex, sonarr,
|
||||
# radarr) and the 270 G legacy Nextcloud archive (Storage/nextcloud).
|
||||
# radarr, qbittorrent) and the 270 G legacy Nextcloud archive (Storage/nextcloud).
|
||||
#
|
||||
# THE STORAGE HOST IS NOT REACHABLE FROM PROD AT ALL RIGHT NOW. It has one interface,
|
||||
# vmbr0 192.168.1.9/24, on pfSense's LAN; prod's nodes are on 192.168.2.0/24, pfSense's WAN.
|
||||
# 22 and 8006 answer from prod only because pfSense port-forwards them via 192.168.2.102;
|
||||
# 2049 and 111 do not. Opening that path is an OPERATOR decision, and the two candidate
|
||||
# outcomes give DIFFERENT values here:
|
||||
# port-forward 192.168.2.102:2049 -> 192.168.1.9:2049 => "192.168.2.102"
|
||||
# a WAN->LAN route to 192.168.1.0/24 => "192.168.1.9"
|
||||
# Guessing either one authors a prod cluster that mounts nothing, or — worse, if the guess
|
||||
# ever became reachable — mounts something unintended. So it stays a placeholder.
|
||||
# THIS WAS THE REPO'S ONE LOAD-BEARING PLACEHOLDER, AND THE REASON IT COULD BE IS NOW GONE.
|
||||
# The datasets lived on a Proxmox host with a single interface on pfSense's LAN
|
||||
# (192.168.1.9), which prod could reach only through a DNAT at 192.168.2.102 — so the correct
|
||||
# value here depended on an undecided operator choice, and guessing it would have authored a
|
||||
# cluster that mounts nothing. Both pools were physically moved onto a standalone TrueNAS at
|
||||
# 192.168.2.30, prod's OWN /24. No crossing to arrange, no candidates to choose between.
|
||||
# Measured 2026-10-07: 192.168.2.102:2049 no longer answers; 192.168.2.30:2049 and :111 do.
|
||||
#
|
||||
# WHICH PORTS THE DECISION MUST OPEN: exactly ONE, tcp/2049. The PVs mount nfsvers=4.1, and
|
||||
# NFSv4 carries mount and lock in-protocol — no rpcbind/111, no mountd, no statd. Choosing v4
|
||||
# over v3 was in part this decision's cost: v3 would have needed four ports and three of them
|
||||
# on ephemeral numbers.
|
||||
#
|
||||
# WHAT THE EXPORT MUST ALLOW IS ALREADY SETTLED, and it is the same under BOTH outcomes.
|
||||
# Measured 2026-09-07 from this workstation (192.168.2.132) through the existing port-forward:
|
||||
# the storage host logged the connection as coming FROM 192.168.2.132, not from a pfSense LAN
|
||||
# address (`ss -tn` peer 192.168.2.132, and the host's only route off-LAN is its default via
|
||||
# 192.168.1.1). pfSense DNATs WITHOUT source NAT, so the NFS server will see prod's REAL node
|
||||
# addresses either way. The additive `zfs set sharenfs=` values are in docs/BULK-NFS-STORAGE.md.
|
||||
#
|
||||
# *** BLAST RADIUS — READ BEFORE MERGING THIS TO A BRANCH PROD RECONCILES. ***
|
||||
# A PersistentVolume's spec.nfs.server is an unvalidated string: the API server ACCEPTS this
|
||||
# placeholder, the PV is created and the PVC BINDS to it (binding is pure control-plane). The
|
||||
# failure surfaces only when a pod mounts — MountVolume.SetUp "Failed to resolve server
|
||||
# TBD-PROD-BULK-NFS-SERVER" — so plex, sonarr, radarr and nextcloud would sit in
|
||||
# ContainerCreating on prod. Unlike the LAN knobs above, this placeholder does NOT fail at
|
||||
# apply time and there is no CRD validation to lean on. Set this value BEFORE prod reconciles
|
||||
# a revision containing the bulk volumes; the dev overlay renders a working value, which is
|
||||
# where these manifests are meant to be proven first.
|
||||
# ONE PORT IS STILL ALL IT NEEDS: tcp/2049. The PVs mount nfsvers=4.1 and NFSv4 carries mount
|
||||
# and lock in-protocol — no rpcbind/111, no mountd, no statd. The NAS advertises
|
||||
# `+3 +4 +4.1 +4.2` (/proc/fs/nfsd/versions, measured), so 4.1 negotiates.
|
||||
- op: replace
|
||||
path: /data/BULK_NFS_SERVER
|
||||
# 192.168.2.102 is pfSense's WAN address, which DNATs tcp/2049 through to the Proxmox
|
||||
# host's REAL address 192.168.1.9 on pfSense's LAN. Prod sits on the WAN side and cannot
|
||||
# reach 192.168.1.9 directly, so the cluster mounts the NAT address. Dev is on the LAN and
|
||||
# keeps the base value 192.168.1.9.
|
||||
# VERIFIED ON THE NAS 2026-10-07, before any cluster touched it (`exportfs -v`):
|
||||
# /mnt/Storage/plex 192.168.2.10/.11/.12 rw,sec=sys,secure,root_squash,no_all_squash
|
||||
# /mnt/Storage/nextcloud 192.168.2.10/.11/.12 ro,sec=sys,secure,root_squash,no_all_squash
|
||||
#
|
||||
# PROVEN from k3s-prod-1 on 2026-09-12, not inferred:
|
||||
# - mount of 192.168.2.102:/mnt/Storage/plex negotiated nfs4 vers=4.1 proto=tcp
|
||||
# - a write as uid 1000 succeeded. Tested as 1000 and NOT root deliberately: the export
|
||||
# carries no_root_squash, so a root write would have passed and proved nothing, while
|
||||
# LSIO images default to PUID 911 and would have gone Ready then failed every import
|
||||
# with EACCES. The tree is 0755 1000:3000, so uid 1000 is the only writer.
|
||||
# - ln() across downloads/ -> tv/ returned link count 2 (same inode), so the arrs get
|
||||
# real atomic moves instead of silently degrading to copy-then-delete on EXDEV.
|
||||
# pfSense DNATs WITHOUT source NAT (the host logs prod's own node addresses as the peer),
|
||||
# so the ZFS export ACL names 192.168.2.10/.11/.12 directly rather than one translated IP.
|
||||
value: "192.168.2.102"
|
||||
# no_all_squash is the flag the entire permission model rests on: client uids pass through
|
||||
# untouched, so the arrs' PUID=1000 arrives as the owner of a 0755 1000:3000 tree. sec=sys
|
||||
# keeps that numeric rather than principal-mapped. The export deliberately does NOT carry
|
||||
# no_root_squash, which the Proxmox one did: nothing here needs it, because every write to
|
||||
# the tree happens after the LSIO drop to uid 1000 and the images only chown their Longhorn
|
||||
# /config. Both subPath directories (plex/downloads, nextcloud/nextcloud) already exist at
|
||||
# 0755 1000:3000, so a squashed root can still traverse them for subPath resolution.
|
||||
#
|
||||
# The ro= posture on the archive is now SERVER-enforced, which the old export also managed.
|
||||
# The media tree's read-only half (plex) stays a CLIENT-side guarantee — all four media pods
|
||||
# can land on any node, so the server sees the same three addresses for reader and writers
|
||||
# alike and cannot tell them apart. Unchanged by the move; see docs/BULK-NFS-STORAGE.md §5.
|
||||
#
|
||||
# *** CHANGING THIS VALUE REQUIRES DELETING AND RECREATING THE PVs. *** A bound PV's
|
||||
# spec.nfs is immutable — measured by server dry-run 2026-10-07:
|
||||
# spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is immutable after creation
|
||||
# so Flux's apply FAILS rather than updating and the pods keep their old, dead mount. The
|
||||
# operator sequence is docs/BULK-NFS-STORAGE.md §10. Retain + the renderer's pre-set
|
||||
# claimRef make it safe: no data is touched and each recreated PV rebinds its own PVC.
|
||||
value: "192.168.2.30"
|
||||
# ==========================================================================================
|
||||
# OFF-CLUSTER DR TARGET — Cloudflare R2 (operator, 2026-09-05; bucket + token 2026-09-07).
|
||||
# ==========================================================================================
|
||||
|
||||
@@ -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,134 @@
|
||||
# =============================================================================
|
||||
# L0 STATIC — `--api-route` is PER APP, ANCHORED, and DECLARED
|
||||
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||
#
|
||||
# THE DEFECT THIS CLOSES. oauth2-proxy answers an unauthenticated request with a 302 to
|
||||
# Keycloak. Its built-in AJAX guard would send a 401 instead, but it fires only on an EXACT
|
||||
# `Accept: application/json`, which these apps' JavaScript does not consistently send. So a
|
||||
# browser XHR gets a 302, FOLLOWS IT INVISIBLY, has it satisfied silently by the live Keycloak
|
||||
# SSO session, and resolves 200 carrying the app's HTML where the JavaScript expected JSON — no
|
||||
# prompt, no console error, nothing logged as a failure. On a WRITE it is worse: a browser
|
||||
# following a 302 re-issues a POST as a GET and DROPS THE BODY, so the operator's change is
|
||||
# discarded and the UI reports success.
|
||||
#
|
||||
# MEASURED, twice, on two different apps and two different paths:
|
||||
# jackett 2026-09-30 — on /api/ paths: 33 x 302 against 2 x 401, and 4 of 6 POSTs answered BY
|
||||
# THE PROXY, never reaching Jackett (access-log upstream field `-`, not `/`). That is
|
||||
# the operator's whole report: "GET works, most PUT/POST to add indexers fail,
|
||||
# reloading never fixes it, and I am never re-prompted for login".
|
||||
# radarr 2026-10-01, 23h after the cookie fix rolled (pod radarr-7d55cbffb9-42hmh, 0
|
||||
# restarts) — 232 unauthenticated requests in the pod's whole lifetime and ALL 232 are
|
||||
# the same call: `POST /signalr/messages/negotiate` from a browser UA, 302, upstream
|
||||
# `-`. A left-open tab whose session the cookie rename orphaned, polling SignalR
|
||||
# forever, silently redirected every time, NEVER re-prompting. The reported symptom,
|
||||
# still running in production, on a service nobody was watching.
|
||||
#
|
||||
# WHY IT CANNOT BE A RENDERER CONSTANT — AND WHY THAT NEEDS A GATE. The path shape belongs to
|
||||
# the APP, not to the forward-auth pattern: radarr's broken path is `/signalr/`, jackett's is
|
||||
# `/api/`, and PIHOLE SERVES NO `/api/` AT ALL. One function renders all five sidecars
|
||||
# (catalog/render.py::_oauth2_sidecar_container), so the cheap fix is a constant — and a
|
||||
# constant applies one app's measurement to four apps nobody measured. The gate that already
|
||||
# exists cannot see that mistake: `ci/render-golden.sh` compares the render to the goldens, and
|
||||
# a constant plus regenerated goldens AGREE WITH EACH OTHER while both being wrong. Hence
|
||||
# assertion (2) below, which compares the rendered flag against each service's OWN
|
||||
# ServiceDefinition rather than against the renderer's output.
|
||||
#
|
||||
# WHY BREADTH IS THE DANGEROUS DIRECTION, AND IT IS NOT SYMMETRIC. A MISSING --api-route is a
|
||||
# silent 302 — bad, invisible, recoverable. A TOO BROAD one answers a plain PAGE LOAD with 401:
|
||||
# a logged-out operator gets a bare error page and is NEVER OFFERED A SIGN-IN, so the service
|
||||
# looks permanently broken and no reload recovers it. That is a worse outcome than the defect
|
||||
# the flag fixes, which is why the anchoring rule is asserted on three independent surfaces
|
||||
# (schema items.pattern, the renderer, and this check) and why assertion (5) fails if they drift
|
||||
# apart — if they do, the loosest one wins silently.
|
||||
#
|
||||
# GIVEN every oauth2-proxy sidecar this repo DEPLOYS (a container in any manifest at any depth:
|
||||
# the rendered sidecars in apps/, crafty's hand-authored one in platform/), the
|
||||
# ServiceDefinitions in catalog/ that generate five of them, and the renderer itself,
|
||||
# WHEN each sidecar's `--api-route` values are collected (the flag is REPEATABLE, so all of
|
||||
# them, in order) and each onboarded forward-auth service is paired with its golden,
|
||||
# THEN (1) every rendered --api-route is anchored at `^/` and continues with literal path
|
||||
# characters — `^/`, `^/.*`, `.*`, an unanchored `/api/` and any alternation FAIL;
|
||||
# (2) every onboarded forward-auth service's rendered route list EQUALS its own
|
||||
# ServiceDefinition's `auth.apiRoutes`, in order — which fails both a hand-edited
|
||||
# golden and a renderer constant, neither of which render-golden can see;
|
||||
# (3) BOTH cases are live in this tree: at least one service declares routes and at least
|
||||
# one declares none and renders none (the pihole case), so neither path is an
|
||||
# unexercised seam;
|
||||
# (4) `_oauth2_sidecar_container()` reads `auth.apiRoutes` and contains NO literal path
|
||||
# regex — a constant is the defect, back in the one place that reintroduces it
|
||||
# fleet-wide in a single edit;
|
||||
# (5) the anchoring pattern in the renderer and in the schema are the SAME pattern this
|
||||
# check enforces;
|
||||
# (6) the SHIPPED renderer, run as a subprocess on synthetic ServiceDefinitions, emits
|
||||
# exactly the declared routes in order, emits NOTHING for a service declaring none or
|
||||
# an empty list, and REFUSES all five breadth shapes.
|
||||
#
|
||||
# WHY ALSO A SCHEMA PATTERN, GIVEN THIS CHECK. The schema is the only surface that rejects a
|
||||
# too-broad regex WHERE A HUMAN TYPES IT — `ci/l0.sh` validates `catalog/*.yaml` against it on
|
||||
# every gate run, so the operator is told at the authoring surface rather than finding out from
|
||||
# a 401 on a page load. The renderer's own guard is there because `render-golden` runs even on a
|
||||
# gate invocation where the schema stage skipped. This check is there because neither of those
|
||||
# can see a renderer constant. Three surfaces, three different blind spots.
|
||||
#
|
||||
# WHY THIS IS A SIBLING AND NOT PART OF oauth2-proxy-cookie-names-distinct. That check's subject
|
||||
# is cookie IDENTITY: its inputs are names and domains, its failure mode is mutual session
|
||||
# eviction, its register is the shared proxy's claim on the default name. `--api-route` shares
|
||||
# none of those. Bundling them would make that check's name a lie and couple two unrelated
|
||||
# ratchets, so a future api-route decision could only be expressed by weakening a COOKIE check —
|
||||
# the same discipline that check's own header argues for, applied to this change.
|
||||
#
|
||||
# WHAT THIS DOES NOT ESTABLISH — read with the handoff entry, not instead of it:
|
||||
# - That a 401 FIXES anything in a browser. No browser round-trip has been made, by anyone,
|
||||
# for this flag. The 302/401 split, the dropped POST bodies and the orphaned SignalR poll
|
||||
# are all read from access logs; the repair is reasoned from HTTP semantics.
|
||||
# - That the regexes are RIGHT for these two apps beyond the measured paths. `^/api/` on
|
||||
# radarr is justified by a measurement of radarr taken on its PRE-FIX pod by another
|
||||
# engineer (decisions/2026-09-30-jackett-oauth2-proxy-session.md: 62 x /api/v3/command,
|
||||
# 46 x /api/v3/queue/details); the current pod has logged zero /api/ traffic.
|
||||
# - Anything about sonarr, qbittorrent or pihole. Their logs were read (2026-10-01) and
|
||||
# contain ZERO unauthenticated requests — no traffic, which is not the same as no need.
|
||||
# Assertion (3) therefore proves the "declares none" PATH works; it does not claim those
|
||||
# three apps are correctly configured.
|
||||
# - That the LIVE pods carry what the tree says. git is not the cluster (CLAUDE.md §2b).
|
||||
# - It does not read ../home-network-k3s. The spec is read-only (R-TEST-06).
|
||||
# =============================================================================
|
||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||
kind: Test
|
||||
metadata:
|
||||
name: oauth2-proxy-api-routes-per-app
|
||||
labels:
|
||||
conformance.home.k3s/R-IDP-02: "true"
|
||||
annotations:
|
||||
conformance.home.k3s/requirements: "R-IDP-02"
|
||||
conformance.home.k3s/layer: "L0"
|
||||
# WHOLE-REPO SCOPE — the subject spans apps/wave-0, apps/wave-1, platform/crafty, every
|
||||
# ServiceDefinition in catalog/, the renderer that generates five of the sidecars, and the
|
||||
# ServiceDefinition schema. No single requirement path-maps to that set, and the renderer
|
||||
# maps to no identity requirement at all, so a requirement-scoped selection would skip this
|
||||
# check on precisely the change that can reintroduce the defect fleet-wide in one edit.
|
||||
conformance.home.k3s/scope: "whole-repo"
|
||||
conformance.home.k3s/safety-critical: "false"
|
||||
conformance.home.k3s/executable: "true"
|
||||
conformance.home.k3s/intent: >-
|
||||
An unauthenticated XHR is answered 401, not 302, on exactly the paths each app was
|
||||
MEASURED to serve as a machine API: every rendered --api-route is anchored at ^/ and
|
||||
continues with literal path characters, every onboarded forward-auth service's rendered
|
||||
routes equal its own auth.apiRoutes in order (so neither a hand-edited golden nor a
|
||||
renderer constant can pass), an app declaring none renders none, the anchoring pattern is
|
||||
the same in the schema and the renderer, and the shipped renderer is proven on synthetic
|
||||
ServiceDefinitions in both directions — including refusing the five breadth shapes that
|
||||
would answer a page load with 401.
|
||||
spec:
|
||||
steps:
|
||||
- name: assert-api-route-is-per-app-anchored-and-declared
|
||||
description: >-
|
||||
Inventory every oauth2-proxy sidecar's repeatable --api-route values, assert each is an
|
||||
anchored path regex, cross-check each onboarded forward-auth service's rendered routes
|
||||
against its own ServiceDefinition, require both the declares-routes and the
|
||||
declares-none cases to be live in the tree, assert the renderer derives from
|
||||
auth.apiRoutes and hardcodes no path regex, assert the schema and renderer share this
|
||||
check's anchoring pattern, and prove the shipped renderer on synthetic
|
||||
ServiceDefinitions in both directions.
|
||||
try:
|
||||
- script:
|
||||
content: bash ./check.sh
|
||||
@@ -0,0 +1,433 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# L0 STATIC CHECK — `--api-route` is PER APP, ANCHORED, and DECLARED.
|
||||
# See chainsaw-test.yaml's header for WHY this exists and what it measured. In one paragraph:
|
||||
#
|
||||
# oauth2-proxy answers an unauthenticated request with a 302 to Keycloak. A browser XHR follows
|
||||
# that redirect invisibly, the live SSO session satisfies it silently, and the call resolves 200
|
||||
# carrying the app's HTML where its JavaScript expected JSON — and a POST following a 302 is
|
||||
# re-issued as a GET with its BODY DROPPED. `--api-route=<regex>` makes matching paths answer
|
||||
# 401 instead. The flag is right for jackett (`/api/`) and radarr (`/signalr/`, `/api/`) and
|
||||
# WRONG for pihole, which serves no `/api/` at all — so it cannot be a renderer constant, and
|
||||
# the point of this check is that it never becomes one again.
|
||||
#
|
||||
# The breadth direction is the dangerous one and it is NOT symmetric with the bug. A missing
|
||||
# `--api-route` is a silent 302; a TOO BROAD one answers a plain PAGE LOAD with 401, so a
|
||||
# logged-out operator gets a bare error page and is never offered a sign-in — the service looks
|
||||
# permanently broken. That is a worse outcome than the defect, which is why the anchoring rule
|
||||
# is asserted here as well as in the schema.
|
||||
#
|
||||
# Exit 0 = all assertions pass. Exit 1 = a failure. Exit 3 (honest SKIP, which the runner turns
|
||||
# into rc 3 = UNWIRED, never green, because this test declares the whole-repo scope) only if the
|
||||
# tree contains no forward-auth oauth2-proxy sidecar at all — genuinely nothing to audit.
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||
PYTHON="${PYTHON:-python3}"
|
||||
|
||||
# Honest SKIP (never green for a scope-declaring test) if the forward-auth pattern has left the
|
||||
# repo. Anchored on `image:` for the same reason as the sibling cookie check: a bare
|
||||
# `oauth2-proxy/oauth2-proxy:` also matches platform/oauth2-proxy/forward-auth-cec.yaml's Envoy
|
||||
# cluster reference (`serviceName: oauth2-proxy/oauth2-proxy:80`), which runs no proxy.
|
||||
if ! grep -rlE 'image:[[:space:]]*[^[:space:]]*oauth2-proxy' \
|
||||
"${ROOT}/apps" "${ROOT}/platform" >/dev/null 2>&1; then
|
||||
echo "check SKIPPED: no oauth2-proxy container image found under apps/ or platform/ — the forward-auth sidecar pattern is gone and there is nothing to audit" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
cd "${ROOT}"
|
||||
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||
"""Static audit: --api-route is per-app, anchored, and declared in the ServiceDefinition.
|
||||
|
||||
PyYAML (the same dependency as catalog/render.py and the sibling l0-static checks) rather than
|
||||
line-based parsing: a container's args live in a nested structure, and a line-based reader of
|
||||
one is precisely the approximate parser that silently stops matching.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError: # pragma: no cover
|
||||
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||
file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
ROOT = Path(sys.argv[1])
|
||||
sys.dont_write_bytecode = True # keep __pycache__ out of the tracked tree
|
||||
|
||||
FAILURES: list[str] = []
|
||||
CHECKS = 0
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
global CHECKS
|
||||
CHECKS += 1
|
||||
if not cond:
|
||||
FAILURES.append(msg)
|
||||
|
||||
|
||||
# THE BREADTH RATCHET, stated once and asserted against three independent surfaces below (the
|
||||
# rendered tree, the authored catalog, the shipped renderer). Each entry must be anchored at
|
||||
# `^/` and continue with LITERAL path characters. This rejects:
|
||||
# `^/` and `^/.*` — match every page load, so the UI answers 401 and never signs in
|
||||
# `/api/` — unanchored; oauth2-proxy would match it mid-path
|
||||
# `.*` — everything
|
||||
# `^/api/|^/` — alternation smuggles breadth past a prefix-only reading
|
||||
# Alternation is REFUSED rather than supported because `--api-route` is repeatable: one entry
|
||||
# per route costs nothing and each entry stays readable. Kept byte-identical to
|
||||
# catalog/render.py's OAUTH2_API_ROUTE_PATTERN and to the schema's items.pattern; assertion (5)
|
||||
# below fails if they drift apart.
|
||||
ANCHORED = re.compile(r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$")
|
||||
SCHEMA_PATTERN = r"^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$"
|
||||
|
||||
|
||||
def read(p: Path) -> str:
|
||||
try:
|
||||
return p.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
def is_test_fixture(p: Path) -> bool:
|
||||
"""conformance/tests/**/{golden,fixtures}/** is assertion DATA, not deployed manifests."""
|
||||
parts = p.parts
|
||||
return "conformance" in parts and ("golden" in parts or "fixtures" in parts)
|
||||
|
||||
|
||||
def iter_containers(node):
|
||||
"""Yield every container mapping under any `containers`/`initContainers` list, at any depth.
|
||||
|
||||
Depth-agnostic on purpose: the same container shape appears under a Deployment, a DaemonSet,
|
||||
a CronJob's jobTemplate or a bare Pod, and a reader that hardcoded one path would silently
|
||||
stop seeing an instance the day one moved.
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
for key, val in node.items():
|
||||
if key in ("containers", "initContainers") and isinstance(val, list):
|
||||
for c in val:
|
||||
if isinstance(c, dict):
|
||||
yield c
|
||||
else:
|
||||
yield from iter_containers(val)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
yield from iter_containers(item)
|
||||
|
||||
|
||||
def api_routes_of(args: list) -> list:
|
||||
"""Every `--api-route=<v>` / `--api-route <v>` value, in order. The flag is REPEATABLE, so
|
||||
this must collect ALL of them — a single-value reader would audit the first and wave the
|
||||
rest through, which is the shape of reader bug this file's header warns about."""
|
||||
out = []
|
||||
for i, a in enumerate(args):
|
||||
if not isinstance(a, str):
|
||||
continue
|
||||
if a.startswith("--api-route="):
|
||||
out.append(a.split("=", 1)[1])
|
||||
elif a == "--api-route" and i + 1 < len(args) and isinstance(args[i + 1], str):
|
||||
out.append(args[i + 1])
|
||||
return out
|
||||
|
||||
|
||||
# ============================================================ (0) INVENTORY: THE RENDERED TREE
|
||||
# Every oauth2-proxy container this repo DEPLOYS, keyed by file + owner, with its api-routes.
|
||||
rendered: dict[str, list] = {}
|
||||
for p in sorted(ROOT.rglob("*.yaml")):
|
||||
relparts = p.relative_to(ROOT).parent.parts
|
||||
# NESTED WORKTREES: skip dot-DIRECTORIES (.git, .claude) while still matching dot-FILES such
|
||||
# as .sops.yaml. .claude/worktrees/agent-*/ holds full repo copies for isolated agent runs,
|
||||
# so a bare rglob audits up to 18 duplicates of every file — which both invents FAILs and,
|
||||
# worse, can satisfy a "something declares X" check from a copy rather than from real source.
|
||||
if any(part.startswith(".") for part in relparts) or is_test_fixture(p) or ".sops." in p.name:
|
||||
continue
|
||||
txt = read(p)
|
||||
if "oauth2-proxy" not in txt:
|
||||
continue
|
||||
rel = str(p.relative_to(ROOT))
|
||||
try:
|
||||
docs = [d for d in yaml.safe_load_all(txt) if isinstance(d, dict)]
|
||||
except yaml.YAMLError as exc:
|
||||
check(False, f"{rel} is not parseable YAML ({exc}) — any oauth2-proxy instance in it "
|
||||
f"went unaudited, so a too-broad --api-route could be hiding there")
|
||||
continue
|
||||
for d in docs:
|
||||
for c in iter_containers(d):
|
||||
image = str(c.get("image") or "")
|
||||
name = str(c.get("name") or "")
|
||||
if "oauth2-proxy" not in image and name != "oauth2-proxy":
|
||||
continue
|
||||
owner = (d.get("metadata") or {}).get("name") or "?"
|
||||
args = list(c.get("args") or []) + list(c.get("command") or [])
|
||||
rendered[f"{rel}:{owner}/{name or 'oauth2-proxy'}"] = api_routes_of(args)
|
||||
|
||||
check(len(rendered) >= 5,
|
||||
f"only {len(rendered)} oauth2-proxy container(s) parsed out of the tree. This repo "
|
||||
f"deploys more than that (five rendered sidecars plus crafty's hand-authored one), so the "
|
||||
f"inventory has stopped seeing instances and every assertion below would pass vacuously "
|
||||
f"while an unanchored --api-route went unaudited")
|
||||
|
||||
# ============================================================ (1) EVERY ROUTE IS ANCHORED
|
||||
for key, routes in sorted(rendered.items()):
|
||||
for r in routes:
|
||||
check(bool(ANCHORED.match(r)),
|
||||
f"{key} declares --api-route={r!r}, which is not an anchored path regex.\n"
|
||||
f" WHY THIS IS WORSE THAN THE BUG IT FIXES: --api-route makes matching paths "
|
||||
f"answer 401 instead of 302ing to Keycloak. A regex that also matches a path a "
|
||||
f"BROWSER NAVIGATES to — `^/`, `^/.*`, `.*`, or an alternation that reaches the "
|
||||
f"UI root — answers a plain PAGE LOAD with 401. A logged-out operator then sees a "
|
||||
f"bare error page and is NEVER OFFERED A SIGN-IN: the service looks permanently "
|
||||
f"broken rather than merely unauthenticated, and no reload can recover it.\n"
|
||||
f" FIX: anchor it at '^/' followed by literal path characters "
|
||||
f"(e.g. '^/api/', '^/signalr/', '^/api/v3/.*'), one entry per route — the flag is "
|
||||
f"repeatable, so alternation is never needed. For a service rendered from catalog/, "
|
||||
f"do NOT hand-edit the golden: declare auth.apiRoutes and re-render "
|
||||
f"(ci/render-golden.sh's header has the canonical command)")
|
||||
|
||||
# ============================================================ (2) DERIVED FROM THE CATALOG
|
||||
# The five rendered sidecars are OUTPUTS. Auditing only them would let someone "fix" a golden by
|
||||
# hand (reverted by the next render) or make the renderer emit a CONSTANT and regenerate every
|
||||
# golden to match — which render-golden cannot see, because render and golden would agree.
|
||||
# So: every onboarded forward-auth service's rendered route list must EQUAL its own
|
||||
# ServiceDefinition's auth.apiRoutes, in order.
|
||||
catalog_dir = ROOT / "catalog"
|
||||
declared: dict[str, list] = {}
|
||||
forward_auth: list[str] = []
|
||||
for sd_path in sorted(catalog_dir.glob("*.yaml")):
|
||||
try:
|
||||
sd = yaml.safe_load(read(sd_path))
|
||||
except yaml.YAMLError as exc:
|
||||
check(False, f"catalog/{sd_path.name} is not parseable YAML ({exc})")
|
||||
continue
|
||||
if not isinstance(sd, dict) or not sd.get("name"):
|
||||
continue
|
||||
auth = sd.get("auth") or {}
|
||||
routes = auth.get("apiRoutes") or []
|
||||
declared[sd["name"]] = list(routes)
|
||||
if auth.get("mode") == "forward-auth" and (sd.get("ingressRoutes") or []):
|
||||
forward_auth.append(sd["name"])
|
||||
for r in routes:
|
||||
check(bool(ANCHORED.match(str(r))),
|
||||
f"catalog/{sd_path.name}: auth.apiRoutes entry {r!r} is not anchored at '^/' "
|
||||
f"followed by literal path characters. See assertion (1) for why breadth here is "
|
||||
f"the dangerous direction. The schema rejects this too (items.pattern); this "
|
||||
f"assertion exists because the schema stage can be SKIPPED and this one cannot")
|
||||
if routes:
|
||||
check(auth.get("mode") == "forward-auth",
|
||||
f"catalog/{sd_path.name} declares auth.apiRoutes with auth.mode="
|
||||
f"{auth.get('mode')!r}. --api-route renders onto the per-app oauth2-proxy sidecar, "
|
||||
f"and no sidecar is rendered for oidc/native/none — so this is a SILENT NO-OP: the "
|
||||
f"operator believes the app's XHRs now answer 401 and nothing changed")
|
||||
|
||||
matched = 0
|
||||
for name in sorted(forward_auth):
|
||||
golden = next((p for p in sorted(ROOT.glob(f"apps/wave-*/{name}.yaml"))), None)
|
||||
if golden is None:
|
||||
continue # catalog service not onboarded to apps/ yet — no golden
|
||||
key = next((k for k in rendered if k.startswith(str(golden.relative_to(ROOT)) + ":")), None)
|
||||
check(key is not None,
|
||||
f"apps/{golden.name} is the golden for forward-auth service {name!r} but no "
|
||||
f"oauth2-proxy container was parsed out of it. Its --api-route configuration is "
|
||||
f"UNAUDITED")
|
||||
if key is None:
|
||||
continue
|
||||
matched += 1
|
||||
check(rendered[key] == declared.get(name, []),
|
||||
f"{name}: the rendered sidecar declares --api-route {rendered[key]!r} but "
|
||||
f"catalog/{name}.yaml declares auth.apiRoutes {declared.get(name, [])!r}.\n"
|
||||
f" These must be IDENTICAL and in order. A mismatch means one of two things, "
|
||||
f"both bad: a golden was hand-edited (the next re-render silently reverts it), or the "
|
||||
f"renderer is emitting --api-route from somewhere other than this service's own "
|
||||
f"ServiceDefinition — i.e. a constant or a per-service table. A constant is the defect "
|
||||
f"this field exists to prevent: it applies ONE app's path shape to all five, and "
|
||||
f"pihole serves no /api/ at all, so it would answer pihole's page loads with 401. "
|
||||
f"render-golden cannot catch that, because render and golden would agree with each "
|
||||
f"other while both being wrong")
|
||||
|
||||
check(matched >= 4,
|
||||
f"only {matched} onboarded forward-auth service(s) were cross-checked against their "
|
||||
f"ServiceDefinition. This repo onboards five, so the catalog<->golden pairing has stopped "
|
||||
f"resolving and assertion (2) is passing vacuously")
|
||||
|
||||
# ============================================================ (3) BOTH CASES EXIST
|
||||
# Once the tree is consistent, (2) is satisfied by any pairing — including one where NOTHING
|
||||
# declares a route (vacuous) or one where EVERYTHING does (so the "renders nothing" path is
|
||||
# never exercised). Both halves are asserted to be live, because the per-app field only earns
|
||||
# its complexity if both outcomes actually occur in this tree.
|
||||
with_routes = [n for n in forward_auth if declared.get(n)]
|
||||
without_routes = [n for n in forward_auth if not declared.get(n)]
|
||||
check(bool(with_routes),
|
||||
"no onboarded forward-auth service declares auth.apiRoutes, so assertion (2) cannot "
|
||||
"distinguish a working renderer from one that emits the flag nowhere. If the flag is "
|
||||
"genuinely not needed anywhere, delete the field rather than leaving an unexercised seam")
|
||||
check(bool(without_routes),
|
||||
"EVERY onboarded forward-auth service declares auth.apiRoutes, so nothing in this tree "
|
||||
"exercises the 'declares none => renders no flag' path. That path is the whole reason the "
|
||||
"field is per-app: pihole serves no /api/, and a renderer-wide default would answer its "
|
||||
"page loads with 401")
|
||||
for name in sorted(without_routes):
|
||||
golden = next((p for p in sorted(ROOT.glob(f"apps/wave-*/{name}.yaml"))), None)
|
||||
if golden is None:
|
||||
continue
|
||||
key = next((k for k in rendered if k.startswith(str(golden.relative_to(ROOT)) + ":")), None)
|
||||
if key is None:
|
||||
continue
|
||||
check(rendered[key] == [],
|
||||
f"{name} declares NO auth.apiRoutes but its rendered sidecar carries "
|
||||
f"--api-route {rendered[key]!r}. An app that declares nothing must render NOTHING")
|
||||
|
||||
# ============================================================ (4) THE RENDERER DERIVES
|
||||
render_py = ROOT / "catalog" / "render.py"
|
||||
check(render_py.is_file(),
|
||||
"catalog/render.py is missing — the generator of five of these sidecars cannot be audited")
|
||||
if render_py.is_file():
|
||||
src = read(render_py)
|
||||
m = re.search(r"def _oauth2_sidecar_container\(.*?\n(?=\n*def |\n*# =)", src, re.S)
|
||||
fn = m.group(0) if m else ""
|
||||
check(bool(fn),
|
||||
"could not locate _oauth2_sidecar_container() in catalog/render.py. It was renamed or "
|
||||
"removed, so assertion (4) cannot run and the generator is unaudited")
|
||||
if fn:
|
||||
check("--api-route=" in fn,
|
||||
"catalog/render.py's _oauth2_sidecar_container() emits no --api-route at all, so "
|
||||
"every forward-auth app's XHRs are answered with a 302 the browser follows "
|
||||
"invisibly — measured as 33x302-vs-2x401 on jackett and 232x302 on radarr, with "
|
||||
"POST bodies dropped. auth.apiRoutes would then be a field with no consumer")
|
||||
check(not re.search(r'--api-route=\^/[A-Za-z0-9]', fn),
|
||||
"catalog/render.py hardcodes a literal path regex into --api-route. Whatever path "
|
||||
"that is, EVERY forward-auth app now renders it: one app's path shape applied to "
|
||||
"five. pihole serves no /api/, so a constant answers its page loads with 401 and "
|
||||
"the service looks permanently broken to a logged-out operator. Render the routes "
|
||||
"declared in each service's own auth.apiRoutes instead")
|
||||
check("apiRoutes" in fn,
|
||||
"catalog/render.py's _oauth2_sidecar_container() does not read auth.apiRoutes, so "
|
||||
"the per-app declaration has no effect on what is rendered")
|
||||
|
||||
# ============================================================ (5) ONE PATTERN, THREE SURFACES
|
||||
# The ratchet is written in three places (this check, the renderer, the schema) because each
|
||||
# guards a surface the others cannot. If they drift apart, the loosest one wins silently.
|
||||
if render_py.is_file():
|
||||
# The renderer writes the pattern as a raw string literal, so the source text contains it
|
||||
# verbatim. A substring test is deliberate: it also catches a rename of the constant.
|
||||
check(SCHEMA_PATTERN in read(render_py),
|
||||
f"catalog/render.py no longer contains the anchoring pattern {SCHEMA_PATTERN!r} that "
|
||||
f"this check and the schema enforce. Three surfaces guard --api-route breadth (the "
|
||||
f"authoring surface via the schema, the render via the renderer, the tree via this "
|
||||
f"check); if they drift apart the loosest wins and the ratchet is decorative")
|
||||
|
||||
# The schema's pattern is read from the PARSED JSON, not from the file text: in the file its
|
||||
# backslashes are JSON-escaped, so a substring test against the raw bytes would compare the
|
||||
# wrong string and pass or fail for the wrong reason.
|
||||
schema_file = ROOT / "schemas" / "servicedefinition.schema.json"
|
||||
schema_pat = None
|
||||
if schema_file.is_file():
|
||||
try:
|
||||
schema_pat = (((((json.loads(read(schema_file)).get("properties") or {})
|
||||
.get("auth") or {}).get("properties") or {})
|
||||
.get("apiRoutes") or {}).get("items") or {}).get("pattern")
|
||||
except (ValueError, AttributeError) as exc:
|
||||
check(False, f"schemas/servicedefinition.schema.json could not be read as JSON ({exc}) — "
|
||||
f"the schema stage that rejects a too-broad --api-route cannot be audited")
|
||||
check(schema_pat == SCHEMA_PATTERN,
|
||||
f"schemas/servicedefinition.schema.json constrains auth.apiRoutes items with "
|
||||
f"{schema_pat!r}, not {SCHEMA_PATTERN!r}. The schema is the only surface that rejects a "
|
||||
f"too-broad regex WHERE A HUMAN TYPES IT (ci/l0.sh validates catalog/*.yaml on every gate "
|
||||
f"run); if it is absent or looser than the renderer's, the operator finds out from a 401 "
|
||||
f"on a page load")
|
||||
|
||||
# ============================================================ (6) PROVE THE SHIPPED RENDERER
|
||||
# Once the tree is compliant, (1)-(3) have nothing left to catch and their passing is evidence
|
||||
# of nothing. This runs the SHIPPED renderer as a SUBPROCESS on synthetic ServiceDefinitions and
|
||||
# asserts the behaviour directly, in BOTH directions, so the guard stays falsifiable.
|
||||
def _sd(name: str, port: int, routes) -> str:
|
||||
auth = {"mode": "forward-auth", "groups": ["platform-admins"]}
|
||||
if routes is not None:
|
||||
auth["apiRoutes"] = routes
|
||||
return yaml.safe_dump({
|
||||
"name": name,
|
||||
"kind": "container",
|
||||
"source": {"image": "example/app:1"},
|
||||
"exposure": "internal",
|
||||
"ingressRoutes": [{"host": f"{name}.home.keithjackson.dev", "port": port}],
|
||||
"auth": auth,
|
||||
}, sort_keys=False)
|
||||
|
||||
|
||||
def _render(name: str, port: int, routes):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
f = Path(td) / f"{name}.yaml"
|
||||
f.write_text(_sd(name, port, routes))
|
||||
return subprocess.run([sys.executable, str(render_py), str(f)],
|
||||
capture_output=True, text=True)
|
||||
|
||||
|
||||
def _sidecar_args(rendered_yaml: str) -> list:
|
||||
for doc in yaml.safe_load_all(rendered_yaml):
|
||||
if not isinstance(doc, dict) or doc.get("kind") != "Deployment":
|
||||
continue
|
||||
for c in iter_containers(doc):
|
||||
if c.get("name") == "oauth2-proxy":
|
||||
return list(c.get("args") or [])
|
||||
return []
|
||||
|
||||
|
||||
if render_py.is_file():
|
||||
# (6a) DECLARED ROUTES RENDER, IN ORDER, AND NOTHING ELSE DOES.
|
||||
proc = _render("apirtdecl", 8121, ["^/api/", "^/signalr/"])
|
||||
check(proc.returncode == 0,
|
||||
f"the shipped catalog/render.py failed on a synthetic forward-auth fixture declaring "
|
||||
f"two apiRoutes: rc={proc.returncode} {proc.stderr.strip()[:300]}")
|
||||
if proc.returncode == 0:
|
||||
got = api_routes_of(_sidecar_args(proc.stdout))
|
||||
check(got == ["^/api/", "^/signalr/"],
|
||||
f"the shipped renderer turned auth.apiRoutes ['^/api/', '^/signalr/'] into "
|
||||
f"--api-route {got!r}. It must emit one repeatable --api-route per entry, in "
|
||||
f"declared order. Note the flag is SINGULAR: --api-routes does not exist in "
|
||||
f"oauth2-proxy v7.8.1 and the proxy REFUSES TO START with it, so a plural would "
|
||||
f"crashloop every sidecar it rendered")
|
||||
|
||||
# (6b) AN APP THAT DECLARES NOTHING RENDERS NOTHING. The pihole case, proven on the
|
||||
# generator rather than inferred from one committed golden.
|
||||
for label, routes in (("absent", None), ("empty", [])):
|
||||
proc = _render(f"apirtnone{label}", 8122, routes)
|
||||
check(proc.returncode == 0,
|
||||
f"the shipped renderer failed on a synthetic forward-auth fixture with apiRoutes "
|
||||
f"{label}: rc={proc.returncode} {proc.stderr.strip()[:300]}")
|
||||
if proc.returncode == 0:
|
||||
got = api_routes_of(_sidecar_args(proc.stdout))
|
||||
check(got == [],
|
||||
f"the shipped renderer emitted --api-route {got!r} for a synthetic service "
|
||||
f"whose auth.apiRoutes is {label}. A newly-onboarded forward-auth app must "
|
||||
f"render NO --api-route: pihole serves no /api/, so a default would answer "
|
||||
f"its page loads with 401 and the service would look permanently broken to a "
|
||||
f"logged-out operator — a worse failure than the 302 bug the flag fixes")
|
||||
|
||||
# (6c) THE RENDERER REFUSES BREADTH. Asserted on the generator because render-golden runs
|
||||
# on every gate invocation while the schema stage can be skipped.
|
||||
for bad in ("^/", "^/.*", ".*", "/api/", "^/api/|^/"):
|
||||
proc = _render("apirtbad", 8123, [bad])
|
||||
check(proc.returncode != 0,
|
||||
f"the shipped renderer ACCEPTED auth.apiRoutes {bad!r} and rendered a sidecar. "
|
||||
f"That regex matches paths a browser NAVIGATES to, so a plain page load answers "
|
||||
f"401 and the operator is never offered a sign-in. The renderer must refuse it, "
|
||||
f"so a breadth mistake fails render-golden even on a run where the schema stage "
|
||||
f"did not execute")
|
||||
|
||||
# ============================================================ VERDICT
|
||||
inv = " | ".join(f"{k} -> {v or '(none)'}" for k, v in sorted(rendered.items()))
|
||||
print(f"oauth2-proxy sidecars audited ({len(rendered)}): {inv}")
|
||||
if FAILURES:
|
||||
print(f"\nCHECK FAIL (oauth2-proxy-api-routes-per-app): {len(FAILURES)} failed, "
|
||||
f"{CHECKS - len(FAILURES)} passed", file=sys.stderr)
|
||||
for f in FAILURES:
|
||||
print(f" FAIL {f}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
print(f"check OK: oauth2-proxy-api-routes-per-app — {CHECKS} assertions (R-IDP-02)")
|
||||
PY
|
||||
@@ -129,6 +129,22 @@ KNOWN_EXTERNAL_MONITORS: dict[str, str] = {
|
||||
"a PodMonitor, so it resolves against POD ports, not Service ports — the Flux controller "
|
||||
"Deployments (and their `http-prom` container port) are installed by the Flux bootstrap "
|
||||
"manifests, not authored here. Live on prod: job flux-system/flux-system, 6 targets up.",
|
||||
"platform/oauth2-proxy/podmonitor.yaml:oauth2-proxy-sidecars":
|
||||
"a PodMonitor, so it resolves against POD ports, not Service ports. Unlike the other "
|
||||
"entries here the target is NOT chart-owned — the `metrics` (44180) container port is "
|
||||
"authored in this repo, on the oauth2-proxy sidecar in apps/wave-0/{jackett,qbittorrent,"
|
||||
"pihole}.yaml and apps/wave-1/{radarr,sonarr}.yaml. It is unauditable here because NO "
|
||||
"Service exposes 44180 BY DESIGN: adding one would mean changing catalog/render.py and "
|
||||
"every apps/wave-* render and golden, to publish an endpoint only Prometheus should dial. "
|
||||
"A PodMonitor needs no Service, which is the whole reason it was chosen. What assertion "
|
||||
"(2) would have checked was instead MEASURED on prod 2026-10-01, read-only: the pods "
|
||||
"declare ports [('http', 4180), ('metrics', 44180)], the selector label "
|
||||
"homelab.keithjackson.dev/auth-mode=forward-auth is on exactly those five gate pods, and "
|
||||
"port-forwarding 44180 returns http=200 with 10208 bytes of oauth2_proxy_* metrics. "
|
||||
"CAVEAT, deliberately recorded: those targets are DOWN on prod, because the app "
|
||||
"namespaces' default-deny NetworkPolicy drops the scrape from ns monitoring (same bug and "
|
||||
"fingerprint as platform/longhorn/networkpolicy-metrics.yaml documents). The port name "
|
||||
"resolves; the network path does not yet.",
|
||||
}
|
||||
|
||||
MONITOR_KINDS = ("ServiceMonitor", "PodMonitor")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -171,6 +171,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): paas-demo-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: paas-demo-allow-monitoring-scrape
|
||||
namespace: paas-demo
|
||||
labels:
|
||||
app.kubernetes.io/name: paas-demo
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: paas-demo
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): paas-demo-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
+21
@@ -198,6 +198,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: sonarr-allow-monitoring-scrape
|
||||
namespace: sonarr
|
||||
labels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
@@ -198,6 +198,27 @@ spec:
|
||||
- port: '4180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy monitoring scrape ingress allow (R-NET-02/R-OBS-01): sonarr-allow-monitoring-scrape
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
metadata:
|
||||
name: sonarr-allow-monitoring-scrape
|
||||
namespace: sonarr
|
||||
labels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
spec:
|
||||
endpointSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: sonarr
|
||||
ingress:
|
||||
- fromEndpoints:
|
||||
- matchLabels:
|
||||
k8s:io.kubernetes.pod.namespace: monitoring
|
||||
toPorts:
|
||||
- ports:
|
||||
- port: '44180'
|
||||
protocol: TCP
|
||||
---
|
||||
# CiliumNetworkPolicy world egress allow (egress class governs the path; R-NET-05): sonarr-allow-internet-egress
|
||||
apiVersion: cilium.io/v2
|
||||
kind: CiliumNetworkPolicy
|
||||
|
||||
+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:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
CONFORMANCE_RUNNER_IMAGE="${CONFORMANCE_RUNNER_IMAGE_REPO}@${CONFORMANCE_RUNNER_IMAGE_DIGEST}"
|
||||
#
|
||||
# AFTERWARDS, TIGHTEN THE BOUNDARY: the header of conformance-trigger-job-shape.yaml asks for
|
||||
# its `@sha256:*` wildcard to be replaced with the LITERAL digest once the image exists —
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
---
|
||||
date: 2026-09-30
|
||||
branch: feat/oauth2-proxy-auth-failure-visible
|
||||
title: The Jackett auth failure was invisible because the gates emit nothing — alert on the gap, not on a metric that does not exist
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Jackett's UI broke in the way that costs the most: **HTTP 200 and a plausible page**. The
|
||||
operator's report named the property that matters — *"reloading the page never fixes the issue and
|
||||
the user is never re-prompted for login"*. Two sibling branches fix the auth defects. This one is
|
||||
about the fact that **no signal existed anywhere**, and it turns out that is not an oversight in
|
||||
the alert rules. It is an absence of data.
|
||||
|
||||
**The measurement that decided the whole design** (prod, live Prometheus, 2026-09-30):
|
||||
|
||||
count by (__name__) ({__name__=~"oauth2_proxy.*"})
|
||||
oauth2_proxy_requests_in_flight => 2
|
||||
|
||||
Two series. Both from the **shared** forward-auth Deployment in the `oauth2-proxy` namespace.
|
||||
That is every oauth2-proxy metric this cluster holds. The six per-app gate sidecars — crafty,
|
||||
jackett, pihole, qbittorrent, radarr, sonarr — publish **nothing at all**: they run with
|
||||
`--http-address=0.0.0.0:4180` and no `--metrics-address`, so oauth2-proxy's metrics listener never
|
||||
starts; they declare only a `http:4180` container port; and no ServiceMonitor or PodMonitor in the
|
||||
cluster selects them.
|
||||
|
||||
So the alert the brief asked for — *"a gated app is serving a sustained rate of auth redirects to
|
||||
a user who is authenticated"* — **is not writable**, and writing it anyway would have produced
|
||||
exactly the artefact this repo keeps getting burned by: a rule that is present, evaluating,
|
||||
permanently silent, and indistinguishable from health. It was not written.
|
||||
|
||||
What was written instead alerts on the condition that made the incident invisible, from families
|
||||
that **are** scraped:
|
||||
|
||||
- **`platform/oauth2-proxy/auth-visibility-alerts.yaml`** — `OAuth2ProxyGateUnobserved`:
|
||||
|
||||
count by (namespace) (kube_pod_container_info{container="oauth2-proxy"})
|
||||
unless
|
||||
count by (namespace) (up{container="oauth2-proxy"})
|
||||
|
||||
`for: 1h`, `severity: warning`. `kube_pod_container_info` is kube-state-metrics (a declared
|
||||
owner in `alert-metrics-have-scrape-targets`) and sees the container whether or not it exports
|
||||
anything; `up` is UNIVERSAL and carries a `container` label for any scrape target, so the rule
|
||||
needs no advance knowledge of what job name a future per-app scrape will use. Nothing in it
|
||||
touches `oauth2_proxy_*`, so it cannot be inert for the reason it is reporting. **It returns six
|
||||
namespaces against live prod right now** and excludes `oauth2-proxy`, which is scraped. It
|
||||
clears per-namespace as each gate gains a real scrape.
|
||||
- **`platform/oauth2-proxy/auth-visibility-alerts.promtool-test.yaml`** — four states pinned:
|
||||
fires for an unscraped namespace, never for the scraped one, honours `for: 1h` (pending at 59m),
|
||||
and **stops** once a scrape appears under a *different* job name. Series shapes copied off live
|
||||
prod, not invented.
|
||||
- **`runbooks/oauth2-proxy-auth-failures.md`** — how to tell "the gate refused me" from "the group
|
||||
check refused me" from "the app refused me" from "the XHR silently got HTML", with the
|
||||
2026-09-30 jackett trace as the worked example. Registered in `runbooks/kustomization.yaml`'s
|
||||
`configMapGenerator` and listed in `runbooks/README.md`.
|
||||
- **`runbooks/README.md`** — a new row in *"Alerts you should not trust the silence of"* for the
|
||||
per-app sidecars, plus the measured reason.
|
||||
- **`platform/oauth2-proxy/README.md`** — what this component can and cannot see, and the exact
|
||||
three-part change that would close the gap.
|
||||
|
||||
**Why the obvious alternative was rejected.** An alert on the *shared* proxy's
|
||||
`oauth2_proxy_requests_total{code="302"}` ratio is the one a reader expects to see here. It would
|
||||
need a new `SCRAPE_OWNERS` entry in `conformance/**` (a sensitive path needing human approval),
|
||||
and — decisively — **it would not have fired on this incident**: the shared instance is not in the
|
||||
data path for any sidecar-gated app, and its current pods export no `oauth2_proxy_requests_total`
|
||||
at all, having proxied nothing since they started (the counter exists in the TSDB only from an
|
||||
older ReplicaSet, `oauth2-proxy-78449b5657-*`, with `code` labels `302` and `401` and no `202`).
|
||||
Shipping it would have looked like coverage of the reported failure and been none.
|
||||
|
||||
## What I verified
|
||||
|
||||
Everything below was measured against live prod through the pinned container, read-only. No
|
||||
`apply`, no `edit`, no `patch`, no `flux reconcile`.
|
||||
|
||||
- **The full oauth2-proxy metric inventory.** `count by (__name__) ({__name__=~"oauth2_proxy.*"})`
|
||||
→ `oauth2_proxy_requests_in_flight => 2`. `count by (__name__) ({job="oauth2-proxy"})` returns
|
||||
go_*/process_*/promhttp_* plus that one family and nothing else. **Only the shared instance is
|
||||
scraped**: `up{job="oauth2-proxy"}` → 2 targets, `10.0.1.189:44180` and `10.0.2.47:44180`, both
|
||||
`1`, `namespace="oauth2-proxy"`.
|
||||
- **The per-app sidecars are unscraped, and unscrapable as configured.** Every pod carrying an
|
||||
`oauth2-proxy` container, with its declared ports: the two shared replicas have
|
||||
`[('http',4180),('metrics',44180)]`; crafty, jackett, pihole, qbittorrent, radarr and sonarr all
|
||||
have `[('http',4180)]` only. The live jackett sidecar's args contain no `--metrics-address`.
|
||||
`kubectl get servicemonitors,podmonitors -A` lists 16 ServiceMonitors and 5 PodMonitors; the
|
||||
only oauth2-proxy entry is `oauth2-proxy/oauth2-proxy`.
|
||||
- **No existing alert is inert *for this reason*, because no alert references these metrics at
|
||||
all.** `grep -rn oauth2_proxy --include=*.yaml` over the tree hits only cookie-name strings in
|
||||
`platform/crafty/`. The 39 live PrometheusRules include `platform-health-identity`, whose four
|
||||
rules are about Keycloak readiness and the client reconciler and are all keyed on `kube_*`
|
||||
(scraped, fine). **The inertness here is the absence of a rule, not a broken one** — which is
|
||||
why the runbook README's inert-alerts table gains a row phrased like the etcd one.
|
||||
- **Keycloak cannot express it either.** `count by (__name__) ({__name__=~"keycloak_.*"})` →
|
||||
empty, and empty over `[30d]`. The `keycloak-service` scrape yields JVM/Agroal/JGroups metrics
|
||||
only; there are no user-event or login counters, so "a burst of re-logins for one client" is not
|
||||
observable from the IdP side.
|
||||
- **`oauth2_proxy_requests_total` has existed historically but not on the current pods.**
|
||||
`count_over_time(oauth2_proxy_requests_total[30d])` returns three series, all from
|
||||
`oauth2-proxy-78449b5657-*` (a prior ReplicaSet): `code="302"` on both replicas and `code="401"`
|
||||
on one, ~634 samples each (~5h of a 30d window). **No `code="202"` ever.**
|
||||
- **The alert expression fires against live data.** Run verbatim on prod:
|
||||
`count by (namespace) (kube_pod_container_info{container="oauth2-proxy"}) unless count by
|
||||
(namespace) (up{container="oauth2-proxy"})` → `pihole, jackett, crafty, qbittorrent, sonarr,
|
||||
radarr`, value 1 each; `oauth2-proxy` correctly absent.
|
||||
- **The log signal exists and is collected.** Loki (`loki-0`, SingleBinary) and a 3-node Alloy
|
||||
DaemonSet are running, and Alloy labels pod logs with `namespace`/`container`. LogQL over 24h,
|
||||
`{container="oauth2-proxy"}`: crafty 12036 lines, jackett 11669, oauth2-proxy 34559, pihole
|
||||
11520, qbittorrent 11520, radarr 11763, sonarr 11520. Filtered to `|= "No valid authentication
|
||||
in request"`: **crafty 81, jackett 39, radarr 67**; to `|= "AuthSuccess"`: **crafty 1, jackett
|
||||
4, radarr 1**. Those ratios (81:1, 10:1, 67:1) are the incident's signature and are in the
|
||||
runbook as the thing to compare against.
|
||||
- **Why `kubectl logs` hid it.** The jackett sidecar's stdout is ~11.5k lines/day and almost all
|
||||
of it is `GET "/ping" ... 200 2` from the kubelet probe, twice every 15s. A `--tail=40` shows
|
||||
forty `/ping` lines and looks perfectly healthy. The runbook's commands all filter.
|
||||
- **Runbook registration, both halves.** `- oauth2-proxy-auth-failures=oauth2-proxy-auth-failures.md`
|
||||
is in `runbooks/kustomization.yaml`'s `configMapGenerator.files`, and the page is listed under
|
||||
*Identity* in `runbooks/README.md`. The page clears the L0 quality floor (the four operator
|
||||
sections, >1500 bytes, mentions `dev.sh` and `--context k3s-prod`), and the alert's
|
||||
`runbook_url` points at exactly that name.
|
||||
- **Gate:** run in the pinned toolbox from this worktree with the `ENGINE` shim. Exit code and
|
||||
per-stage outcomes in the next section — read them there, not here.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The alert has never fired in Alertmanager.** Nothing is applied; the PrometheusRule exists
|
||||
only in git on this branch. What is proven is that its expression returns the expected vector
|
||||
against live prod data and that promtool agrees about its behaviour over synthetic series.
|
||||
**Merging is what deploys it.**
|
||||
- **`OAuth2ProxyGateUnobserved` would NOT have fired *during* the 2026-09-30 jackett incident in
|
||||
any useful sense** — it describes a standing condition that has been true since the sidecars
|
||||
were deployed, so it would have been firing before, during and after. That is deliberate and it
|
||||
is the honest claim: it is a **gap tracker**, not an incident detector. It would have meant that
|
||||
on 2026-09-30 an operator asking "why did nothing alert?" had the answer already on the board
|
||||
instead of discovering it during the investigation. **No alert on this platform could have
|
||||
detected the incident itself, and none can today.**
|
||||
- **The alert that the brief actually asked for does not exist, and cannot be written yet.** The
|
||||
sustained-redirect-ratio rule needs `oauth2_proxy_requests_total` from the *per-app* sidecars.
|
||||
**Enabling that requires editing files owned by the two sibling branches** — the sidecar args in
|
||||
`apps/wave-0/jackett.yaml` (Mira Vance) and `apps/wave-0/{qbittorrent,pihole}.yaml`,
|
||||
`apps/wave-1/{radarr,sonarr}.yaml` (Theo Bright) — so it was **not attempted here**. The change
|
||||
is three things per app, together: `--metrics-address=0.0.0.0:44180`, a `metrics` containerPort
|
||||
plus Service port, and a ServiceMonitor. Once any one app has them, the rule belongs beside
|
||||
`OAuth2ProxyGateUnobserved`, and that app's namespace drops out of it automatically.
|
||||
- **The Loki alerting path is inert and I did not fix it.** Loki *has* a ruler stanza and the
|
||||
chart's `loki-sc-rules` sidecar is running with `LABEL=loki_rule`, `FOLDER=/rules` — so a
|
||||
ConfigMap-shipped LogQL rule would be delivered. But `GET /loki/api/v1/rules` answers **"unable
|
||||
to read rule dir /var/loki/rules/fake: no such file or directory"** (the ruler reads a different
|
||||
directory from the one the sidecar writes), and the rendered config contains **no
|
||||
`alertmanager_url`** — `grep -i 'alertmanager\|rule_path'` over the `loki` ConfigMap returns
|
||||
nothing. So a LogQL alert on `|= "No valid authentication in request"` — which *would* have
|
||||
caught this incident, and is the single highest-value follow-up here — **would evaluate and
|
||||
notify nobody**. Wiring it means `rulerConfig` (alertmanager_url + a matching rules directory)
|
||||
in `platform/monitoring/loki.yaml`, which is not a file this brief gave me. **I did not ship an
|
||||
inert rule ConfigMap to look productive.**
|
||||
- **Nothing was reproduced in a browser.** The 302-to-200-with-HTML mechanism is read off the
|
||||
sidecar access log and the response sizes (`/UI/Dashboard 200 44379`), not observed in devtools.
|
||||
- **Loki retention is 72h** (`platform/monitoring/loki.yaml`), so the 24h ratios above are a
|
||||
snapshot; anything older than three days is already gone. This is stated in the runbook because
|
||||
it bounds what an operator can still investigate.
|
||||
- **The gate's L2/L3 conformance did not run** (`CLUSTER_AVAILABLE` unset), so no runtime
|
||||
requirement touched by this branch is certified. See the exit code below — **rc 3 is
|
||||
UNDETERMINED, not green.**
|
||||
|
||||
### Follow-ups, in order of value
|
||||
|
||||
1. **Wire the Loki ruler to Alertmanager** (`platform/monitoring/loki.yaml`). This is the only
|
||||
route to an alert that would have caught the actual 2026-09-30 failure, and the data is
|
||||
already being collected. The rule is one expression:
|
||||
`sum by (namespace) (rate({container="oauth2-proxy"} |= "No valid authentication in request" [15m]))
|
||||
> 5 * sum by (namespace) (rate({container="oauth2-proxy"} |= "AuthSuccess" [15m]))`.
|
||||
2. **Give one gate sidecar a metrics endpoint** and watch its namespace drop out of
|
||||
`OAuth2ProxyGateUnobserved`. That is the cheapest proof the alert is a tracker and not a nag,
|
||||
and it unlocks the ratio rule in Prometheus.
|
||||
3. **Consider whether `--set-xauthrequest` apps should get `--api-routes`** so API paths answer
|
||||
401 instead of 302. That is the fix that would make this failure *loud* rather than merely
|
||||
observable, and it belongs with whoever owns the sidecar args.
|
||||
|
||||
---
|
||||
|
||||
# Round 2 — 2026-10-01: the scrape got wired, and found a bug in this branch's own alert
|
||||
|
||||
Added to the same PR (#29) rather than a fourth concurrent branch. PR #28 merged in the meantime
|
||||
and `main` moved three PRs ahead; `origin/main` is merged in here, and the `runbooks/kustomization.yaml`
|
||||
conflict was resolved by **keeping both** runbook entries (mine and `oauth2-proxy-session`).
|
||||
|
||||
## What changed
|
||||
|
||||
**`platform/oauth2-proxy/podmonitor.yaml` (new).** PR #28 gave the five fleet sidecars
|
||||
`--metrics-address=0.0.0.0:44180` and a named `metrics` port. No Service exposes 44180 on any of
|
||||
them, so a ServiceMonitor would have nothing to select — a **PodMonitor** targets pod ports directly
|
||||
and needs no renderer change. Selects on the pod label `homelab.keithjackson.dev/auth-mode:
|
||||
forward-auth`, which is on exactly the five fleet gate pods and absent from crafty's: the property,
|
||||
not an app list, so a sixth forward-auth app needs no edit.
|
||||
|
||||
**`auth-visibility-alerts.yaml`: `up{container="oauth2-proxy"}` → `up{container="oauth2-proxy"} == 1`.
|
||||
This is a real bug fix, and it is the most important thing in this round.** A target that is
|
||||
discovered but failing still publishes `up`, with value **0**. The series exists, so
|
||||
`count by (namespace)` returned 1, `unless` removed the namespace, and `OAuth2ProxyGateUnobserved`
|
||||
would have **gone quiet while nothing was being scraped at all** — this branch's own disease,
|
||||
reproduced inside the alert written to cure it. Not hypothetical: the PodMonitor lands five targets
|
||||
whose scrape is dropped, so the bare form would have silently cleared on this very commit.
|
||||
|
||||
**Two new unit-test scenarios** pinning that: a discovered-but-failing target must still fire, and a
|
||||
target that works and then breaks must re-fire rather than latch cleared.
|
||||
|
||||
**Docs corrected rather than appended to.** The 2026-09-30 claim "the sidecars export no metrics at
|
||||
all" is now only half true and was rewritten in `runbooks/README.md`, `platform/oauth2-proxy/README.md`
|
||||
and the alert header, each showing the two-step history, because each step looked like the end of it.
|
||||
The runbook gains the policy-vs-no-target triage (step 1b) and the **radarr `/signalr` worked case**.
|
||||
|
||||
**Deliberately NOT shipped: the ratio alert.** `oauth2_proxy_requests_total` now exists on the
|
||||
endpoint, but not in the TSDB, because the scrape is blocked. Writing the ratio rule today would make
|
||||
it inert for precisely the reason it was refused on 2026-09-30. It becomes writable the day a scrape
|
||||
succeeds, not the day a listener exists.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **The endpoint serves — by payload, not by flag.** Port-forwarded to the jackett pod's 44180:
|
||||
`http=200 bytes=10208`, exporting `oauth2_proxy_requests_in_flight`, `oauth2_proxy_requests_total`
|
||||
(`code="200" 3`, `code="302" 2`) and `oauth2_proxy_response_duration_seconds`. `--metrics-address`
|
||||
being in `args` and `/metrics` returning a body are different claims; both now hold.
|
||||
- **The scrape path is DROPPED, measured from the right place.** From grafana in ns `monitoring`
|
||||
(beside Prometheus, same policy position) to the jackett pod IP: `:44180` → `wget: download timed
|
||||
out`; `:4180` → **also** timed out; the shared proxy's `:44180` → 200 with `oauth2_proxy_*` present.
|
||||
Cause, read from the objects: ns jackett's `default-deny` is `podSelector: {}`,
|
||||
`policyTypes: [Ingress, Egress]`, with an `egress` rule and **no `ingress` key** (deny-all ingress);
|
||||
the only allowance is CNP `jackett-allow-gateway-ingress`, `fromEntities: [ingress]` to port 4180.
|
||||
The shared proxy is scrapable only because ns `oauth2-proxy` has **no NetworkPolicy at all**.
|
||||
- **No Service exposes 44180** on any of the five (enumerated).
|
||||
- **The pod label selector is exact:** `auth-mode=forward-auth` on all five fleet gate pods, absent
|
||||
from crafty's. Crafty's sidecar still has no `--metrics-address` (args read in full).
|
||||
- **Flux ordering forbids shipping the fix from here:** `oauth2-proxy dependsOn=['platform-config']`
|
||||
and `apps-wave-0 dependsOn=['platform-config']` — siblings, no ordering. A NetworkPolicy naming ns
|
||||
`jackett` from this tier would apply today and fail on a cold rebuild.
|
||||
- **The `== 1` guard is non-vacuous — proven by mutation.** Reverting it to the bare `up` makes the
|
||||
two new scenarios FAIL (`got: nil` at 1h5m and 2h5m) and leaves every other assertion passing;
|
||||
restoring it returns the suite to SUCCESS under pinned promtool v3.14.0.
|
||||
- **Gate:** see the next section for the exit code and skips.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The PodMonitor has never been applied.** That it discovers the five pods is reasoned from the
|
||||
measured pod labels and port name, not observed — no target list has been read with it in place,
|
||||
because applying it is an operator action. **Expect `up == 0` on all five when it lands**, which is
|
||||
the documented state, not a regression.
|
||||
- **I did not fix the NetworkPolicy, and this PR therefore does not make the gates observable.** It
|
||||
makes them *discovered*, and makes the remaining gap loud instead of silent. The one narrow rule
|
||||
per app (ns monitoring → `auth-mode=forward-auth` → TCP 44180, per
|
||||
`platform/longhorn/networkpolicy-metrics.yaml`) has to be rendered with each app's own policy, in
|
||||
the catalog/renderer that is in flight on another branch. **It needs an owner.**
|
||||
- **Whether Prometheus will actually select a PodMonitor in ns `oauth2-proxy` targeting other
|
||||
namespaces.** `namespaceSelector.any: true` plus `podMonitorSelectorNilUsesHelmValues: false` and
|
||||
the fact that cross-namespace ServiceMonitors/PrometheusRules already work here say yes; it is not
|
||||
measured with this object.
|
||||
- **No session-lifecycle claim can be made from the current window.** `AuthSuccess` is **0** on both
|
||||
jackett and radarr since the PR #28 restart, so nobody has signed in and `--cookie-refresh=2m30s`
|
||||
has never executed a refresh. The cookie fix is unproven in the one dimension that matters most.
|
||||
- **The radarr `/signalr` finding is the coordinator's measurement, not mine** — I reproduced neither
|
||||
the 206/209 sample nor the jackett 0-in-13,612. They are recorded as reported.
|
||||
- **L2/L3 conformance still did not run** (`CLUSTER_AVAILABLE` unset). Nothing is certified.
|
||||
|
||||
### Round 2 addendum — CI is RED, by design, and the local gate cannot see why
|
||||
|
||||
After pushing round 2, `gate / gate (pull_request)` reported **failure after 1m42s** while the local
|
||||
toolbox run was rc 3 with nothing failed. Measured cause, singular:
|
||||
|
||||
sensitive paths touched:
|
||||
conformance/tests/l0-static/servicemonitor-ports-resolve/check.sh (matched conformance/**)
|
||||
approving reviews: (no reviews at all) author=gitea-admin
|
||||
|
||||
`ci/approval-policy.sh` is fail-closed and exits 1 when a PR touches a `sensitive_paths` glob with no
|
||||
`APPROVED` review from a non-author — "a change to the gate cannot be gated by the gate". The single
|
||||
`KNOWN_EXTERNAL_MONITORS` entry is what trips it. **Nothing in L0/L1 failed.**
|
||||
|
||||
**The local gate structurally cannot catch this, and said `approval-policy: green (N/A)`.** The stage
|
||||
short-circuits to N/A when `GITHUB_EVENT_NAME` is unset, which it always is off-CI. So for any branch
|
||||
touching a sensitive path, a clean local gate is a FALSE REASSURANCE — the PR run is the only honest
|
||||
signal. That is a general trap, not specific to this change, and it is worth knowing before someone
|
||||
concludes a red PR means broken code.
|
||||
|
||||
To clear: a non-author approving review, then **re-run the gate** — approval does not re-trigger the
|
||||
status check, so the required context stays red until the job runs again. `required_approvals=1`
|
||||
already applied to every PR here, so the human was needed regardless; the conformance entry only
|
||||
turns the tick red while that is pending.
|
||||
|
||||
The alternative is to drop the register entry, which means dropping `podmonitor.yaml` with it (the
|
||||
check fails without the declaration). I did not take it: the entry is the "declare the limit"
|
||||
mechanism the check explicitly asks for, it is shrink-only so it cannot rot quietly, and
|
||||
`flux-podmonitor.yaml` is the same case already in the register. But it is a real trade and the
|
||||
reviewer should make it knowingly.
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
date: 2026-10-01
|
||||
branch: fix/admin-approval-override
|
||||
title: Administrators can merge their own green PR — required_approvals 1 → 0, plus an attributed /approve-override for sensitive paths
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**The problem, measured first.** `required_approvals: 1` on `main` was a lock-out, not a
|
||||
protection. Gitea refuses to let a pull request's author approve their own PR, and `jakman85` is
|
||||
the **only** collaborator (permission `owner`). So no account in existence could approve a PR the
|
||||
operator opened. Measured on the live API, 2026-10-01:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| PR #35 (`bugfix/arr-to-plex`, author `jakman85`) | `gate / gate (pull_request)` = **success, 1m41s** |
|
||||
| its reviews | `[]` |
|
||||
| live `required_approvals` | `1` |
|
||||
| live `block_admin_merge_override` | `true` |
|
||||
|
||||
Green gate, zero reviews, no path to a merge — and the same was true of any PR that would have
|
||||
fixed it.
|
||||
|
||||
It was also never the design this repo wrote down. `ci/approval-policy.sh`'s own header and
|
||||
`ci/gate.sh:194` both already described the model as *"`required_approvals` drops to 0 and the
|
||||
GATE decides"*, and `infra/gitea/require-gate.sh`'s closing note named this exact failure and
|
||||
prescribed this exact remedy. `decisions/2026-09-30-flux-deploy-notification-inhibited.md` had
|
||||
flagged the 1-vs-0 disagreement as unresolved and not that session's to settle. This settles it
|
||||
at 0.
|
||||
|
||||
**Two layers were blocking, and both are addressed.**
|
||||
|
||||
*Layer 1 — Gitea branch protection.* `required_approvals: 1 → 0` in
|
||||
`infra/gitea/expected-branch-protection.json`, with the rationale in the file, plus
|
||||
`infra/gitea/allow-admin-self-merge.sh` to apply it to the live rule (it reads the target **from
|
||||
the declaration**, so the script cannot drift from what the gate checks). `require-gate.sh` was
|
||||
changed to 0 as well — left at 1 it would silently re-impose the lock-out on its next run.
|
||||
|
||||
The narrow choice matters: the *other* way to unblock an owner is
|
||||
`block_admin_merge_override: false`, and that would have re-opened precisely what
|
||||
`infra/gitea/block-admin-override.sh` was written to close (PRs #5–#9 merged with zero approvals
|
||||
while the settings read as enforced). This change drops the approval **count** only.
|
||||
`block_admin_merge_override` and `enable_status_check` both stay `true`, and the script asserts
|
||||
both afterwards and fails loudly if the PATCH disturbed either. **A red gate remains unmergeable
|
||||
by everyone, administrators included.**
|
||||
|
||||
*Layer 2 — `ci/approval-policy.sh`.* Sensitive paths (`ci/**`, `infra/gitea/**`, `CLAUDE.md`,
|
||||
kyverno, rbac, sops, Flux wiring) still require a non-author `APPROVED` review, which the sole
|
||||
administrator still cannot obtain. An administrator may now waive it by commenting on the PR:
|
||||
|
||||
/approve-override <reason>
|
||||
|
||||
- the directive must **start a line** (a quoted or mid-sentence mention does not count, or
|
||||
repeating someone else's override would re-arm it);
|
||||
- a **reason is mandatory** — a bare directive records that review was waived but not why;
|
||||
- the commenter's permission is **re-checked live** against
|
||||
`/collaborators/{login}/permission` when the gate runs, so an old comment from a demoted
|
||||
account carries nothing;
|
||||
- it is **invalidated by a new commit** (the comment must post-date the head commit), mirroring
|
||||
Gitea's own `dismiss_stale_approvals`;
|
||||
- the overrider, timestamp and reason are printed in the gate log and the job summary, and a
|
||||
self-waiver by the PR's own author is called out as such.
|
||||
|
||||
Selection of the candidate comment is `ci/approval-override.py` — pure, offline, no authority of
|
||||
its own — so it could be unit-tested; authorisation stays in the fail-closed stage.
|
||||
`ci/tests/approval-override.test.sh` adds **23 assertions**, all passing, each constructing a
|
||||
case the parser must refuse. Every new failure mode blocks: unreadable head-commit timestamp,
|
||||
unreadable permission, parser error.
|
||||
|
||||
**The cost, stated rather than buried.** For an administrator, *"a change to the gate cannot be
|
||||
gated by the gate"* is now **advisory, not structural**. That is unavoidable with one human — the
|
||||
alternative is a repository its owner cannot change — and what is bought back is an attributed,
|
||||
reasoned, commit-scoped record of each waiver instead of a silent bypass.
|
||||
|
||||
**The uncomfortable part, accepted with eyes open (operator decision).** Authorisation is by live
|
||||
permission **alone**, with no allowlist of human accounts. The **agent account `gitea-admin` also
|
||||
reports permission `owner`** on this repo (measured), so an agent *can* clear the sensitive-path
|
||||
review on its own PR — and agents author most PRs here, so that is the common case, not an edge
|
||||
case. I raised this before implementing; the operator chose permission-only. It is documented in
|
||||
three places rather than left to be discovered: `approval-policy.json`
|
||||
(`_known_consequence_agent_accounts`), the stage's header, and `CLAUDE.md`, which now tells
|
||||
agents plainly **not** to use it on their own work. The narrowing path needs no code change —
|
||||
add an `override_admins` list to `approval-policy.json` and the stage already requires membership
|
||||
in addition to the permission check.
|
||||
|
||||
Also updated: `CLAUDE.md` §1 and the §2 enforcement table (the "approval required" row is now
|
||||
explicitly ⬜ with the reason, rather than a stale ✅), and `infra/gitea/README.md` gains §6 and
|
||||
marks its historical step 4 as reversed — do not re-apply.
|
||||
|
||||
**Second pass, 2026-10-02 — the override did not work the first time, and why.** The operator ran
|
||||
the live PATCH (`required_approvals: 0` confirmed live, with `block_admin_merge_override` and
|
||||
`enable_status_check` still `true`), merged PR #35, and posted `/approve-override Need to ensure
|
||||
approval override work as expected` on PR #38. The gate re-ran (run 203) and **still blocked.**
|
||||
|
||||
The comment was not at fault: posted by `jakman85`, directive at the start of the line, reason
|
||||
present, and 29 minutes **newer** than head commit `616a497`, so it cleared the staleness rule.
|
||||
What failed was that **CI could not read it.** Run 203's log:
|
||||
|
||||
approval-override: COMMENTS_JSON is not a list
|
||||
|
||||
`SETTINGS_API_TOKEN` is minted `["read:repository", "read:admin"]` by
|
||||
`infra/gitea/set-ci-token.sh`, and Gitea puts `/issues/{n}/comments` behind the **issue** scope. So
|
||||
Gitea returned an error *object*, `jq -e .` accepted it as valid JSON, and the stage fell through
|
||||
to "no approving review" — **a permission failure wearing the costume of a missing override.**
|
||||
`/pulls/{n}/files` and `/pulls/{n}/reviews` are under `read:repository`, which is exactly why every
|
||||
other part of the same stage worked and this one did not.
|
||||
|
||||
Three fixes, and the first is the one that mattered most:
|
||||
|
||||
1. **The diagnosis is no longer misleading.** `COMMENTS_JSON` is out of the must-parse-or-block
|
||||
loop and is explicitly checked for being an *array*; when it is not, the stage prints Gitea's
|
||||
own `.message`, names the missing scope, and says where to post the override instead. An
|
||||
unreadable source can only cause a false BLOCK, never a false pass, so degrading it to a loud
|
||||
warning is safe — and blocking on it was never the honest behaviour.
|
||||
2. **The override is accepted from a PR REVIEW body as well as a comment**, so the hatch is not
|
||||
hostage to one API scope. A review of type "Comment" works on the token CI has *today*. The
|
||||
gate names which source it used. `PENDING` (unsubmitted, author-only draft) reviews are dropped.
|
||||
3. **`set-ci-token.sh` now requests `read:issue`**, which makes the ordinary comment box work —
|
||||
the thing anybody reaches for first. It takes effect only on a **re-run**: an existing token's
|
||||
scopes cannot be widened in place.
|
||||
|
||||
`ci/tests/approval-override.test.sh` grows from 23 to **38 assertions**, covering the reviews
|
||||
source, `PENDING` reviews, an error *object* from the comments API, "an unreadable comments source
|
||||
must not suppress a valid review override", latest-wins across both sources, and that the
|
||||
diagnostic actually quotes Gitea's message.
|
||||
|
||||
**One adjacent fix, deliberately in scope: `ci/tests/gate-decision.test.sh` was already broken.**
|
||||
It hand-maintains a list of stub stage scripts that "must name EVERY stage `gate.sh` runs", and it
|
||||
did not name `approval-policy` — added to `gate.sh` by PR #17 and never added here. `run_stage`
|
||||
therefore got exit 127 from a missing file and recorded a FAILED stage in every case, so **15 of
|
||||
50 assertions failed**, including every one expecting a PASS/UNDETERMINED headline. Measured on
|
||||
pristine `origin/main` (`0c7e8dd`) before touching it, so the attribution is not a guess.
|
||||
|
||||
I fixed it because `make -C ci test` is the *only* runner for the suite this change adds — leaving
|
||||
it red would have made the new tests unverifiable in practice. The file's own comment already
|
||||
narrated this exact failure for `gitea-settings` and did not prevent the repeat, which says the
|
||||
comment was not the fix. So the stub list is now **derived from `gate.sh`'s own `run_stage` lines**
|
||||
and asserts it parsed at least 8 of them: a stage added to the gate can no longer be silently
|
||||
missing from its test. `make -C ci test` goes from **FAILED to all suites passed**.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **The lock-out is real, and PR #35 is the proof.** Queried live: head sha
|
||||
`8a094c46`, statuses `gate / gate (pull_request) = success :: Successful in 1m41s`,
|
||||
`/pulls/35/reviews` → `[]`, `/branch_protections/main` → `required_approvals: 1`. I read the
|
||||
**rule object**, not `/branches/main`, per §2b.
|
||||
- **`jakman85` is the only collaborator**: `/collaborators` → one entry. Permission `owner`.
|
||||
- **`gitea-admin` also reports `owner`** — this is why the agent-account consequence above is a
|
||||
statement of fact and not a hypothetical.
|
||||
- **PR #35 touches no sensitive paths** (`apps/wave-0/plex.yaml`, `apps/wave-1/{radarr,sonarr}.yaml`,
|
||||
`catalog/{radarr,sonarr}.yaml`, its decisions entry) — so `ci/approval-policy.sh` was never its
|
||||
blocker. Layer 1 alone unblocks it.
|
||||
- **API shapes verified before depending on them**, rather than assumed: `/git/commits/{sha}`
|
||||
returns `.commit.committer.date` as RFC3339 **with an offset** (`2026-10-01T23:09:08-05:00`),
|
||||
not a trailing `Z` — a parser handling only `Z` would have silently dropped every real
|
||||
override, so there is a test for the offset form. PR conversation comments are under
|
||||
`/issues/{n}/comments`; `/pulls/{n}/comments` returns diff comments only and would never have
|
||||
seen an override.
|
||||
- **`ci/tests/approval-override.test.sh`: 23 passed, 0 failed.** Covers the happy path, bare
|
||||
directive, mid-sentence and `>`-quoted directives, stale and equal-timestamp overrides,
|
||||
unparseable timestamps, missing login, invalid JSON, non-list JSON, latest-wins, offset
|
||||
timestamps, and a configurable directive.
|
||||
- `bash -n` clean on `ci/approval-policy.sh`, `infra/gitea/allow-admin-self-merge.sh`,
|
||||
`infra/gitea/require-gate.sh`; `jq -e .` clean on both JSON declarations.
|
||||
- **`gate-decision.test.sh`'s 15 failures are pre-existing, measured not assumed.** Extracted
|
||||
`origin/main`'s `ci/` at `0c7e8dd` into a throwaway directory and ran the suite from there:
|
||||
**FAIL, 15 failed / 35 passed**. After deriving the stub list from `gate.sh`: **PASS, 50
|
||||
assertions**.
|
||||
- **`make -C ci test` in the pinned toolbox: `ci/tests: all suites passed`, rc 0** — approval-override
|
||||
**38**, gate-decision 50, touched-requirements-base 31, test-attribution 15, touched-requirements 26,
|
||||
decisions-log pass. It was rc 2 (`ci/tests: FAILED`) before the stub-list fix.
|
||||
- **The live flip worked, and the drift detector proved it.** Run 202 (before): `gitea-settings:
|
||||
FAIL`, `required_approvals: DECLARED 0 but LIVE is 1`. Run 203 (after the operator ran the
|
||||
PATCH): `gitea-settings: pass`, with `block_admin_merge_override = true` among the keys it
|
||||
checked. So the narrow change held — the approval count moved and the gate's teeth did not.
|
||||
- **The first override attempt failed for a measured reason, not a guessed one.** Run 203 log:
|
||||
`approval-override: COMMENTS_JSON is not a list`. Token scopes read from `set-ci-token.sh`:
|
||||
`[read:repository, read:admin]`. The same run successfully read `/pulls/38` and
|
||||
`/pulls/38/files` (author and sensitive paths both resolved) and got past the reviews
|
||||
validation, which is how I know `/pulls/{n}/reviews` IS readable with that token — from
|
||||
behaviour, not from documentation.
|
||||
- **The comment itself was valid.** `created_at` 2026-10-02T05:31:24Z vs head commit `616a497`
|
||||
committer date 2026-10-02T00:02:20-05:00 → the comment is 29m04s newer, so it passed the
|
||||
staleness rule. Ruling the comment out is what left the token scope as the only candidate.
|
||||
- **`?limit=1000` is not the culprit.** Queried `/issues/38/comments` with `limit=1000`, `limit=50`
|
||||
and no limit using my own credentials: `type=array` in all three. So the difference is the
|
||||
credential, not the query.
|
||||
- **The root cause is confirmed by Gitea, in its own words.** Run 206 (the fix in place):
|
||||
`[warn] cannot read PR COMMENTS from Gitea — token does not have at least one of required
|
||||
scope(s), required=[read:issue], token scope=read:admin,read:repository.` followed by the
|
||||
instruction to post the override as a review. That is the diagnosis the first attempt should
|
||||
have produced, and `gitea-settings: pass` in the same run. The stage still ends `FAIL`, correctly
|
||||
— there is no valid override on the PR yet.
|
||||
- **Toolbox merge gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`), run on the
|
||||
rebased branch: **rc 3, `MERGE GATE: UNDETERMINED`**. Green stages: spec-immutability,
|
||||
decisions-log, lint, l0 (23 offline cases, 0 failures), render-golden including the new
|
||||
`catalog/render.py --selftest`, l1 (3 cases), approval-policy (N/A offline). Skipped:
|
||||
`gitea-settings` (no `SETTINGS_API_TOKEN` on this host) and `conformance` (no cluster).
|
||||
**This is NOT a green run and I am not reporting it as one** — rc 3 is UNDETERMINED per the
|
||||
CLAUDE.md §3 contract.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The override has still never been ACCEPTED by a real run.** This is the second attempt at
|
||||
this bullet, which is itself the point: the first pass called the hatch unverified, and the
|
||||
first real test found a defect that no amount of local testing would have (an API scope CI has
|
||||
and I did not). What is now measured is that the parse → *reject* path works end to end and
|
||||
diagnoses itself. The **accepting** branch — directive found → live permission lookup → exit 0 —
|
||||
has never executed. Posting the override as a **review** on PR #38 is its first real test, and
|
||||
I would not be surprised by a third round.
|
||||
- **`read:issue` is the right scope name — Gitea said so itself, so this is no longer a guess.**
|
||||
Run 206 printed Gitea's own message: `token does not have at least one of required scope(s),
|
||||
required=[read:issue], token scope=read:admin,read:repository`. That both confirms the root
|
||||
cause and names the string, which matters because this repo has been burned by a token *name*
|
||||
that could not exist (`GITEA_SETTINGS_TOKEN`). What is **still** unverified is that the token
|
||||
*mint* accepts it — `POST /users/{u}/tokens` with that scope has not been run. The review route
|
||||
does not depend on it.
|
||||
- **I did not verify that Gitea lets a PR's own author submit a `COMMENT`-type review.** Gitea
|
||||
blocks author self-*approval*; "Comment" is expected to be allowed, and that expectation is now
|
||||
load-bearing for the recommended route. It is one click to find out, and I deliberately did not
|
||||
test it by posting an override myself: doing so as `gitea-admin` is precisely the agent
|
||||
self-override that `CLAUDE.md` now forbids.
|
||||
- **The whole re-run mechanism is manual.** `gate.yml` triggers on `pull_request` and `push: main`
|
||||
only — there is no `issue_comment` trigger, so posting an override does **not** re-run the gate.
|
||||
Re-run the job from the Actions UI, or close and reopen the PR (which fires `pull_request` and
|
||||
leaves the head SHA alone). **Do not push an empty commit to re-trigger it:** that moves the head
|
||||
commit and makes the override stale by the rule above, so it would have to be posted again. A
|
||||
comment trigger was considered and not built — it would need to check out PR head code while the
|
||||
job holds `SETTINGS_API_TOKEN`, which is the classic pwn-request shape and not worth it here.
|
||||
- **I have not seen the gate still REFUSE a merge** now that approvals are 0. The claim that
|
||||
`block_admin_merge_override: true` keeps a red gate unmergeable rests on Gitea's documented
|
||||
behaviour and on the 2026-09-29 session, **not** on a provoked refusal in this one. That probe
|
||||
is written out at the foot of `allow-admin-self-merge.sh` and should be done, exactly as the
|
||||
push rejection was provoked rather than assumed.
|
||||
- **Clock skew in the staleness check is unhandled.** Staleness compares the comment's
|
||||
server-side `created_at` against the head commit's *committer* date, which comes from the
|
||||
committer's machine. A commit dated in the future would reject legitimate overrides until real
|
||||
time catches up (fail-closed, so loud, but annoying). Gitea's own `dismiss_stale_approvals`
|
||||
compares SHAs instead; a comment cannot. Not hit here — PR #35's commit date is ~21 min behind
|
||||
server time — and not fixed.
|
||||
- **`ci/tests/approval-override.test.sh` does not run in the merge gate.** `ci/gate.sh` runs no
|
||||
`ci/tests/*`; they run only via `make -C ci test`. So a regression in the parser would not be
|
||||
caught by CI. PR #33 (merged while this branch was being written, and rebased onto here) fixed
|
||||
the *same class* of gap for `catalog/render.py --selftest` by invoking it from
|
||||
`ci/render-golden.sh` — it did **not** wire `ci/tests/*` in, which I confirmed after rebasing
|
||||
rather than assuming. Wiring them is a one-line stage and I did not widen scope to it.
|
||||
- **Authorisation itself is untested code.** `ci/approval-override.py` is unit-tested; the live
|
||||
permission lookup, the `override_admins` allowlist branch, and every new BLOCK path in
|
||||
`ci/approval-policy.sh` need Gitea and have never been exercised. The allowlist branch in
|
||||
particular is implemented but **unused** — `override_admins` is absent by design.
|
||||
- **The other open PRs will go red** once the live value changes, until they are rebased onto the
|
||||
merged declaration: as of writing those are **#29** and **#37** (both `gitea-admin`), plus
|
||||
**#35**. I did not rebase them. Merging them first avoids it entirely; the script's header gives
|
||||
the order. PRs #33, #34 and #36 merged while this branch was being written — this branch is
|
||||
rebased onto `0c7e8dd`, so the list was re-measured rather than carried forward.
|
||||
- No cluster state was read or changed; nothing here touches Flux, Kyverno or any workload.
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
date: 2026-10-01
|
||||
branch: bugfix/arr-to-plex
|
||||
title: Radarr and Sonarr may now reach Plex on 32400 — the network path only, not the Plex connection itself
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Radarr and Sonarr had no route to Plex. The media library lives on NFS
|
||||
(`/mnt/Storage/plex`, the ZFS dataset `Storage/plex` on `${BULK_NFS_SERVER}`), and NFS
|
||||
does not deliver filesystem CRUD notifications, so Plex never learns that an arr has
|
||||
written a new file. Without a push from the arrs, the library only reflects new media
|
||||
when somebody scans it by hand. That is the bug this branch addresses.
|
||||
|
||||
The fix is one `dependencies:` entry per app, added to the catalog — the source of truth:
|
||||
|
||||
- to: plex
|
||||
port: 32400
|
||||
protocol: tcp
|
||||
|
||||
in both `catalog/radarr.yaml` and `catalog/sonarr.yaml` (these two *are* the arr suite
|
||||
here; `jackett`, `qbittorrent` and `seerr` are different roles and are untouched).
|
||||
|
||||
The renderer expands each entry into a **pair** of Cilium policies, which is why a
|
||||
three-line catalog change produces 88 lines under `apps/`:
|
||||
|
||||
| Side | Policy | File |
|
||||
|---|---|---|
|
||||
| egress | `radarr-allow-plex-32400` | `apps/wave-1/radarr.yaml` |
|
||||
| egress | `sonarr-allow-plex-32400` | `apps/wave-1/sonarr.yaml` |
|
||||
| reciprocal ingress | `plex-allow-radarr-32400` | `apps/wave-0/plex.yaml` |
|
||||
| reciprocal ingress | `plex-allow-sonarr-32400` | `apps/wave-0/plex.yaml` |
|
||||
|
||||
Three commits, and the middle two are worth recording because both were self-inflicted
|
||||
gate failures rather than design changes:
|
||||
|
||||
- `753c48b` — the catalog edit alone, with `apps/` left unrendered.
|
||||
- `9048c2f` — re-rendered `apps/` from the catalog, which `render==golden` requires.
|
||||
- `58f02fe` — removed 20 trailing spaces on `catalog/radarr.yaml:103`, left behind as
|
||||
comment-alignment padding when the comment moved down to the `port:` line. Sonarr's
|
||||
equivalent line kept its comment in place, which is why only radarr tripped.
|
||||
|
||||
## What I verified
|
||||
|
||||
**The lint failure, root-caused.** Gate run 193 on `9048c2f` failed with
|
||||
`[fail] lint: FAILED -> blocks merge`, and lint was the only failing stage. yamllint
|
||||
emitted 419 findings, but **418 were `line-length`**, which `ci/yamllint.yaml` pins at
|
||||
`level: warning` and which therefore cannot change the exit code. The single error-level
|
||||
finding in the entire repository was:
|
||||
|
||||
catalog/radarr.yaml
|
||||
103:13 error trailing spaces (trailing-spaces)
|
||||
|
||||
`trailing-spaces` inherits the default severity (error), so that one line returned rc 1.
|
||||
After the fix, `git grep -nIE ' +$' -- '*.yaml' '*.yml'` matches nothing repo-wide.
|
||||
|
||||
**Gate run 195 on `58f02fe`** (read from the job log, not from the status tick):
|
||||
|
||||
[ ok ] approval-policy: green [ ok ] l0: green
|
||||
[ ok ] gitea-settings: green [ ok ] render-golden: green
|
||||
[ ok ] decisions-log: green [ ok ] l1: green
|
||||
[ ok ] lint: green
|
||||
[fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED.
|
||||
[fail] Stages that did not run: spec-immutability
|
||||
|
||||
The Gitea check reports `success` / "Successful in 1m41s", but the gate's own verdict is
|
||||
**UNDETERMINED (rc 3)**, not PASS. That tick is `gate.yml` tolerating exit 3 for the one
|
||||
stage it documents as unverifiable on this runner (the spec repo is not checked out
|
||||
there). Nothing else produced a 3.
|
||||
|
||||
**`render==golden` green** on `58f02fe`, so `apps/` is a faithful canonical render of
|
||||
`catalog/` — the four generated policies are not hand-written drift.
|
||||
|
||||
**DNS egress was already open** and is not a new gap: `apps/wave-1/radarr.yaml` carries
|
||||
an existing allow for port 53 UDP+TCP, so resolving the Plex service name does not need
|
||||
anything added here.
|
||||
|
||||
**Live branch protection on `main`**, measured against
|
||||
`/api/v1/repos/Home-Network/home-network-k3s-impl/branches/main` rather than assumed:
|
||||
`protected: true`, `user_can_push: false`, `enable_status_check: true`,
|
||||
`status_check_contexts: ['gate / gate (pull_request)']`, `required_approvals: 1`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
**This change does not accomplish what the PR set out to do.** It opens the network path
|
||||
and nothing else. Triggering a library update also requires each arr's
|
||||
*Settings → Connect → Plex Media Server* entry plus a Plex token, and **none of that
|
||||
exists anywhere in `catalog/` or `apps/`** — I grepped both for `plex`, `token` and
|
||||
`connect` and found only NFS paths, a uid and a timezone. Radarr's and Sonarr's config
|
||||
lives on a 5Gi Longhorn PVC mounted at `/config`, so that connection is UI-only state
|
||||
that exists solely on the PVC and never in git, in the same way Crafty's local login
|
||||
does. **Until somebody adds it in each app's UI, Plex still will not see library
|
||||
updates, and this branch will look like it did nothing.** That follow-up is not tracked
|
||||
by any file in this PR.
|
||||
|
||||
**L2/L3 conformance never ran.** `CLUSTER_AVAILABLE` is unset on the runner, so the
|
||||
eight requirements this diff touches — `R-NET-01`, `R-NET-05`, `R-NET-06`, `R-NET-07`,
|
||||
`R-IDP-02`, `R-IDP-06`, `R-GITOPS-02`, `R-PLAT-02` — are **UNVERIFIED**. That gap is
|
||||
unusually load-bearing here: the entire substance of this change is four
|
||||
CiliumNetworkPolicies, and no test has confirmed that traffic from the `radarr` or
|
||||
`sonarr` namespaces actually reaches `plex:32400`. The green gate proves the offline
|
||||
render subset only.
|
||||
|
||||
**`spec-immutability` did not run** (`R-TEST-06` not verified), for the documented
|
||||
reason — the sibling spec repo is absent from the runner.
|
||||
|
||||
**Nothing was checked on the cluster.** I ran no `kubectl`, so I did not confirm that
|
||||
the `plex` Service exposes 32400 as the arrs would address it, that the pod labels the
|
||||
policies select (`app.kubernetes.io/name`, `k8s:io.kubernetes.pod.namespace`) match the
|
||||
running pods, or that Cilium accepted the four new objects. Flux has not applied any of
|
||||
this: the branch is unmerged, so `.status.lastAppliedRevision` does not contain it.
|
||||
|
||||
**I did not verify Plex's own side** — whether its library-update endpoint on 32400 is
|
||||
the right target for the arrs' notification, or whether Plex requires
|
||||
"Update my library automatically" enabled to act on one.
|
||||
|
||||
**I am not the approver and did not approve this PR.** `required_approvals: 1` is live,
|
||||
and the only credential on the authoring workstation belongs to the PR's author
|
||||
(`jakman85`), whose own review Gitea does not count. Per CLAUDE.md §1 the merge is a
|
||||
human action regardless.
|
||||
@@ -0,0 +1,482 @@
|
||||
---
|
||||
date: 2026-10-01
|
||||
branch: feat/oauth2-proxy-api-route-per-app
|
||||
title: The config model had no way to say "this app's API lives here" — so --api-route could not ship. Now it can, per app.
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Second half of PR #28 (`decisions/2026-09-30-oauth2-proxy-cookie-collision-fleet.md`), which
|
||||
fixed the cookie collision, the 5-minute session and the missing metrics listener in one renderer
|
||||
function and **deliberately declined `--api-route`** for a reason I have preserved rather than
|
||||
worked around: that flag was measured on **one** app, and `PiHole serves no /api/ at all`, so a
|
||||
renderer-wide constant would apply one app's path shape to five. The `auth` block is
|
||||
`additionalProperties: false`, so there was no per-app override hook. **Creating the hook was the
|
||||
work; setting the flag is the easy part.**
|
||||
|
||||
**Three surfaces, in order of what they protect:**
|
||||
|
||||
| Surface | Change | What it guards that the others cannot |
|
||||
|---|---|---|
|
||||
| `schemas/servicedefinition.schema.json` (**1.8.0 → 1.9.0**) | new optional `auth.apiRoutes`: array of anchored path regexes, `uniqueItems`, default `[]` | rejects a too-broad regex **where a human types it** — `ci/l0.sh` validates `catalog/*.yaml` against this schema on every gate run |
|
||||
| `catalog/render.py` | emits one `--api-route=<r>` per declared entry, in order; **raises** on an unanchored entry | `ci/render-golden.sh` runs on every gate invocation even when the schema stage skips |
|
||||
| `conformance/tests/l0-static/oauth2-proxy-api-routes-per-app/` (new) | cross-checks each rendered sidecar against its own ServiceDefinition | catches a **renderer constant**, which neither of the above can see |
|
||||
|
||||
**The `auth` block stays `additionalProperties: false`.** One named, documented key was added; the
|
||||
door is not opened. That property is the reason the gap existed, and it is worth keeping — it is
|
||||
what made "there is no override hook" a *fact* rather than a *guess* for the two engineers who hit
|
||||
it before me.
|
||||
|
||||
**Rebased mid-flight.** `origin/main` advanced from `08deb20` to `cdf3e14` while I was working
|
||||
(PR #27 and PR #32, the `hardware.gpu: intel-quicksync` capability). **That change also took schema
|
||||
`1.8.0`**, so mine is renumbered to **1.9.0** and chained behind it; both version entries are
|
||||
present in the description, and `catalog/render.py` had a two-sided conflict in the constants block
|
||||
which is resolved by keeping both (the `DRI_*` constants and `OAUTH2_API_ROUTE_PATTERN` are
|
||||
unrelated). Re-rendering after the rebase still changes **only** jackett and radarr — plex's new
|
||||
golden reproduces byte-identically, so the two changes do not interact.
|
||||
|
||||
**Shape:** a **list** of regexes, not a single string, because Radarr needs two
|
||||
(`^/api/` and `^/signalr/`) and `--api-route` is natively repeatable. A single string would have
|
||||
forced alternation into the value, and alternation is exactly how breadth gets smuggled past a
|
||||
prefix-only reading of a regex.
|
||||
|
||||
**The anchoring ratchet.** Each entry must match `^\^/[A-Za-z0-9._~/-]+(\.\*|\$)?$` — anchored at
|
||||
`^/`, then literal path characters, with an optional trailing `.*` or `$`. The same pattern is in
|
||||
the schema, in the renderer, and in the new check, and assertion (5) of the check **fails if they
|
||||
drift apart**, because if they do the loosest one wins silently. Rejected shapes, all five
|
||||
provoked: `^/`, `^/.*`, `.*`, `/api/` (unanchored), `^/api/|^/` (alternation). Alternation is
|
||||
**refused rather than supported**: the flag is repeatable, so one entry per route costs nothing.
|
||||
|
||||
**Why breadth is the dangerous direction, and it is not symmetric.** A *missing* `--api-route` is a
|
||||
silent 302 — bad, invisible, recoverable. A *too broad* one answers a plain **page load** with 401:
|
||||
a logged-out operator gets a bare error page and is **never offered a sign-in**, so the service
|
||||
looks permanently broken and no reload recovers it. That is a worse outcome than the defect the
|
||||
flag fixes, which is why it is gated on three surfaces rather than documented in one.
|
||||
|
||||
### Also folded in: the selftest could exit 1 with ZERO diagnostics
|
||||
|
||||
Not my change originally — Desmond Oyelaran found it while wiring `catalog/render.py --selftest`
|
||||
into the `render-golden` stage (his PR #33, which touches only `ci/render-golden.sh` and is **not**
|
||||
in my base; `origin/main` at `cdf3e14` does not yet drive the selftest from any gate stage). The
|
||||
coordinator asked me to fold the fix into this PR because the defect is in the file I own.
|
||||
|
||||
`_selftest()` reached `_cookie_name()` through a bare `next(genexpr)` with **no default**. Delete
|
||||
the `--cookie-name` emission — exactly the mutation that assertion exists to catch — and it raises
|
||||
`StopIteration`, which escapes `_selftest()` **before its collected `SELFTEST FAIL:` lines are
|
||||
printed**. Right verdict, no diagnostics, and it masks every other assertion the same mutation
|
||||
broke. Fixed with `next(..., None)` plus an explicit `check()` so a missing flag reports **as a
|
||||
named assertion**.
|
||||
|
||||
Provoking it turned up **three more instances of the same failure class** in the same block, which
|
||||
is why this is not literally a one-line change: `side = fa_containers[1]`,
|
||||
`side["env"][0][...]`, and the `fa_svc` lookup all raise rather than assert when the sidecar is
|
||||
absent entirely. All four are now None-tolerant, and the two fixtures I added for `--api-route`
|
||||
were written the same way from the start. Measured, on throwaway copies:
|
||||
|
||||
| Mutation | Before | After |
|
||||
|---|---|---|
|
||||
| `--cookie-name` emission deleted (origin/main's renderer) | rc 1, **0** `SELFTEST FAIL` lines, 1 `StopIteration` | rc 1, **3** `SELFTEST FAIL` lines, 0 tracebacks |
|
||||
| the oauth2-proxy sidecar not appended at all | rc 1, **0** lines, `IndexError` | rc 1, **20** `SELFTEST FAIL` lines, 0 tracebacks |
|
||||
|
||||
Note the consequence of his stage change for anyone reading this later: once PR #33 lands,
|
||||
`.gitea/workflows/gate.yml:138` runs `./ci/gate.sh || [ "$?" -eq 3 ]`, so **rc 1 is not tolerated
|
||||
in CI the way rc 3 is** — a selftest failure will block. Which is the right outcome, and it is why
|
||||
a selftest that prints nothing was worth fixing before that lands rather than after.
|
||||
|
||||
### Declined, with reasons: the metrics-ingress NetworkPolicy
|
||||
|
||||
The coordinator asked for my judgement on bundling Ines Dubois's finding — that #28's metrics
|
||||
listener genuinely serves (she measured `http=200`, 10,208 bytes,
|
||||
`oauth2_proxy_requests_total{code="302"} 2` via port-forward) but the scrape will be **dropped**,
|
||||
because namespace `jackett`'s default-deny has **no `ingress` key at all** and
|
||||
`jackett-allow-gateway-ingress` admits `fromEntities: [ingress]` only. The fix belongs in each
|
||||
app's own rendered policy, i.e. in my file and my goldens. **I declined to bundle it**, and asked
|
||||
for it as the immediate next PR. Five reasons, in the order that decided it:
|
||||
|
||||
1. **The real reason is that it is a MODEL question, not a policy to bolt on.** Schema 1.6.0
|
||||
already owns this: `observability.metricsEndpoint` renders the Service port **and** the
|
||||
monitoring-namespace ingress allow. What the model cannot currently express is a **second**
|
||||
metrics endpoint on the same pod belonging to a *sidecar the renderer injected*, rather than to
|
||||
the workload the operator declared. Keying a bespoke policy off the pod label
|
||||
`homelab.keithjackson.dev/auth-mode: forward-auth` works today, but it hardcodes "forward-auth
|
||||
implies a metrics port on 44180" into a policy instead of **deriving** it from the sidecar
|
||||
declaration that is already the single source of that port — the exact "list, don't derive"
|
||||
anti-pattern `oauth2-proxy-cookie-names-distinct` assertion (5) exists to forbid, and which I
|
||||
would be introducing one directory away from the check that forbids it. That deserves its own
|
||||
review, and it is squarely the kind of decision I was brought in to make deliberately.
|
||||
2. **Different subject, and it is a trust-boundary change.** Mine is the auth *response code* on
|
||||
API paths (R-IDP-02). That is an ingress allow (R-NET-01/02, R-OBS-01) admitting a new
|
||||
namespace to a new port. Bundling a security-relevant ingress rule into a PR whose headline is
|
||||
`--api-route` is how such a rule gets waved through on the strength of the other half — and this
|
||||
PR already needs a human for a *different* sensitive-path reason, so the second subject would be
|
||||
reviewed in the shadow of the first.
|
||||
3. **The conflict argument does not actually apply to my diff.** It assumes I am regenerating five
|
||||
goldens; **I am regenerating two** (jackett, radarr), because the flag renders only where
|
||||
declared. A metrics-ingress policy renders a *different document* — a CiliumNetworkPolicy — in
|
||||
all five. Different hunks, in files git merges independently; and goldens regenerate
|
||||
deterministically from the renderer, so the follow-up rebases onto `main` cleanly. The N-1
|
||||
rebase problem `decisions/README.md` records was two branches appending to **the same hunk**,
|
||||
which this is not. I checked my own diff before answering rather than accepting the premise.
|
||||
4. **Every fact in the finding is hers and I have measured none of it** — not the 200/10,208 bytes,
|
||||
not the `wget: download timed out` on `:44180` *and* `:4180`, not the Flux sibling ordering that
|
||||
makes `oauth2-proxy` and `apps-wave-0` unordered. Shipping a security-adjacent rule on wholly
|
||||
inherited evidence, as a footnote in someone else's PR, is precisely what CLAUDE.md §2b exists
|
||||
to stop. In its own PR I would measure it first.
|
||||
5. **Sequencing: the PodMonitor is in HER PR #29, not mine.** If I ship the policy and her
|
||||
PodMonitor is reworked or delayed, I have opened an ingress allow for a scraper that selects
|
||||
nothing — a hole with no consumer. The policy is only useful paired with the PodMonitor, which
|
||||
argues for it landing adjacent to that, not here. It also drags in a
|
||||
`KNOWN_EXTERNAL_MONITORS` entry in `conformance/tests/l0-static/servicemonitor-ports-resolve/`,
|
||||
a second conformance interaction on top of the new check this PR already adds.
|
||||
|
||||
Diff size is the tiebreaker rather than the argument: **1,245 insertions across 13 files**,
|
||||
including a ~400-line new conformance check. I would rather hand a reviewer one subject they can
|
||||
actually falsify. **Queued, not dropped** — and if the next person takes it, the model note in (1)
|
||||
is the part worth arguing about, not the YAML.
|
||||
|
||||
### Which apps got which routes, and the measurement behind each
|
||||
|
||||
| App | `auth.apiRoutes` | Evidence |
|
||||
|---|---|---|
|
||||
| **jackett** | `^/api/` | 2026-09-30, its own access log: **33 × 302 against 2 × 401** on `/api/` paths, and **4 of 6 POSTs answered by the proxy and never reaching Jackett** (upstream field `-`, not `/`), including both `/api/v2.0/server/update` calls |
|
||||
| **radarr** | `^/api/`, `^/signalr/` | `^/signalr/`: measured **by me, on the live post-fix pod** — see below. `^/api/`: measured on radarr by the PR #30 author, on the **pre-fix** pod |
|
||||
| **pihole** | *(none — deliberate)* | serves no `/api/`; **0** unauthenticated requests in its current pod's 23 h |
|
||||
| **sonarr** | *(none — not measured)* | 13,643 log lines, **0** unauthenticated requests. No traffic, not a negative result |
|
||||
| **qbittorrent** | *(none — not measured)* | 13,649 log lines, **0** unauthenticated requests. Same |
|
||||
|
||||
**`^/signalr/` on radarr is the live proof this change was needed**, and it is the strongest
|
||||
evidence in this entry. 23 h after PR #28 deployed, on `radarr-7d55cbffb9-42hmh` (0 restarts), the
|
||||
sidecar logged **232 unauthenticated requests across the pod's entire lifetime, and all 232 are the
|
||||
same call**: `POST /signalr/messages/negotiate`, browser UA, **302**, upstream `-` (Radarr never saw
|
||||
one of them). That is a **left-open browser tab** whose session the cookie rename orphaned, polling
|
||||
SignalR forever, silently redirected every time, and **never re-prompting the operator**. It is the
|
||||
reported symptom — "reloading never fixes it and I am never re-prompted" — still happening in
|
||||
production, on a service nobody was looking at. With 401 the UI's connection breaks **loudly**; the
|
||||
operator reloads; `/` matches neither regex, so the reload signs them back in.
|
||||
|
||||
**`^/api/` on radarr: included, and here is the honest basis.** The current pod has logged **zero**
|
||||
`/api/` traffic, so I did **not** re-measure it. It rests on a measurement of **radarr** recorded in
|
||||
the merged PR #30 handoff — its 156 unauthenticated requests were `/api/v3/command` (62),
|
||||
`/api/v3/queue/details` (46) and `/signalr/messages/negotiate` (43), i.e. **108 of 156 were
|
||||
`/api/v3` XHRs**. That is this app's own evidence, by another engineer, not an analogy to another
|
||||
app. I include it because `/api/v3/` is a pure JSON namespace a browser never navigates to (Radarr
|
||||
serves its UI at `/`, assets under `/Content/` and `/MediaCover/`), so the breadth risk is nil and
|
||||
the measured benefit is 70% of that app's rejection volume.
|
||||
|
||||
**Sonarr and qBittorrent were measured and deliberately left alone.** Sonarr is nearly the same
|
||||
codebase as Radarr, which is exactly what makes `^/api/` + `^/signalr/` a tempting guess and still a
|
||||
guess. I read both pods' logs: **zero** unauthenticated requests, nothing but health probes, because
|
||||
nobody has opened either UI since the cookie fix rolled. **That is evidence of no traffic, not
|
||||
evidence of no need.** Each catalog file now carries a comment saying so, with the numbers and what
|
||||
to measure before adding the routes.
|
||||
|
||||
### Goldens and fixtures regenerated
|
||||
|
||||
Rendered with the canonical command
|
||||
(`python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation`).
|
||||
**Two goldens changed, not five** — and that is the per-app property proving itself on the real
|
||||
tree rather than in a fixture:
|
||||
|
||||
apps/wave-0/jackett.yaml + --api-route=^/api/
|
||||
apps/wave-1/radarr.yaml + --api-route=^/api/
|
||||
+ --api-route=^/signalr/
|
||||
|
||||
`apps/wave-0/pihole.yaml`, `apps/wave-0/qbittorrent.yaml` and `apps/wave-1/sonarr.yaml` are
|
||||
**byte-identical to `main`**. **No L1 fixture needed regenerating**: all three
|
||||
`conformance/tests/l1-rendering/*/fixtures/svcdef*.yaml` declare `auth.mode: forward-auth` with no
|
||||
`apiRoutes`, so they render no flag and their goldens are unchanged. PR #28 touched 5 goldens and 3
|
||||
L1 fixtures because it changed flags on *every* sidecar; this one changes flags only where a
|
||||
measurement says so, which is the whole point.
|
||||
|
||||
### The L0 decision (and it is a decision, not a default)
|
||||
|
||||
**Not** in `oauth2-proxy-cookie-names-distinct`. That check's subject is cookie **identity** — its
|
||||
inputs are names and domains, its failure mode is mutual session eviction, its register is the
|
||||
shared proxy's claim on the default name. `--api-route` shares none of those. Bundling them would
|
||||
make that check's name a lie and couple two unrelated ratchets, so a future api-route decision could
|
||||
only be expressed by weakening a *cookie* check. That is PR #28's own stated discipline, applied to
|
||||
my change rather than quietly dropped.
|
||||
|
||||
**A sibling check, `oauth2-proxy-api-routes-per-app`** (whole-repo scope, so it runs on every gate
|
||||
invocation via `ci/l0.sh`'s `--include-scope` union — no bespoke stage, which
|
||||
`whole-repo-invariants-wired` would fail). **Nowhere was the wrong answer for exactly one reason:**
|
||||
the existing gates cannot see a renderer constant. `render-golden` compares the render to the
|
||||
goldens, so a constant **plus regenerated goldens agree with each other while both being wrong** —
|
||||
I provoked that and it regenerates with **rc 0**. The new check's assertion (2) compares each
|
||||
rendered sidecar against its **own ServiceDefinition**, which is the only reading that fails it.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **`--api-route` is SINGULAR, read off the PINNED binary rather than taken from the brief.**
|
||||
`podman run --rm quay.io/oauth2-proxy/oauth2-proxy:v7.8.1 --help`:
|
||||
`--api-route strings return HTTP 401 instead of redirecting to authentication server if token
|
||||
is not valid. Format: path_regex`. There is **no `--api-routes`** in the help output. The PM asked
|
||||
me to verify rather than trust, and that is the result — the singular form is confirmed and the
|
||||
help text also confirms the *semantics* I am relying on (401 instead of a redirect).
|
||||
- **The premise, in-cluster, read-only, through the pinned toolbox, `--context k3s-prod` named
|
||||
explicitly** (CLAUDE.md §2b and §5). PR #28 **is** deployed: all five sidecars carry
|
||||
`--cookie-name=_oauth2_proxy_<app>`, `--cookie-domain=<app>.home.keithjackson.dev`,
|
||||
`--cookie-refresh=2m30s` and `--metrics-address=0.0.0.0:44180`. Pods, all **0 restarts**:
|
||||
`jackett-6877fcd9f5-ht9d9`, `pihole-…`, `qbittorrent-5547bdb9bd-zq557`,
|
||||
`radarr-7d55cbffb9-42hmh`, `sonarr-ddf485775-8ckwt` (started 2026-09-30T21:41–21:42Z).
|
||||
- **The full post-fix log census, parsed rather than grepped** (oauth2-proxy's access-log format,
|
||||
with `/ping` health probes excluded), over each pod's whole life:
|
||||
|
||||
app lines unauth AuthSuccess removing session cookie sig invalid
|
||||
jackett 13,649 0 0 0 0
|
||||
pihole 13,649 0 0 0 0
|
||||
qbittorrent 13,649 0 0 0 0
|
||||
radarr 14,099 232 0 0 0
|
||||
sonarr 13,643 0 0 0 0
|
||||
|
||||
Three findings, kept separate:
|
||||
1. **The collision fix works.** Jackett was 39 unauthenticated before; it is **0** in 13,612
|
||||
non-probe lines. The eviction fingerprint (`removing session` / `cookie signature not valid`)
|
||||
is **0 on all five**, where radarr previously had 8 and 8.
|
||||
2. **Radarr's 232 are one request.** 100% `POST /signalr/messages/negotiate`, 100% **302**, 100%
|
||||
upstream `-`, browser UA (`Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537…`). **Zero `/api/`
|
||||
traffic of any kind** on this pod.
|
||||
3. **0 `AuthSuccess` anywhere**, so nobody has signed in since the restart and `--cookie-refresh`
|
||||
is **still** unexercised — unchanged from PR #28's own caveat.
|
||||
- **Only two goldens change, and three are byte-identical to `main`.** `git status` after the
|
||||
canonical render lists exactly `apps/wave-0/jackett.yaml` and `apps/wave-1/radarr.yaml`.
|
||||
`render-golden` is green.
|
||||
- **`catalog/render.py --selftest`: PASS**, with new assertions that (a) the `fa` fixture, which
|
||||
declares nothing, renders **no** `--api-route`; (b) a fixture declaring two routes renders exactly
|
||||
those two, in order; (c) the renderer **refuses** all five breadth shapes; (d) four legitimate
|
||||
routes (`^/api/`, `^/signalr/`, `^/api/v3/.*`, `^/api/v2.0/server/update$`) are **accepted** — a
|
||||
ratchet that blocks real API prefixes is a ratchet someone eventually deletes.
|
||||
- **The new check: rc 0, 43 assertions, 6 sidecars audited**, with its inventory printed so the
|
||||
reviewer can see what it read:
|
||||
`jackett -> ['^/api/'] | pihole -> (none) | qbittorrent -> (none) | radarr -> ['^/api/',
|
||||
'^/signalr/'] | sonarr -> (none) | crafty -> (none)`.
|
||||
- **The check was tested in BOTH directions, on throwaway copies outside the repo**, never by
|
||||
mutating the branch. Each mutation fired its own assertion class and nothing else:
|
||||
- **Pre-change tree** (`git archive origin/main` + the test dropped in): **rc 1, 11 failures** —
|
||||
no service declares routes, the renderer emits none, the renderer does not read `auth.apiRoutes`,
|
||||
the pattern is absent from both renderer and schema, and the shipped renderer accepts all five
|
||||
breadth shapes.
|
||||
- **pihole's golden hand-edited** to carry a route it never declared: **rc 1, 2 failures** (the
|
||||
catalog↔golden mismatch, and "declares nothing must render nothing").
|
||||
- **radarr's golden widened** to `--api-route=^/`: **rc 1, 2 failures** (unanchored, plus the
|
||||
mismatch).
|
||||
- **Renderer turned into a constant and EVERY golden regenerated to match: the regen exits 0** —
|
||||
i.e. `render-golden` is **happy** — and the new check exits **1 with 15 failures**, naming
|
||||
pihole, qbittorrent, radarr and sonarr individually. **This is the case that justifies the
|
||||
check's existence** and I provoked it rather than argued it.
|
||||
- **Schema `items.pattern` deleted**: **rc 1, 1 failure** — the three-surface drift guard.
|
||||
- **Unmutated control: rc 0.**
|
||||
- **The selftest diagnostics fix was PROVOKED, not reasoned.** Both mutations above were run
|
||||
against throwaway copies of the renderer outside the repo (`origin/main`'s `render.py` for the
|
||||
"before" column, mine for the "after"), never by mutating the branch. The pre-fix StopIteration
|
||||
and the post-fix 3-and-20 named assertions are quoted from those runs. `--selftest` on the
|
||||
unmutated branch: **PASS**. Goldens after the fix: **unchanged** (it touches only `_selftest()`).
|
||||
- **The check is selected by the whole-repo union.** `./conformance/run.sh --layer l0 --requirements
|
||||
r-nosuchreq-99 --include-scope whole-repo --print-selection` lists
|
||||
`tests/l0-static/oauth2-proxy-api-routes-per-app` among **21** tests (it was 20), i.e. it runs
|
||||
even when the touched-requirement filter matches nothing.
|
||||
- **THE SCHEMA GUARD IS PROVEN WITH THE PINNED VALIDATOR `ci/l0.sh` ACTUALLY DRIVES**, not by
|
||||
reading the pattern. `./schemas/validate.sh` (`check-jsonschema, version 0.29.4`) against seven
|
||||
throwaway ServiceDefinitions written outside the repo:
|
||||
|
||||
good ["^/api/", "^/signalr/"] rc 0
|
||||
bad-root ["^/"] rc 1 apiRoutes ... does not match
|
||||
bad-dotstar ["^/.*"] rc 1 apiRoutes ... does not match
|
||||
bad-unanchored ["/api/"] rc 1 apiRoutes ... does not match
|
||||
bad-alt ["^/api/|^/"] rc 1 apiRoutes ... does not match
|
||||
bad-everything [".*"] rc 1 apiRoutes ... does not match
|
||||
bad-mode ["^/api/"] on mode: oidc rc 1 const 'forward-auth'
|
||||
|
||||
So a too-broad regex is rejected **where a human types it**, on every gate run, by the real tool
|
||||
— and the shape invariant fires too.
|
||||
- **THE RENDERER'S REFUSAL REACHES `render-golden` AS A READABLE FAILURE, NOT A TRACEBACK.** With
|
||||
`^/signalr/` swapped for `^/` in a *copy* of the catalog (outside the repo), both invocation
|
||||
shapes exit **rc 1 with 0 tracebacks** — the single-SD form and `--all <dir> -o <dir>
|
||||
--wave-layout --no-image-automation`, which is the one `ci/render-golden.sh` drives:
|
||||
|
||||
render.py: radarr: auth.apiRoutes entry '^/' is not an anchored path regex. Each entry
|
||||
must start with '^/' and continue with literal path characters (e.g. '^/api/',
|
||||
'^/signalr/', '^/api/v3/.*'). ... A too-broad --api-route answers a PAGE LOAD with 401, so
|
||||
a logged-out operator sees a bare error page and is never offered a sign-in: the service
|
||||
looks permanently broken rather than merely unauthenticated.
|
||||
|
||||
So the breadth guard is gated by `render-golden` even on a run where the schema stage skipped,
|
||||
and the operator is told *why* rather than handed a stack trace.
|
||||
- **The schema's new shape invariant.** A non-empty `auth.apiRoutes` requires
|
||||
`auth.mode: forward-auth`, because the flag renders onto the per-app sidecar and no sidecar exists
|
||||
for `oidc`/`native`/`none` — declaring it elsewhere would be a **silent no-op**, which is the
|
||||
failure class this repo keeps paying for.
|
||||
- **Gate:** toolbox `./ci/gate.sh` with the two-mount ENGINE shim — exit code and stage list in the
|
||||
next section, because it is not a pass and that belongs there.
|
||||
- **No mutating cluster access.** `kubectl get/logs/jsonpath` only, `--context k3s-prod` named on
|
||||
every call. No `apply`/`edit`/`patch`, no `flux reconcile`. No Secret `.data` or `.stringData` was
|
||||
read or printed; I did not need to touch a Secret at all this session. The one container I ran
|
||||
locally was the pinned oauth2-proxy image, to read its `--help`.
|
||||
- **`~/.git-credentials` was never read.** PR creation used `git credential fill`, which does not
|
||||
echo the value.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **NO BROWSER ROUND-TRIP. BY ANYONE. FOR THIS FLAG.** This is the most important line here. The
|
||||
302/401 split, the dropped POST bodies and the orphaned SignalR poll are all read from access
|
||||
logs; that a **401 repairs** the operator's experience is reasoned from HTTP semantics, not
|
||||
watched. Nobody has added a Jackett indexer or recovered a Radarr tab through this
|
||||
configuration. The human test below is the first real evidence this change will ever have.
|
||||
- **Local gate: rc 3 (UNDETERMINED), not 0.** Green: `spec-immutability`, `decisions-log`, `lint`,
|
||||
`l0`, `render-golden`, `l1`. **Skipped:** `gitea-settings` (no `SETTINGS_API_TOKEN` locally) and
|
||||
**L2/L3 conformance** (`CLUSTER_AVAILABLE` unset). Nothing FAILED; nothing runtime was certified
|
||||
either. **rc 3 is UNDETERMINED, never green.**
|
||||
- **MY LOCAL `approval-policy: green` WAS MEANINGLESS, AND I ALMOST REPORTED IT AS EVIDENCE.**
|
||||
`ci/approval-policy.sh:47` reads `GITHUB_EVENT_NAME`, which is unset outside CI, so the stage
|
||||
short-circuits at line 50: `ok "not a pull_request event (event='none') — approval policy N/A"`.
|
||||
**A clean local gate therefore CANNOT catch a sensitive-path problem — structurally, not by
|
||||
accident.** Flagged to me by the coordinator from Ines Dubois's round, and I verified it by
|
||||
re-running the gate with `GITHUB_EVENT_NAME=pull_request PR_NUMBER=34`: the stage then
|
||||
**evaluates** and fails closed on the missing token —
|
||||
|
||||
[fail] no SETTINGS_API_TOKEN — cannot read this PR's changed files or its reviews.
|
||||
[fail] stage approval-policy: FAIL
|
||||
[fail] approval-policy: a sensitive change lacks a non-author approval -> blocks merge
|
||||
[fail] MERGE GATE: BLOCKED (rc 1, not 3)
|
||||
|
||||
— while **every other stage stays green** (`spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||
`render-golden`, `l1`). That is the useful result: it bounds the CI failure to this one stage
|
||||
rather than leaving "something is red" as the whole report. Fail-closed is correct here and
|
||||
deliberate: unlike `gitea-settings`, where a missing credential is a lock-out, a skip of this
|
||||
stage would be a bypass (CLAUDE.md §1).
|
||||
- **CI, read from each commit's `/statuses` rather than from a tick.** Both contexts report, and
|
||||
**only one of them runs**. Identical on `99560c2d`, `df1b25b6` and `f1d16f44`:
|
||||
|
||||
gate / gate (pull_request) state=failure desc='Failing after ~1m43-1m45s'
|
||||
gate / conformance (pull_request) state=success desc='Has been skipped'
|
||||
|
||||
**That second line is §2b's trap in the wild**: a *skipped* Gitea job reports
|
||||
`state=success`. The conformance job did not execute, so its green tick certifies nothing —
|
||||
read the description, not the colour. The duration is given as a range on purpose: it differs
|
||||
by a second or two per run, and pinning one number inside the file whose commit triggers the
|
||||
next run is a self-reference, not a measurement. The load-bearing fields are the **state** and
|
||||
the **description**, and those are stable across all three.
|
||||
|
||||
Worth recording that the **same context changed its answer over time**: on the first push it
|
||||
briefly read `state=pending desc='Blocked by required conditions'` — still waiting on the gate
|
||||
job it depends on — and resolved to `success` only once that job settled. So sampling a status
|
||||
once can show either a pending **or** a green tick for a job that never executed.
|
||||
**I could NOT read the CI job log to confirm the failing stage from CI's
|
||||
own output**: this Gitea's `/api/v1/repos/{owner}/{repo}/actions/runs` endpoint answers
|
||||
**404** (only `/actions/tasks` exists, which lists tasks without logs), and
|
||||
`/actions/jobs/{task_id}/logs` answers `404 Not found` for the task id. So my attribution of the
|
||||
CI failure to `approval-policy` rests on the local event-set reproduction above plus the
|
||||
sensitive-path rule, **not** on CI's own log. That is weaker evidence than PR #28 had and I am
|
||||
naming it rather than borrowing its wording.
|
||||
- **Branch protection and the runner, RE-MEASURED this session against the live API** (§2b),
|
||||
not inherited from CLAUDE.md §2's table:
|
||||
|
||||
GET /repos/Home-Network/home-network-k3s-impl/branches/main
|
||||
protected True
|
||||
user_can_push False
|
||||
enable_status_check True
|
||||
status_check_contexts ['gate / gate (pull_request)']
|
||||
required_approvals 1
|
||||
GET /admin/actions/runners
|
||||
id=1 name=incluster-dind status=online
|
||||
|
||||
So `required_approvals=1` applies to **every** PR here regardless of path — this needs a human
|
||||
even without the sensitive-path rule. I did **not** provoke a rejected push to `main` this
|
||||
session; `user_can_push: False` is the API's word, which is exactly the weaker form of evidence
|
||||
CLAUDE.md §2 warns about, and I did not strengthen it.
|
||||
- **`^/api/` ON RADARR IS NOT MY MEASUREMENT.** It is PR #30's, taken on the **pre-fix** pod. The
|
||||
current pod has logged **zero** `/api/` traffic in 23 h, so I could not reproduce it. I judge the
|
||||
breadth risk nil (`/api/v3/` is pure JSON; the UI is at `/`), but if someone wants a strictly
|
||||
self-measured configuration, drop `^/api/` and keep `^/signalr/` — the 232 are entirely SignalR.
|
||||
- **SONARR AND QBITTORRENT ARE UNMEASURED, NOT CLEARED.** Zero traffic in 23 h is zero evidence.
|
||||
qBittorrent's WebUI drives everything through `/api/v2/` and is *plausibly* affected the same way
|
||||
Jackett is; Sonarr is nearly Radarr's codebase. **Both are left alone on purpose** — plausibility
|
||||
is what put a renderer constant on the table in the first place. Expect to add `^/api/` to
|
||||
qBittorrent and `^/api/` + `^/signalr/` to Sonarr once someone opens those UIs and records the
|
||||
split. **This PR does not fix them.**
|
||||
- **PiHole is asserted to serve no `/api/` on inherited evidence, not mine.** Its log shows 0
|
||||
unauthenticated requests, which says nothing about what paths it serves. The claim comes from
|
||||
PR #28/#30. Note the pinned `update.range` is `>=2024.0.0`, and PiHole v6 *does* introduce a REST
|
||||
API at `/api/` — so if PiHole is ever upgraded across that line, this needs re-measuring. It is
|
||||
still correct to declare nothing today, because declaring an unmeasured route is the mistake.
|
||||
- **`--cookie-refresh=2m30s` has STILL never been exercised** — 0 `AuthSuccess` on all five pods.
|
||||
PR #28 named this as the fix most likely to need a second pass and it remains unobserved. My
|
||||
change does not touch it, but a sign-in during the human test below will exercise both at once,
|
||||
so a failure there must be attributed carefully.
|
||||
- **The regexes are not validated against the apps' real route tables.** I assert `/api/v3/` and
|
||||
`/signalr/` are JSON/RPC namespaces a browser never navigates to from Radarr's and Jackett's
|
||||
observed traffic and from how those UIs are built — **not** from reading their routing code or
|
||||
enumerating their endpoints. A path I have not thought of that matches `^/api/` and *is*
|
||||
browser-navigated would 401.
|
||||
- **`OPTIONS` preflight and non-browser clients are unconsidered.** `--skip-auth-preflight` exists
|
||||
and is not set; nothing in these logs shows a CORS preflight, and Sonarr/Radarr talk to Jackett
|
||||
**directly on `:9117`**, bypassing the proxy entirely — so I do not expect impact. Unverified.
|
||||
- **The live pods do not carry this change and nothing re-measures that they will.** git is not the
|
||||
cluster (§2b). **Merging is what deploys**, and it will roll **jackett** and **radarr** only
|
||||
(`strategy: Recreate` on RWO Longhorn volumes, so a brief gap per app, and radarr/sonarr lose
|
||||
direct `:9117` access to Jackett while its pod restarts). Existing sessions survive — no cookie
|
||||
flag changed — so unlike PR #28 nobody is signed out.
|
||||
- **No L2/L3 assertion reads any of this.** There is no runtime check that a `/api/` request to a
|
||||
gated app answers 401, and I did not write one; `CLUSTER_AVAILABLE` is unset so it could not run
|
||||
in CI anyway. The whole guard is static.
|
||||
- **`catalog/render.py --selftest` is UNGATED IN MY BASE, and I did not wire it.** Desmond's PR #33
|
||||
does that (`ci/render-golden.sh`) and is in review, not merged — `origin/main` at `cdf3e14`
|
||||
contains no stage that drives it, which I checked rather than assumed (`grep -n selftest
|
||||
ci/render-golden.sh` → no match). **So until #33 lands, my new selftest assertions are
|
||||
dev-time only**, and the *gated* half of my breadth ratchet is the schema `pattern` (via
|
||||
`ci/l0.sh`'s schema stage), the renderer's own `raise` (via `render-golden`), and the new L0
|
||||
check. I deliberately did not wire the selftest myself: #33 already does it, and duplicating it
|
||||
here would collide with his branch in the one file he owns. Note the two PRs are **independent
|
||||
but complementary** — his makes the stage real, mine makes its output readable when it fires.
|
||||
- **I did NOT verify PR #33's own claims.** The 106-assertion count, the
|
||||
selftest→stage→`gate.sh`→`BLOCKED` propagation chain, and the `--cookie-refresh 2m30s → 10m`
|
||||
mutation being caught by the selftest but not by `render-golden` are all his measurements,
|
||||
relayed to me by the coordinator. I reproduced only the StopIteration defect itself, because that
|
||||
is the part I was asked to fix. If his branch is reworked, nothing in mine depends on it.
|
||||
- **The new check does not read the spec repo** and asserts nothing about `../home-network-k3s`
|
||||
(R-TEST-06, read-only). `schemas/renderer-contract.md` is updated in **this** repo only; the
|
||||
canonical spec `docs/13-service-catalog.md` is **not** updated, because it is immutable from here.
|
||||
Someone with spec write access should mirror `auth.apiRoutes` into docs/13 and docs/15.
|
||||
- **THE OAUTH2-PROXY SIDECARS' METRICS ARE STILL UNSCRAPABLE, AND THIS PR DOES NOT FIX IT.** See
|
||||
the declined-bundle section above. #28 shipped `--metrics-address` as a prerequisite and said
|
||||
plainly that it was not scraped; Ines has now measured that the listener **serves** and that the
|
||||
scrape is **dropped by each app namespace's default-deny**. Nothing in my change touches that.
|
||||
Anyone reading this entry as "the fleet gates are observable now" is wrong, and the next PR is
|
||||
where that changes.
|
||||
- **Alternation is refused, and that is a judgement I may have to revisit.** No app in this catalog
|
||||
needs it, and the flag is repeatable — but if some future app genuinely needs a regex the ratchet
|
||||
rejects, the fix is to widen the pattern **with a reason** on all three surfaces at once, not to
|
||||
delete the ratchet on one.
|
||||
- **CI status: two contexts, and only one of them ran.** Quoted in the PR body and in the report to
|
||||
the PM. `approval-policy` **fails by design** until a non-author approving review exists, because
|
||||
the diff touches `conformance/**`, which `infra/gitea/approval-policy.json` lists as sensitive
|
||||
("the tests and the harness; changing them changes what green MEANS"). That is CLAUDE.md §1
|
||||
working, not a defect. `required_approvals=1` applies to every PR here regardless of path, so this
|
||||
needs a human either way.
|
||||
|
||||
### The human test — and how to tell a too-broad regex from a working one
|
||||
|
||||
After merge, wait for **new** pod names at 2/2 in `jackett` and `radarr`.
|
||||
|
||||
1. **The breadth test FIRST, before anything else, from a signed-out browser** (private window).
|
||||
Open `https://radarr.home.keithjackson.dev/` and `https://jackett.home.keithjackson.dev/UI/Dashboard`.
|
||||
**Each must bounce through Keycloak and land on the app's UI.** If either shows a bare
|
||||
`401 Unauthorized` instead of a sign-in, **the regex is too broad — stop and revert.** That is
|
||||
the one failure this change can introduce, and it is the only test that can see it. A working
|
||||
regex is invisible on a page load; a broken one is total and immediate.
|
||||
2. **Jackett, the original report:** signed in, *+ Add indexer* → pick one → *Configure* → *Okay*.
|
||||
**It must save.** Previously 4 of 6 POSTs never reached Jackett at all.
|
||||
3. **Radarr, the live symptom:** open Radarr and leave the tab open. DevTools → Network, filter
|
||||
`signalr`. Today those negotiate calls are **302** and the UI never complains. They must now be
|
||||
**200** while signed in — and if the session is ever lost, **401**, with the UI showing a visible
|
||||
connection error instead of polling silently forever.
|
||||
4. **The loud-failure test:** in DevTools, delete the `_oauth2_proxy_radarr` cookie and let the tab
|
||||
poll. The negotiate call must answer **401** and Radarr must *show* a problem. Then **reload** —
|
||||
`/` matches neither regex, so the reload must sign you back in silently. That round trip is the
|
||||
entire fix: fail loudly on the API, recover on the page load.
|
||||
5. **The six-minute test (PR #28's open item, still open):** stay on a dashboard for >6 minutes,
|
||||
then save something. That exercises `--cookie-refresh=2m30s` past the 300 s access-token cliff.
|
||||
If it fails, suspect the realm's refresh/SSO-idle timeouts — not this change.
|
||||
6. **If something still fails, it must now fail LOUDLY.** A silent success-looking no-op means the
|
||||
change did not take: check the running pod's args for `--api-route` before assuming anything
|
||||
else.
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
date: 2026-10-01
|
||||
branch: fix/render-selftest-runs-in-gate
|
||||
title: catalog/render.py's 106 assertions were dev-time only; the gate now runs them and blocks on them
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Single subject, one file: **`ci/render-golden.sh` now invokes `catalog/render.py --selftest`**
|
||||
and exits 1 when it fails. Nothing else. No renderer change, no new conformance test, no new
|
||||
gate stage.
|
||||
|
||||
This closes the top follow-up of `decisions/2026-09-30-oauth2-proxy-cookie-collision-fleet.md`:
|
||||
|
||||
> Nothing in `ci/` or `.gitea/` runs `catalog/render.py --selftest` — I grepped for it. So the
|
||||
> twenty-odd assertions in that selftest, including the six I just added, are dev-time only: a
|
||||
> future edit that drops either flag *and* re-renders the goldens would pass the entire merge
|
||||
> gate.
|
||||
|
||||
Re-measured here and confirmed: before this commit, `grep -rl -- --selftest` over `ci/` and
|
||||
`.gitea/` returned **nothing** for the renderer. The selftest executes **106** assertions
|
||||
(`check()` invocations counted at runtime via `sys.settrace`, without editing `render.py`; 109
|
||||
static call sites, three of them on failure-only branches). All 106 were unexercised by the
|
||||
merge gate.
|
||||
|
||||
### The lead was WRONG, and here is why
|
||||
|
||||
The brief named `conformance/tests/l0-static/auditability-proofs-producible/check.sh:291` as the
|
||||
one-line fix. That line is real and it does drive selftests:
|
||||
|
||||
for tool in ("record_run.py", "status.py"):
|
||||
r = run([sys.executable, str(LIB / tool), "--selftest"])
|
||||
|
||||
But it is **the wrong hook**. That loop is section (d) of a check that serves **R-SEC-03**, and
|
||||
its subject is the *evidence chain* — can a self-skipped run, or a stale ledger row, become an
|
||||
audit proof? `record_run.py` and `status.py` are **in** that chain; `catalog/render.py` is not.
|
||||
Hanging the renderer there would have made the check's name, its `intent` annotation and its
|
||||
requirement mapping (`R-SEC-03`) all untrue, and a future decision about the renderer could then
|
||||
only be expressed by weakening a *security-auditability* check. That is the precise coupling the
|
||||
cookie-collision entry refused for its own check, and it applies here.
|
||||
|
||||
The second option that entry offered — a new scope-declaring L0 test — is the supported mechanism
|
||||
for a **whole-repo invariant** (`ci/l0.sh` Stage 3b's `--include-scope whole-repo` union, ratcheted
|
||||
by `tests/l0-static/whole-repo-invariants-wired`). It is the right shape for an invariant
|
||||
quantified over the tracked tree. It is the wrong shape for *"a script in this repo must pass its
|
||||
own unit test"*: it would need to declare requirements it does not serve, and it would be two new
|
||||
files of several hundred lines by this tree's conventions for what is a six-line invocation.
|
||||
|
||||
**The honest home is `ci/render-golden.sh`** — the gate stage whose subject *already is*
|
||||
`catalog/render.py`. It already resolves `$PYTHON` and `$RENDER_PY`, already reports rc 3 when
|
||||
either is missing, already exits 1 on renderer error, and `ci/gate.sh` already maps it through
|
||||
`for s in lint l0 render-golden l1` where a FAIL is BLOCKED and a SKIP is UNDETERMINED. The
|
||||
stage's two assertions are independent and both are needed:
|
||||
|
||||
- `render-golden` catches golden-vs-renderer drift, but is satisfied by **any** renderer whose
|
||||
output was re-rendered — including one that just lost a contract invariant and had all ten
|
||||
goldens regenerated in the same commit. That is exactly the hole named in the quote above.
|
||||
- `--selftest` catches that, and is blind to golden drift.
|
||||
|
||||
The file header and the stage name were updated so neither is a lie: the stage is now "the
|
||||
renderer is trustworthy", with two numbered assertions, and the step summary emits its own
|
||||
`- render-selftest:` line so the gate output still NAMES what ran.
|
||||
|
||||
The selftest runs **before** the empty-catalog `exit 3` on purpose: it renders synthetic
|
||||
ServiceDefinitions in memory and is catalog-independent, so an empty catalog must not be able to
|
||||
skip it.
|
||||
|
||||
`catalog/render.py` itself was **not touched** (owned by a concurrent branch this session), and
|
||||
neither was any file under `conformance/`.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **The selftest runs and passes: rc 0, `render.py selftest: PASS`, 106 assertions executed.**
|
||||
Counted at runtime, not from the source: a `sys.settrace` hook counting `check()` calls, in a
|
||||
throwaway script outside the repo, with no edit to `render.py`.
|
||||
- **The stage passes with it wired in**, host run of the single stage:
|
||||
|
||||
[gate] Stage: render==golden reproducibility (canonical render vs committed apps/)
|
||||
[gate] Stage: catalog/render.py --selftest (the renderer's own output-contract assertions)
|
||||
[ ok ] render.py --selftest passed
|
||||
[ ok ] render==golden: all 10 onboarded service golden(s) reproduce from the canonical render
|
||||
|
||||
rc **0**. The golden comparison still reports all 10 goldens, so the new invocation did not
|
||||
displace what the stage already asserted.
|
||||
- **IT CAN FAIL — provoked twice, against a mutated COPY of the renderer, never against the
|
||||
tree.** `ci/render-golden.sh` already honours `RENDER_PY`, so each mutant lived in
|
||||
`/tmp/claude-1000/desmond-selftest/` and the repo's `catalog/render.py` was never modified —
|
||||
which matters, because that file belongs to a concurrent branch this session.
|
||||
- **Mutation 1 — the regression this selftest was written for.** Deleted the
|
||||
`f"--cookie-name=_oauth2_proxy_{s['name']}"` emission from `_oauth2_sidecar_container()`,
|
||||
i.e. reverted the fleet-wide cookie-collision fix of PR #28. Stage rc **1**. Note the shape
|
||||
of the failure, because it is a real (minor) weakness and is listed as a follow-up: the
|
||||
selftest died with an uncaught `StopIteration` out of `next(a for a in container["args"] if
|
||||
a.startswith("--cookie-name="))` at `render.py:2641`, rather than printing its collected
|
||||
`SELFTEST FAIL:` lines. It is correctly non-zero and the stage correctly blocks, but the
|
||||
operator gets a traceback instead of the assertion message.
|
||||
- **Mutation 2 — a clean, named assertion failure.** `OAUTH2_COOKIE_REFRESH = "2m30s"` ->
|
||||
`"10m"`, widening the refresh past the measured 300s access-token lifetime. Stage rc **1**:
|
||||
|
||||
[fail] catalog/render.py --selftest FAILED — the renderer violates its own output contract.
|
||||
[fail] Every golden under apps/ is produced by this renderer, so the goldens below cannot
|
||||
[fail] be trusted even if they reproduce byte-for-byte. Reproduce with:
|
||||
[fail] python3 catalog/render.py --selftest
|
||||
SELFTEST FAIL: --cookie-refresh=10m parses to 600.0s, which is not inside (0, 300s). A
|
||||
refresh at or past the access-token lifetime never renews in time, which is the same
|
||||
logout it was added to stop
|
||||
|
||||
This is the mutation that proves the wiring's value most directly: mutation 2 would **not**
|
||||
be caught by `render-golden`'s diff if the goldens were regenerated in the same commit, and
|
||||
it is one of the two flags the cookie-collision entry explicitly recorded as **ungated**.
|
||||
- **THE NON-ZERO PROPAGATES ALL THE WAY TO `gate.sh`'s rc — provoked, in the pinned toolbox, not
|
||||
reasoned about.** `RENDER_PY=/desmond-selftest/render-mutant2.py ./ci/gate.sh`:
|
||||
|
||||
[ ok ] stage spec-immutability: pass
|
||||
[ ok ] stage decisions-log: pass
|
||||
[ ok ] stage lint: pass
|
||||
[ ok ] stage l0: pass
|
||||
[gate] Stage: catalog/render.py --selftest (the renderer's own output-contract assertions)
|
||||
[fail] catalog/render.py --selftest FAILED — the renderer violates its own output contract.
|
||||
SELFTEST FAIL: --cookie-refresh=10m parses to 600.0s, which is not inside (0, 300s). ...
|
||||
[fail] stage render-golden: FAIL
|
||||
[ ok ] stage l1: pass
|
||||
...
|
||||
[fail] render-golden: FAILED -> blocks merge
|
||||
[fail] MERGE GATE: BLOCKED
|
||||
|
||||
`gate.sh` rc **1**. Three links in the chain, each measured: selftest rc 1 -> stage rc 1 ->
|
||||
gate rc 1 / `MERGE GATE: BLOCKED`. This mattered enough to provoke rather than assume: this
|
||||
repo has a documented habit of exit codes getting flattened, and
|
||||
`.gitea/workflows/gate.yml:138` runs `./ci/gate.sh || [ "$?" -eq 3 ]`, which tolerates rc 3 —
|
||||
so a stage that degraded a failure to a skip would be swallowed by CI. rc 1 is not tolerated
|
||||
there, and rc 1 is what this produces.
|
||||
- **`spec-immutability` genuinely RAN and passed, it did not skip.** The two-mount worktree shim
|
||||
plus `-e SPEC_REPO=/home-network-k3s` (the fix recorded in the 2026-09-30 cookie entry) works:
|
||||
`stage spec-immutability: pass`, not SKIPPED. R-TEST-06 is therefore actually covered by these
|
||||
runs.
|
||||
- **No collision with PR #29.** The brief warned that `feat/oauth2-proxy-auth-failure-visible`
|
||||
had added checks under `conformance/tests/l0-static/`. Measured —
|
||||
`git diff origin/main...origin/feat/oauth2-proxy-auth-failure-visible --name-only` — it touches
|
||||
**no `conformance/` path at all**: `decisions/`, `platform/oauth2-proxy/` (a promtool test, an
|
||||
alert rule, its kustomization), `runbooks/`. Disjoint from this branch either way, since this
|
||||
branch touches only `ci/render-golden.sh` and this file.
|
||||
- **Priya's files were not touched.** `catalog/render.py`, `schemas/servicedefinition.schema.json`,
|
||||
`apps/wave-*` and `conformance/tests/l1-rendering/` are all untouched by this branch — confirmed
|
||||
by `git diff --name-only`, which lists exactly two paths.
|
||||
- **`catalog/render.py --selftest` was the LAST unwired selftest in the tree.** Measured:
|
||||
`grep -rl -- --selftest` across the repo finds selftest-capable artifacts in exactly four
|
||||
places, and after this commit every one is driven by the gate — `conformance/lib/record_run.py`
|
||||
and `conformance/lib/status.py` by `auditability-proofs-producible/check.sh:291`, the Keycloak
|
||||
client-secret reconciler (embedded in `platform/keycloak/client-secret-reconciler.yaml`) by
|
||||
`keycloak-client-secret-reconciler/check.sh:633`, and `catalog/render.py` by this change.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Local gate: rc 3 (UNDETERMINED), NOT 0. This is not green and must not be reported as
|
||||
green.** Measured in the pinned toolbox on `eeb6211`:
|
||||
|
||||
[ ok ] spec-immutability: green
|
||||
[ ok ] approval-policy: green
|
||||
[ ok ] decisions-log: green
|
||||
[ ok ] lint: green
|
||||
[ ok ] l0: green
|
||||
[ ok ] render-golden: green
|
||||
[ ok ] l1: green
|
||||
[fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED.
|
||||
[fail] Stages that did not run: gitea-settings
|
||||
|
||||
Nothing FAILED. The single cause is `gitea-settings` **SKIPPED** — `SETTINGS_API_TOKEN` is not
|
||||
set in my environment, so branch protection and runner registration were not read; CI has that
|
||||
token and the stage is expected to run there. **L2/L3 `conformance`** also skipped
|
||||
(`CLUSTER_AVAILABLE` unset) but is a *declared* gap that `gate.sh` treats as not-applicable
|
||||
rather than undetermined, so it is not what produced the 3. Touched requirements
|
||||
**R-GITOPS-01 R-TEST-02 R-TEST-03 R-TEST-06** are therefore **UNVERIFIED at L2/L3**, all four.
|
||||
The stage this branch changes, `render-golden`, is green **and** was independently proven able
|
||||
to turn the whole gate red (above).
|
||||
- **`approval-policy` passed LOCALLY only because there is no pull_request event**
|
||||
(`not a pull_request event (event='none') — approval policy N/A`). That is not evidence that it
|
||||
passes. This diff touches `ci/**`, a sensitive path, so in CI the stage is expected to **FAIL**
|
||||
until a non-author approving review exists. That is §1 working, fail-closed, not a defect.
|
||||
- **The selftest's own quality is unchanged and ungated.** I wired its *invocation*; I did not
|
||||
review its 106 assertions, and nothing ratchets them. Deleting the body of `_selftest()` would
|
||||
still pass this stage, because an empty selftest returns 0. The repo already has the right
|
||||
answer for that — `conformance/tests/l0-static/keycloak-client-secret-reconciler/mutations.py`
|
||||
injects regressions one at a time and requires `--selftest` to catch every one — and applying
|
||||
that pattern to `catalog/render.py` is the follow-up, deliberately not done here: it is a
|
||||
second subject, it touches `conformance/`, and it would need to live beside the renderer whose
|
||||
file another branch owns this session. **Until it exists, "the selftest runs" is proven and
|
||||
"the selftest is falsifiable" rests on my two hand-run mutations, which are not in the gate.**
|
||||
- **Mutation 1's traceback is not fixed.** `render.py:2641`'s `next(...)` has no default, so the
|
||||
absence of `--cookie-name` raises `StopIteration` and the selftest's collected `SELFTEST FAIL:`
|
||||
lines are never printed. Correct verdict, poor diagnostics, and it suppresses the other
|
||||
assertions that mutation also broke. The one-line fix (`next(..., None)` plus a `check()`) is in
|
||||
`catalog/render.py`, which is another branch's file this session, so it is a follow-up and not
|
||||
a thing I touched.
|
||||
- **Nothing about the live cluster.** No `kubectl` of any kind was run on this branch — not even
|
||||
read-only; this change is offline CI wiring and needed none. No `apply`/`edit`/`patch`, no
|
||||
`flux reconcile`. Merging this deploys nothing: it changes only what the gate refuses.
|
||||
- **CI's own run of this branch.** Quoted in the PR body from the commit's `/statuses`, with
|
||||
descriptions read rather than colours. A *skipped* Gitea job reports `success` with
|
||||
"Has been skipped" and certifies nothing.
|
||||
- **`vars.CLUSTER_AVAILABLE` is still unset in CI**, so even a fully green PR proves the L0/L1
|
||||
offline subset and nothing about runtime behaviour.
|
||||
- **I did not re-measure branch protection, required approvals, or the runner.** Those are
|
||||
CLAUDE.md §2 claims and §2b says re-measure before relying on them; this branch relies on none
|
||||
of them beyond the PR flow itself, and I read the gate's own output rather than the API.
|
||||
@@ -0,0 +1,149 @@
|
||||
---
|
||||
date: 2026-10-02
|
||||
branch: fix/plex-quicksync-verification
|
||||
title: Hardware transcoding works — and `runbooks/plex-quicksync.md`'s verification section was wrong
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**Hardware transcoding is confirmed working on prod.** Measured 2026-10-02 from
|
||||
`Plex Media Server.log` in the running pod: `vaapi` on `/dev/dri/renderD128`
|
||||
(`Intel CoffeeLake-S GT2 [UHD Graphics 630]`), `using hardware decode accelerator vaapi`,
|
||||
`zero-copy support present`, `using zero-copy transcoding`, and
|
||||
`final decoder: vaapi, final encoder: vaapi`. VAAPI for **both** directions, zero-copy, so frames
|
||||
never round-trip through the CPU. The device-plugin mechanism from PR #37 works end to end.
|
||||
|
||||
**And `runbooks/plex-quicksync.md`'s verification section was wrong, which is the correction this
|
||||
branch exists to publish.** The page said the Plex dashboard showing `Transcode (hw)` was "the *only*
|
||||
proof available, and nothing else will ever tell you". The operator had hardware transcoding fully
|
||||
working while **Dashboard → Activity showed nothing of the sort** — so the page's authoritative check
|
||||
was silent on a working system, and sent someone looking for a fault that did not exist. Three
|
||||
inference errors are now corrected on the page, two of them recorded as named false leads so the next
|
||||
reader does not re-walk them:
|
||||
|
||||
1. **The dashboard is not proof.** Replaced as the primary check by Plex's own log — the grep is in
|
||||
the page, in its **redacting** form, because `Plex Media Server.log` carries API URLs containing
|
||||
`X-Plex-Token` (CLAUDE.md §6; a credential has been leaked into a transcript here before). The
|
||||
page also now says explicitly that **`Plex Transcoder Statistics.log` is the wrong file** — it
|
||||
holds XML `<Variant .../>` records, not ffmpeg output, and the name invites the mistake — and why
|
||||
`grep -a` is required. The dashboard is **kept but demoted** to a secondary, best-effort signal,
|
||||
with its absence stated to prove nothing.
|
||||
2. **An fd snapshot is not evidence.** A `/proc/*/fd` sweep inside the pod found no holder of
|
||||
`renderD128` while hardware transcoding was active; transcode sessions are short and the scan
|
||||
landed in a gap. Recorded as a documented false lead, with the reliable process-level check in its
|
||||
place: the transcoder's **argv** (`-hwaccel`, `-hwaccel_device`, `vaapi`, `renderD128`), found via
|
||||
`ps -eo pid,user,comm | grep -i transcoder`.
|
||||
3. **A missing preference key does not mean the setting is off.** The page told readers to treat an
|
||||
absent `HardwareAcceleratedCodecs` in `Preferences.xml` as "never enabled". With hardware
|
||||
acceleration demonstrably on and working, that key was **absent from all 27 attributes** while
|
||||
`HardwareDevicePath` **was** written and the log said `hardware transcoding: enabled`. First-command
|
||||
#4 now reads both keys and states plainly that `HardwareDevicePath` present is a positive signal,
|
||||
absence of `HardwareAcceleratedCodecs` means nothing either way, only `="0"` is conclusive, and the
|
||||
log settles it.
|
||||
|
||||
Also on the page:
|
||||
|
||||
- **GID `992` is no longer presented as a number to expect.** Under the plugin the injected device
|
||||
arrives `root:root`, so the image's `cont-init.d` hook resolves GID **`0`** and adds `plex` to the
|
||||
**root group** — measured `Groups: 0 100 1000`, `id plex` → `groups=1000(plex),0(root),100(users)`.
|
||||
992 was the hostPath-era host `render` GID. The page now says so, marks the root-group membership as
|
||||
an inherent property of the plugin's `DeviceSpec` rather than a defect, and repeats that it is
|
||||
**not** a reason to reintroduce `supplementalGroups` (still a no-op: `s6-setuidgid` rebuilds the
|
||||
group list from `/etc/group`). The stale `Groups: 100 1000` claim in the escalation section is gone.
|
||||
- **A new troubleshooting entry for legitimate per-title fallbacks**, marked as a **hypothesis**.
|
||||
Between two fully hardware sessions, one logged `enabled, but no hardware decode accelerator found`
|
||||
and empty final decoder/encoder. UHD 630 decodes H.264, HEVC 8/10-bit and VP9 but **not AV1** and
|
||||
not 10-bit 4:2:2 HEVC, so the likely cause is the source file, not the platform — explicitly
|
||||
written as a lead to check first, **not** a confirmed diagnosis of that item.
|
||||
- The status is stated at the top of the page and in a rewritten provenance footer that separates the
|
||||
2026-10-02 confirmations from the superseded 2026-09-30/2026-10-01 hostPath-era readings.
|
||||
- `runbooks/README.md`'s index entry for the page was stale in the same two ways (it still described a
|
||||
`/dev/dri` hostPath and the dashboard-only proof) and is corrected to match.
|
||||
|
||||
Unchanged on purpose: the "STOP HERE IF PLEX IS `Pending`" section and the escape hatch (delete the
|
||||
`hardware:` block from `catalog/plex.yaml`, re-render) keep exactly their existing prominence — they
|
||||
are correct and they are the 2am material. The "no alert is possible" reasoning is also kept verbatim
|
||||
in substance: Plex declares no `metricsEndpoint`, renders no ServiceMonitor, and a fallback alert
|
||||
would be a rule evaluating against nothing.
|
||||
|
||||
**Documentation only.** No change to `catalog/`, `schemas/`, `apps/` or `platform/`.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Gate, in the pinned toolbox from this worktree** (`DEV_KUBECONFIG=/dev/null`, `ENGINE` shim
|
||||
bind-mounting the main checkout's `.git`): **rc 3 UNDETERMINED**, diff base
|
||||
`origin/main @ 948875aad37d`, with exactly the three expected skips and **no failures**. Green:
|
||||
`decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy`. Skipped:
|
||||
`spec-immutability` (spec repo unavailable → R-TEST-06 not verified), `gitea-settings`
|
||||
(`SETTINGS_API_TOKEN` unset), `conformance` (`CLUSTER_AVAILABLE!=true` → L2/L3 not run).
|
||||
Touched requirements: **R-OBS-04**, reported `UNVERIFIED` for lack of a cluster. `approval-policy`
|
||||
passed as `N/A` locally (`event='none'`, not a `pull_request`) — it evaluates for real on the PR.
|
||||
**rc 3 is not green**, it is the calibrated worktree result, and it is reported here as rc 3.
|
||||
- **The gate was run three times, with the same rc 3 / three-skip result on each committed tip** —
|
||||
including the final one. Worth recording from the first attempt: run *before* committing, with
|
||||
`HEAD == origin/main`, the touched-requirement set is `NOT DETERMINABLE (on-base)` and the
|
||||
R-TEST-03 coverage gate cannot be applied at all. Commit before gating from a fresh worktree branch.
|
||||
- **`runbooks/kustomization.yaml` still carries `- plex-quicksync=plex-quicksync.md`** in the
|
||||
`configMapGenerator` `files:` list (line 110) — checked explicitly, because without it the page
|
||||
merges and still 404s. `README=README.md` is likewise present, which is what serves the corrected
|
||||
index entry.
|
||||
- **This change touches no `sensitive_paths`.** `infra/gitea/approval-policy.json` lists `ci/**`,
|
||||
`.gitea/**`, `conformance/**`, `clusters/**`, `bootstrap/**`, `infra/gitea/**`,
|
||||
`platform/kyverno/**`, `platform-config/kustomization.yaml`, `.sops.yaml`, `**/*.sops.yaml`,
|
||||
`**/rbac.yaml`, `CLAUDE.md`. `runbooks/**` and `decisions/**` are not among them, and the
|
||||
`approval-policy` stage passed.
|
||||
- **No YAML was added or edited**, so `indent-sequences: consistent` (which blocked a sibling branch
|
||||
today) is not in play here; `lint` passed over the full tracked YAML set regardless.
|
||||
- **`DECISIONS-LOG.md` is untouched** — this handoff is its own file, per CLAUDE.md §7.
|
||||
- **The PR's own CI run was read, not assumed** (PR #39, run 208, task 261, head `1677dff5688b`).
|
||||
**It is RED, and not because of this branch.** Every stage this change could affect is green —
|
||||
`decisions-log`, `lint`, `l0`, `render-golden`, `l1`, `approval-policy` (`approval-policy` evaluated
|
||||
for real on the `pull_request` event and passed: no sensitive path touched). The single failure is
|
||||
**`gitea-settings`**, quoting the measured field:
|
||||
|
||||
[fail] required_approvals: DECLARED 1 but LIVE is 0
|
||||
[fail] Gitea has DRIFTED from infra/gitea/expected-branch-protection.json.
|
||||
[fail] MERGE GATE: BLOCKED
|
||||
|
||||
Every other declared setting matched (`block_admin_merge_override`, `block_on_rejected_reviews`,
|
||||
`dismiss_stale_approvals`, `enable_status_check`, `enable_push=false`,
|
||||
`status_check_contexts = ["gate / gate (pull_request)"]`, `runners online: 1`). So **live `main`
|
||||
currently requires ZERO approvals**, the declaration still says 1, and with
|
||||
`REQUIRE_GITEA_SETTINGS: "true"` in `.gitea/workflows/gate.yml` that drift **blocks every PR in
|
||||
the repo**, not just this one. Note the shape of it: the check that would notice approvals are
|
||||
unenforced is the check that is failing. Local branch `fix/admin-approval-override` (`616a497`,
|
||||
"fix(ci): let an administrator merge their own green PR") looks like the in-flight work that means
|
||||
to change the declaration. **I did not touch it** — `infra/gitea/**` is a sensitive path, needs a
|
||||
non-author approval, and is outside this branch's documentation-only scope. It is an operator
|
||||
decision: re-assert the protection, or change the declaration deliberately in its own PR.
|
||||
- The repository-wide grep for `992` to find what else still asserts it: remaining occurrences are in
|
||||
`catalog/plex.yaml`, `catalog/render.py`, `schemas/renderer-contract.md`,
|
||||
`platform/intel-gpu-plugin/README.md`, `platform/intel-gpu-plugin/kustomization.yaml`, and three
|
||||
older `decisions/` entries.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **I did not re-measure anything on the live cluster in this session.** Every platform number on the
|
||||
page — the VAAPI/zero-copy log lines, `RW-OPEN-OK` as uid 1000, the `gpu.intel.com/i915` limits and
|
||||
requests on the pod spec, `Groups: 0 100 1000`, `id plex`, the 27 `Preferences.xml` attributes, the
|
||||
empty `/proc/*/fd` sweep — came to me as measurements taken earlier on 2026-10-02 and was
|
||||
transcribed, not re-run. Per CLAUDE.md §2b, treat the page as reporting those readings rather than
|
||||
as proof of current state; re-run the primary check before relying on it.
|
||||
- **The per-title fallback cause is unverified and labelled as such.** The source codec of the title
|
||||
that logged `no hardware decode accelerator found` was never read. The AV1 / 10-bit-4:2:2-HEVC
|
||||
explanation is inferred from UHD 630's capability set alone.
|
||||
- **Whether Plex's dashboard *ever* shows `Transcode (hw)` on this deployment is unknown.** It was
|
||||
observed silent during working hardware transcoding; whether that is a client/version quirk, a
|
||||
reporting bug, or something about this setup was not investigated. The page therefore demotes it
|
||||
rather than explaining it.
|
||||
- **The commands as written in the page were not executed from the page.** They are the forms that
|
||||
were run, re-wrapped for `dev.sh`; in particular the `exec -it deploy/plex -- sh` route for the argv
|
||||
check is noted as possibly lacking a TTY under `dev.sh run` and was not exercised in that shape.
|
||||
- **Stale `992` copies outside `runbooks/` were left alone**, deliberately — scope was documentation
|
||||
only, and `catalog/plex.yaml`, `catalog/render.py`, `schemas/renderer-contract.md` and
|
||||
`platform/intel-gpu-plugin/` all still carry the hostPath-era `Groups: 44 100 992 1000` reading as
|
||||
if current. `render.py --selftest` asserts around that comment, so correcting it is a real change
|
||||
with gate consequences, not a doc edit. **That is open work.** Older `decisions/` entries also cite
|
||||
992; those are historical records and should stay as written.
|
||||
- **CI on the PR.** The branch is pushed and the PR opened; I did not watch the Gitea run report, so
|
||||
nothing here should be read as "CI green". The only gate result I can quote is the local rc 3 above.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
date: 2026-10-03
|
||||
branch: fix/cilium-cgroup-hostroot
|
||||
title: Cilium socket-LB never covered host processes — cgroup.hostRoot pointed at an unmounted path
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
`cgroup.hostRoot` changes from `/run/cilium/cgroupv2` to `/sys/fs/cgroup` in all three parity
|
||||
copies: `platform/cilium/helmrelease.yaml` (authoritative), `platform/cilium/values.yaml`
|
||||
(mirror) and `infra/ansible/roles/cilium/templates/cilium-values.yaml.j2` (bootstrap).
|
||||
`autoMount` stays `false`.
|
||||
|
||||
**The defect.** With `autoMount: false`, the Cilium chart only bind-mounts whatever exists at
|
||||
`hostRoot`, and expects the operator to have mounted cgroup2 there (cilium 1.20.1 values.yaml).
|
||||
The repo's rationale was "k3s provides the cgroupv2 mount". That is true only for
|
||||
`/sys/fs/cgroup`. Nothing mounts cgroup2 at `/run/cilium/cgroupv2` on these nodes; it is an
|
||||
empty `DirectoryOrCreate` hostPath. So the agent mounted cgroup2 there itself, from inside its
|
||||
own private cgroup namespace, and that mount's root is the agent container's own leaf cgroup.
|
||||
The socket-LB programs (`cil_sock4_connect` etc.) were attached to it and covered only the
|
||||
agent.
|
||||
|
||||
**The consequence.** Every other host-netns process got no Service/LB translation:
|
||||
- Service ClusterIPs were unreachable from every node's host.
|
||||
- The node holding the Gateway's L2 lease could not reach the Gateway VIP (`no route to host`).
|
||||
Other nodes reached it only because their packets crossed the wire to the lease holder, where
|
||||
the tc datapath handles them.
|
||||
- k3s-prod-1 holds both the Gateway lease and the kube-vip API VIP. Its apiserver could not reach
|
||||
the OIDC issuer, logging `oidc: authenticator not initialized` since at least 2026-09-12
|
||||
(899 occurrences by 2026-10-03). kubectl SSO through the VIP therefore failed, and in-cluster
|
||||
OIDC clients failed on whichever calls landed on that apiserver.
|
||||
- Webhooks and aggregated APIs were unaffected only because k3s runs
|
||||
`--enable-aggregator-routing=true` (endpoint IPs, not ClusterIPs).
|
||||
|
||||
Cilium's own source (v1.20.1 `bpf/bpf_sock.c`, `__sock4_xlate_fwd`) handles exactly this case
|
||||
when the hook does run: a host-netns connect to an L7-LB Service (the Gateway) is rewritten to
|
||||
`127.0.0.1:<l7_lb_proxy_port>`, the local Envoy listener. The fix is to make the hook cover the
|
||||
host.
|
||||
|
||||
## What I verified
|
||||
|
||||
- `cilium-dbg status --verbose` on k3s-prod-1: `Socket LB: Enabled`, `Coverage: Hostns-only`.
|
||||
The live cilium-config shows `bpf-lb-sock=false`, but socket LB is implied by kube-proxy
|
||||
replacement. The Gateway Service frontends `192.168.2.20:{80,443}` and
|
||||
`10.43.225.79:{80,443}` map to `127.0.0.1:14337`.
|
||||
- Host: `findmnt /run/cilium/cgroupv2` is empty on all three nodes, and `/sys/fs/cgroup` is
|
||||
`cgroup2`.
|
||||
- In the agent: `/run/cilium/cgroupv2` is a `cgroup2 none` mount, the agent's own cgroup reads
|
||||
`0::/`, the mount has 0 child cgroups, and `bpftool cgroup show /run/cilium/cgroupv2` lists all
|
||||
`cil_sock*` programs. k3s runs in `0::/system.slice/k3s.service`, and the agent container in
|
||||
`/kubepods.slice/…/cri-containerd-3ad04cde….scope`.
|
||||
- **The decisive test**, run on k3s-prod-1 in the same netns: raw TCP connect to
|
||||
`192.168.2.20:443`, `10.43.225.79:443` and `10.43.59.141:8080` CONNECTED from inside the agent
|
||||
container (covered cgroup) and FAILED from the host (`No route to host` / timeout).
|
||||
- Blast radius: host-network pods are cilium, cilium-envoy, cilium-operator (API via
|
||||
`192.168.2.9:6443`, not a Service), kube-vip (`127.0.0.1:6443`, `svc_enable=false`) and
|
||||
node-exporter (no outbound). Nothing depends on host Service translation being absent; the
|
||||
change only makes Service/LB addresses reachable from the host where they currently fail.
|
||||
- `helm template cilium/cilium --version 1.20.1` with the new values, in the toolbox: the
|
||||
DaemonSet bind-mounts hostPath `/sys/fs/cgroup` into `cilium-agent` and `clean-cilium-state`,
|
||||
the cilium-config shows `cgroup-root: "/sys/fs/cgroup"`, and the init containers are unchanged
|
||||
(no `mount-cgroup`).
|
||||
- Toolbox gate: see the PR description.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The rollout itself.** After merge, the agent DaemonSet rolls one node at a time
|
||||
(`maxUnavailable: 1`). On each node, afterwards: host TCP connects to the three targets above
|
||||
must succeed. On k3s-prod-1, the apiserver's background OIDC init retry must stop logging
|
||||
`authenticator not initialized` with no k3s restart.
|
||||
- That the old programs, attached to the previous agent container's cgroup, disappear with it.
|
||||
Expected, because cgroup BPF attachments die with the cgroup, but not observed.
|
||||
- An end-to-end kubectl OIDC login (needs a browser).
|
||||
- No regression test was added. A natural one is an L2 conformance check that a host-netns
|
||||
connect to the Gateway VIP succeeds on the lease holder, but `conformance/**` is a sensitive
|
||||
path. Left as a follow-up.
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
date: 2026-10-03
|
||||
branch: fix/node-keycloak-hosts-pin
|
||||
title: Pin the OIDC issuer to the Gateway VIP on prod nodes — the WAN answer does not hairpin
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Follow-up to PR #40 (`decisions/2026-10-03-public-dns-wan-target.md`). Public DNS for
|
||||
`*.home.keithjackson.dev` now CNAMEs to the WAN IP. The prod nodes resolve through public DNS,
|
||||
and the router does not hairpin, so the kube-apiserver could no longer reach its OIDC issuer
|
||||
`keycloak.home.keithjackson.dev`.
|
||||
|
||||
- `roles/node_baseline`: a new `node_baseline_hosts_pins` list (default `[]`) and one
|
||||
`blockinfile` task (tags `dns`, `hosts`). It writes the pins into `/etc/hosts` as a single
|
||||
marked block. An empty list removes the block.
|
||||
- `group_vars/k3s_prod/vars.yml`: pins `keycloak.{{ k3s_base_domain }}` → `192.168.2.20`, which
|
||||
must stay equal to `GATEWAY_VIP` (parity comment added). It also replaces the stale "no
|
||||
/etc/hosts pin is needed" paragraph with the measured reason the pin is now required.
|
||||
|
||||
Dev is unaffected (empty default).
|
||||
|
||||
## What I verified
|
||||
|
||||
- After PR #40 reconciled (`platform` lastAppliedRevision `576e1ab`, Healthy=True), on
|
||||
k3s-prod-2 and k3s-prod-3: `getent hosts keycloak.home.keithjackson.dev` →
|
||||
`136.51.115.252 wan.home.keithjackson.dev`. `curl https://keycloak…/realms/homelab/.well-known/openid-configuration`
|
||||
→ exit 60, `ssl_verify_result=18` (the router's self-signed cert, not the Gateway).
|
||||
- `journalctl -u k3s` (last 20 min) showed no OIDC/JWKS errors yet. The apiserver still holds
|
||||
the keys it cached before the flip, so the failure is latent until a k3s restart or key rotation.
|
||||
- Neither node has cloud-init (`/etc/cloud` absent), so nothing rewrites `/etc/hosts` at boot.
|
||||
- `--check --diff --tags hosts --limit k3s-prod-2,k3s-prod-3`: each node gets exactly the
|
||||
three-line marked block `192.168.2.20 keycloak.home.keithjackson.dev`.
|
||||
- Outside-in, PR #40 works: check-host.net probes from 3 external nodes got `200 OK` from
|
||||
`https://plex.home.keithjackson.dev/identity` at `136.51.115.252`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **k3s-prod-1**: Ansible cannot reach it. SSH host key verification fails (`known_hosts:94`,
|
||||
ED25519 `SHA256:zgUS8OeWznzCX9hlN7XdZ8iLNkU1G2loXjtJFfEUYpE`), which is consistent with
|
||||
the 2026-09-11 SSD replacement and reinstall. The operator must confirm the fingerprint
|
||||
before the pin can be applied there.
|
||||
- The playbook has not been applied for real. It is an operator run after merge:
|
||||
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`.
|
||||
- An end-to-end kubectl OIDC login after the pin (needs a browser).
|
||||
@@ -0,0 +1,129 @@
|
||||
---
|
||||
date: 2026-10-03
|
||||
branch: feat/plex-remote-access-l4
|
||||
title: Plex remote access — raw tcp/32400 via a pinned LoadBalancer VIP, not the L7 Gateway
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Reported symptom: users cannot reach Plex from outside the LAN. **Two independent causes**, measured
|
||||
rather than inferred:
|
||||
|
||||
1. **Plex advertised no remote connection at all.** Live `Preferences.xml` on prod:
|
||||
`customConnections` **ABSENT**, `ManualPortMappingMode` **ABSENT**, while
|
||||
`PublishServerOnPlexOnlineKey="1"` — publishing was on and there was nothing reachable to publish.
|
||||
Plex's automatic UPnP/NAT-PMP path cannot work from a pod on the Cilium overlay.
|
||||
2. **Nothing from the WAN reached the cluster.** `136.51.115.252:32400` refused;
|
||||
`136.51.115.252:443` answered with a **self-signed router cert** (`C=ZZ, CN=%D`), not the
|
||||
Gateway's LE wildcard. The Plex Service was `ClusterIP` only — no off-cluster path to 32400 by
|
||||
any route.
|
||||
|
||||
This branch fixes the in-cluster half. **Remote access does not work until the operator also does
|
||||
two manual steps** (`runbooks/plex-remote-access.md`): the WAN tcp/32400 port-forward, and ticking
|
||||
"manually specify public port" in the Plex UI.
|
||||
|
||||
- **`apps/wave-0/plex-remote.yaml` (new, hand-written).** A `type=LoadBalancer` Service on tcp/32400
|
||||
pinned to `${PLEX_L4_VIP}` via `lbipam.cilium.io/ips`, plus a `CiliumNetworkPolicy`
|
||||
(`plex-allow-remote-32400`) admitting `world`/`cluster`/`host`/`remote-node` to 32400. Non-catalog
|
||||
because `schemas/servicedefinition.schema.json` is `additionalProperties: false` and has no field
|
||||
for a LoadBalancer Service or a pinned VIP — the same reason `apps/wave-0/pihole-dns.yaml`,
|
||||
`platform/crafty/service-l4.yaml` and `apps/vms/unifi/50-service-l4.yaml` are hand-written.
|
||||
- **`catalog/render.py`:** an `EXTRA_UNITS` entry (`plex-remote`, anchored to `plex`, `above: False`)
|
||||
so `--emit-waves` places the unit in plex's own wave.
|
||||
- **`clusters/base/cluster-vars.yaml`:** new `PLEX_L4_VIP` (dev default `192.168.1.57`), plus the
|
||||
substitution inventory comment updated to name `plex-remote: PLEX_L4_VIP`.
|
||||
- **`clusters/prod/kustomization.yaml`:** prod override `PLEX_L4_VIP: 192.168.2.24`.
|
||||
- **`apps/wave-0/kustomization.yaml`:** `plex-remote.yaml` added to `resources`.
|
||||
- **`runbooks/plex-remote-access.md` (new):** the two manual steps, the four-step verification, why
|
||||
no alert is possible, and the two documented warts below.
|
||||
|
||||
**`ADVERTISE_IP` was deliberately NOT set.** Setting it to `https://plex.home.keithjackson.dev:443`
|
||||
was the alternative — it needs no new WAN hole and the mechanism was verified working on an
|
||||
already-claimed server. **The operator chose the native path (2026-10-03)** so Plex terminates its own
|
||||
TLS (`*.plex.direct`) and streams bypass the Envoy L7 hop. `catalog/plex.yaml`'s deferral note for
|
||||
`ADVERTISE_IP` remains accurate and untouched.
|
||||
|
||||
**`externalTrafficPolicy: Cluster`, with a known cost.** `Local` would preserve the client IP, but
|
||||
Cilium's L2 announcer does not follow the backend pod — the wart that cost this LAN an 8-day
|
||||
Minecraft outage (see `platform/crafty/service-l4.yaml`). Consequence accepted and documented: SNAT
|
||||
puts the client in `192.168.0.0/16`, inside `ALLOWED_NETWORKS`, so **Plex classifies remote streams
|
||||
as LAN**. That is a metering effect (remote bandwidth limits will not bind), not an authorization one.
|
||||
|
||||
**SENSITIVE PATHS — this PR touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK
|
||||
it.** It needs the operator's `/approve-override <reason>` posted as a PR **review** of type
|
||||
"Comment". I have deliberately not posted it: CLAUDE.md §1 forbids an agent clearing this on its own
|
||||
PR, and the agent account holds `owner`, so the prohibition is a rule, not a limit.
|
||||
|
||||
## What I verified
|
||||
|
||||
All measurements against live prod, 2026-10-03, read-only.
|
||||
|
||||
- **Plex's advertised state**, the finding this branch rests on: `customConnections=<ABSENT>`,
|
||||
`ManualPortMappingMode=<ABSENT>`, `ManualPortMappingPort=<ABSENT>`,
|
||||
`PublishServerOnPlexOnlineKey="1"`, `allowedNetworks="10.0.0.0/8,192.168.0.0/16"`. Read by
|
||||
attribute grep, never by dumping `Preferences.xml` (it holds `PlexOnlineToken`).
|
||||
- **The L7 Gateway path works and is NOT the problem.** Via the Gateway VIP,
|
||||
`GET https://plex.home.keithjackson.dev/identity` → `HTTP 200`, `claimed="1"`, real
|
||||
`machineIdentifier`. HTTPRoute `plex-32400`: `Accepted=True`, `ResolvedRefs=True`.
|
||||
- **Plex enforces its own auth**, which is what makes an unfiltered WAN port defensible:
|
||||
`/library/sections`, `/status/sessions`, `/:/prefs` all → **401** unauthenticated through the
|
||||
Gateway.
|
||||
- **The SNAT/allowedNetworks wart is real, not theoretical.** A tagged probe through the Gateway was
|
||||
logged by Plex as `[10.0.2.214:36040 (Allowed Network (WAN))]` — the Envoy pod IP, classified as an
|
||||
allowed network.
|
||||
- **WAN state:** `136.51.115.252:32400` → connection refused. `136.51.115.252:443` → self-signed
|
||||
cert `C=ZZ, ST=Somewhere, O=%D342e7e6b, CN=%D`. Public DNS resolves correctly:
|
||||
`plex.home.keithjackson.dev` → CNAME `wan.home.keithjackson.dev` → `136.51.115.252`.
|
||||
- **`192.168.2.24` is free in-cluster.** `CiliumLoadBalancerIPPool default-pool`: IPsTotal 11 /
|
||||
IPsUsed 4 / IPsAvailable 7; the only allocations are `.20` (cilium-gateway-shared), `.21`
|
||||
(unifi-l4-*), `.22` (crafty-l4-*), `.23` (pihole-dns-*). Not a node address (`.10/.11/.12`) and not
|
||||
the kube-vip control-plane VIP (`.9`).
|
||||
- **The manual-port-mapping step genuinely cannot be automated.** Grep of the running image's whole
|
||||
init surface (`/etc/cont-init.d`, `/plex-common.sh`, `/usr/local/bin`) for `ManualPortMapping` and
|
||||
`PLEX_PREFERENCE` → **NONE**.
|
||||
- **`ADVERTISE_IP` would have worked on a claimed server** (recorded because the runbook's
|
||||
"why not" rests on it): `setPref "customConnections"` at line 126 of `40-plex-first-run` sits
|
||||
**outside** the `PLEX_CLAIM`/`PlexOnlineToken` guard, and the `/.firstRunComplete` early-exit
|
||||
marker is on the **overlay** FS, not the `/config` PVC, so the script re-runs every pod start.
|
||||
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||
**pass**. The rc 3 is two environmental skips — `gitea-settings` (no local `SETTINGS_API_TOKEN`)
|
||||
and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3, not as pass.**
|
||||
- **`render-golden` passing is the specific proof** that the `render.py` `EXTRA_UNITS` change, the new
|
||||
hand-written unit and the edited wave kustomization are mutually consistent.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE ACTUAL FIX. Nothing in this branch has been proven to make remote access work** — it cannot
|
||||
be, because both remaining steps are off-repo and manual (router forward, Plex UI toggle). This
|
||||
branch removes two blockers and adds none; it does not demonstrate an outcome.
|
||||
- **Outside-in reachability, at all.** Every probe above was taken from inside the LAN, so the
|
||||
`:443` self-signed cert strictly proves only that there is **no NAT hairpin** — it does **not**
|
||||
prove outside-in failure. A router cert on WAN:443 is suggestive (its management UI would shadow a
|
||||
forward), but unproven. **This repo has no off-LAN vantage point.** Test 3 in the runbook
|
||||
(app.plex.tv from a cellular connection) is the only honest check.
|
||||
- **`192.168.2.24` was never ARP-probed off-cluster.** Only in-cluster pool accounting was checked.
|
||||
`.23` was `arping`-probed before it was claimed; `.24` was not, so an off-cluster squatter would
|
||||
not have been caught.
|
||||
- **The LoadBalancer has never been reconciled.** `plex-l4-tcp` does not exist in prod yet, so
|
||||
LB-IPAM has not actually assigned the pinned VIP, L2 announcement has not been observed for it, and
|
||||
the new CiliumNetworkPolicy has never admitted a real packet. Whether `world`/`host`/`cluster`/
|
||||
`remote-node` is the correct entity set **under this SNAT path** is reasoned from
|
||||
`platform/crafty/networkpolicy.yaml`'s working game-port rule, **not measured**.
|
||||
- **Whether Plex's remote path needs anything beyond the manual port tick.** If plex.tv still lists
|
||||
no remote connection after both manual steps, this branch is necessary-but-insufficient.
|
||||
- **The CI approval-policy outcome.** The local stage reported green, but that is an offline N/A, not
|
||||
a verdict: this PR touches `clusters/**`, so CI will block it pending an override.
|
||||
- **PRE-EXISTING DRIFT I FOUND AND DELIBERATELY LEFT ALONE — worth its own follow-up.** Running the
|
||||
documented `python3 catalog/render.py --emit-waves` regenerates the wave kustomizations and wants to
|
||||
add **`vm-demo.yaml`** to wave 0 and **`keycloak.yaml`** to wave 1. Both are listed as
|
||||
intentionally not onboarded in `ci/render-golden.sh`
|
||||
(`RENDER_GOLDEN_NOT_ONBOARDED="keycloak vm-demo"`); their manifests are tracked in `apps/` but
|
||||
deliberately absent from the `resources` lists so Flux does not apply them. `--emit-waves` places
|
||||
any file present on disk and has **no awareness of that exclusion**, while `render-golden.sh` uses
|
||||
`--wave-layout` and compares rendered manifests only — it never checks kustomization `resources`,
|
||||
which is why this has survived unnoticed. **So the header "DO NOT hand-edit: re-run
|
||||
`render.py --emit-waves`" is currently unsafe advice**: following it onboards an IdP and a KubeVirt
|
||||
VM. I therefore restored both kustomizations and hand-added only my one line. I did not fix
|
||||
`--emit-waves`, and I have not determined how long the drift has existed or whether either service
|
||||
is *meant* to be onboarded.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
date: 2026-10-03
|
||||
branch: feat/public-dns-wan-target
|
||||
title: Public DNS now points at the WAN — Gateway-published names CNAME to an in-cluster DDNS record
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
The operator closed the open "public-target gap" recorded in
|
||||
`platform/externaldns/helmrelease.yaml`. The WAN NAT / port-forwarding (80/443 → Gateway VIP
|
||||
192.168.2.20) was done by the operator outside this repo.
|
||||
|
||||
Operator decisions (2026-10-03):
|
||||
|
||||
1. **Scope: every Gateway-published name goes public.** That covers the five `exposure: public`
|
||||
apps (plex, gitea, keycloak, nextcloud, seerr) and also the hand-authored admin UIs (auth,
|
||||
crafty, grafana, headlamp, the `home.keithjackson.dev` apex). `internal` services still publish
|
||||
nothing: their `controller: none` route annotation is checked before the target is read.
|
||||
2. **The WAN IP is kept current in-cluster**, not hardcoded.
|
||||
|
||||
Implementation:
|
||||
|
||||
- `platform/gateway/gateway.yaml`: `external-dns.alpha.kubernetes.io/target: wan.${BASE_DOMAIN}` on
|
||||
the shared Gateway. Every gateway-sourced record changes from `A 192.168.2.20` to
|
||||
`CNAME wan.home.keithjackson.dev`.
|
||||
- `platform/externaldns/ddns.yaml` (new): `favonia/cloudflare-ddns:1.17.1` Deployment in the
|
||||
`external-dns` namespace. It reuses `cloudflare-api-token` and keeps `wan.${BASE_DOMAIN}` an
|
||||
unproxied IPv4 A record at the WAN IP (detected via Cloudflare trace, checked every 5 min,
|
||||
`DELETE_ON_STOP=false`). external-dns has no TXT registry entry for it, so it never owns or
|
||||
deletes it.
|
||||
- The helmrelease comment block and the README now describe the decision instead of the gap.
|
||||
|
||||
Known consequence: **k3s nodes resolve through public DNS** (`node_baseline_dns_servers` = 8.8.8.8 /
|
||||
1.1.1.1). The kube-apiserver's OIDC issuer `keycloak.home.keithjackson.dev` therefore resolves to the
|
||||
WAN IP from the nodes, and kubectl SSO works only if the router hairpins WAN:443 back to the Gateway.
|
||||
A LAN-side probe of `136.51.115.252:443` on 2026-10-03 returned a **self-signed placeholder cert**,
|
||||
not the Gateway's Let's Encrypt wildcard. That suggests no hairpin. The cert-based admin kubeconfig
|
||||
is unaffected.
|
||||
|
||||
## What I verified
|
||||
|
||||
- Live prod, 2026-10-03, before the change: Gateway `gateway/shared` ADDRESS `192.168.2.20`.
|
||||
External-dns args include `--source=gateway-httproute --source=service --policy=sync
|
||||
--txt-owner-id=prod --domain-filter=keithjackson.dev`, and it logs "All records are already up to
|
||||
date".
|
||||
- Public answers at 1.1.1.1: plex/gitea/keycloak/grafana/auth/apex → `192.168.2.20`;
|
||||
`crafty-mc` → `.22` and `unifi` → `.21` (service source, not affected by this change); jackett →
|
||||
none (internal, correctly unpublished).
|
||||
- The authority (betty.ns.cloudflare.com) has no unprefixed TXT, AAAA or MX at `plex.` or the apex,
|
||||
so the A→CNAME switch has no co-existing record to collide with.
|
||||
- WAN IP from the workstation: `136.51.115.252`.
|
||||
- No CiliumNetworkPolicy or NetworkPolicy in `external-dns`. The Kyverno label and default-deny
|
||||
policies match only `app-namespace` namespaces. The vpn egress policy selects only
|
||||
`egress-class: vpn` pods.
|
||||
- Toolbox gate result: see the PR description (run after this commit).
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The outside-in path.** I have no vantage point outside the LAN. Whether
|
||||
`https://<svc>.home.keithjackson.dev` from the internet reaches the Gateway with the LE cert is
|
||||
UNVERIFIED.
|
||||
- **Hairpin / kubectl SSO after the flip.** Likely broken per the probe above. Fix candidates: a
|
||||
node `/etc/hosts` pin for `keycloak.${BASE_DOMAIN}` → Gateway VIP via `node_baseline`, or router
|
||||
NAT reflection.
|
||||
- **The record-type flip itself**, until Flux reconciles: whether Cloudflare accepts external-dns's
|
||||
create-CNAME in the same batch as the delete-A, or converges one interval later.
|
||||
- `favonia/cloudflare-ddns` under this pod securityContext. The README warns that
|
||||
`no-new-privileges` misbehaves on some Proxmox-style hosts. These are bare-metal nodes, but it is
|
||||
untested until the pod runs.
|
||||
- Whether the L4 service-source names (crafty-mc, unifi) should also go public. Not decided, not
|
||||
changed.
|
||||
- `conformance/tests/l0-static/internal-services-publish-no-public-dns/check.sh` header lines 39–40
|
||||
still describe the target as open. That is a sensitive path, so it is left for a follow-up.
|
||||
@@ -0,0 +1,157 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: feat/hubble-flow-export-plex
|
||||
title: Record the pre-SNAT client address for Plex via Hubble static flow export, instead of breaking externalTrafficPolicy to get it
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Operator requirement, narrowed explicitly: **this is 100% about log visibility** — knowing which
|
||||
address connected to Plex. Not bandwidth metering, which does not matter here.
|
||||
|
||||
**The problem, measured.** Plex's own log cannot show it. Every LoadBalancer Service in this cluster
|
||||
is `externalTrafficPolicy: Cluster`, so the node holding the L2 lease for the VIP SNATs the client
|
||||
before forwarding to the backend pod:
|
||||
|
||||
| Measured 2026-10-04 | Value |
|
||||
|---|---|
|
||||
| Node announcing `192.168.2.24` | `k3s-prod-3` (also holds `.20` Gateway, `.23` pihole-dns) |
|
||||
| Node running the Plex pod | `k3s-prod-1` (`10.0.0.135`) |
|
||||
| Sources in Plex's log | **3,214 × `10.0.1.x`**, 105 × `10.0.2.x`, 120 × loopback |
|
||||
| `192.168.x` sources in Plex's log, ever | **0** |
|
||||
| `X-Forwarded-For` in Plex's log | **0** (it serves raw tcp/32400, not HTTP via the Gateway) |
|
||||
| `10.0.1.78` identity (`cilium-dbg bpf ipcache`) | `identity=1` → `reserved:host` = prod-3 itself |
|
||||
|
||||
**Nothing on the router is involved** — the operator correctly pointed out there is no hairpin NAT
|
||||
here. The rewrite is entirely in-cluster, done by Cilium's BPF load-balancer on the lease holder,
|
||||
because the backend pod is on a different node.
|
||||
|
||||
**The address is not lost, only unrecorded.** Cilium observes the flow at ingress, *before* its own
|
||||
translation, so Hubble already holds the true source:
|
||||
|
||||
192.168.2.133 -> 10.0.0.135 (real LAN client -> the Plex pod)
|
||||
10.0.1.78 -> 10.0.0.135 (the same traffic post-SNAT — what Plex sees)
|
||||
|
||||
What was missing is **retention**: the ring buffer was **4095/4095 flows at ~303 flows/s — about
|
||||
thirteen seconds of history** — with hubble metrics disabled and no exporter configured.
|
||||
|
||||
So: enable the static flow exporter, scoped to Plex's port, writing to the agent's stdout.
|
||||
|
||||
- **`platform/cilium/helmrelease.yaml`** (authoritative): `hubble.export.static.enabled: true`,
|
||||
`filePath: stdout`, `allowList` of `{"destination_port":["32400"]}` and
|
||||
`{"source_port":["32400"]}`.
|
||||
- **`platform/cilium/values.yaml`** (reference mirror) and
|
||||
**`infra/ansible/roles/cilium/templates/cilium-values.yaml.j2`** (the `[parity]` source of truth):
|
||||
the same block, so a bootstrap render does not differ from what Flux reconciles — a mismatch would
|
||||
be a permanent Helm diff and an avoidable agent rollout.
|
||||
|
||||
**`filePath: stdout`, not a file,** because Grafana Alloy already runs as a DaemonSet tailing every
|
||||
pod's logs into Loki (`platform/monitoring/alloy.yaml`). stdout therefore reaches Loki with **no new
|
||||
hostPath mount, no file-rotation handling, and no Alloy change at all**. Upstream documents the
|
||||
value: *"To get Hubble flows directly exported to the logs instead of written to a rotated file,
|
||||
`stdout` can be defined as `hubble-export-file-path`."* Query:
|
||||
|
||||
{namespace="kube-system", app="cilium"} |= "32400"
|
||||
|
||||
**SCOPED BY PORT, NOT BY NAMESPACE — a measured decision, not a preference.** A
|
||||
`--to-namespace plex` query returned **only the post-SNAT flows**; the flow carrying the real client
|
||||
address did not match it, because at the point of observation the destination identity is not yet
|
||||
resolved to the plex pod. A namespace filter would have dropped exactly the record this change
|
||||
exists to capture.
|
||||
|
||||
**WHY NOT FIX THE SNAT INSTEAD — both alternatives rejected on evidence.**
|
||||
- `externalTrafficPolicy: Local` would put the real address in Plex's own log, but cilium 1.20's L2
|
||||
announcer is **explicitly incompatible** with it. Upstream docs for this exact version:
|
||||
*"incompatible with the `externalTrafficPolicy: Local` on services as it may cause service IPs to
|
||||
be announced on nodes without pods causing traffic drops"* and *"L2 announcements isn't currently
|
||||
aware of this setting"*. Live proof it is not theoretical: the VIP is announced by prod-3 while the
|
||||
pod runs on prod-1, so the flip would black-hole tcp/32400 **immediately**. Making it safe means
|
||||
pinning both the pod and the announcement to one node and surrendering Plex's ability to
|
||||
reschedule — a real availability loss to buy a log field. Same trap
|
||||
`platform/crafty/service-l4.yaml` documents from an 8-day Minecraft outage.
|
||||
- **DSR** also preserves the client address, but `routing-mode` here is **vxlan**, not
|
||||
native/geneve, and DSR returns bypass the ingress node, which breaks the SOHO router's stateful
|
||||
NAT on the remote path. Cluster-wide blast radius across all eight LB Services for one service's
|
||||
logging.
|
||||
|
||||
**A correction to the repo's own record.** `decisions/2026-10-03-plex-remote-access-l4.md` states the
|
||||
SNAT puts the client inside `192.168.0.0/16` (the ingress node's LAN address), and I repeated that in
|
||||
the previous entry. **It is wrong**: measured, the SNAT source is `10.0.1.x` — the node's
|
||||
`cilium_host` inside the pod CIDR. This matters, because it means `LanNetworksBandwidth` can never
|
||||
discriminate local from remote while eTP is `Cluster`: `10.0.0.0/8` must be in the LAN set for the
|
||||
in-cluster consumers (Seerr) to count as local, and every external client arrives in that same range.
|
||||
PR #48's LAN definition therefore fixes the Remote badge by making *everything* local — acceptable
|
||||
only because metering is explicitly not a goal here.
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, 2026-10-04, read-only.
|
||||
|
||||
- **Every measurement in the table above**, including the VIP-lease-to-node map
|
||||
(`cilium-dbg shell -- db/show l2-announce` on all three agents) and the pod's node.
|
||||
- **Hubble already carries the pre-SNAT source** — the single finding this whole approach rests on:
|
||||
`hubble observe --port 32400` on the lease holder returned `192.168.2.133 -> 10.0.0.135` **and**
|
||||
its return flow, alongside the post-SNAT `10.0.1.78 -> 10.0.0.135`.
|
||||
- **The retention gap is real:** `cilium-dbg status` reported `Hubble: Ok Current/Max Flows:
|
||||
4095/4095 (100.00%), Flows/s: 303.62 Metrics: Disabled`, and the `cilium-config` ConfigMap
|
||||
contains no `hubble-export-*` key at all.
|
||||
- **The namespace-filter trap**, directly: `--to-namespace plex` returned only `10.0.1.78` and
|
||||
`10.0.1.71`; the `192.168.2.133` flow was absent. This is why the allowList is port-scoped.
|
||||
- **All eight LoadBalancer Services are `eTP=Cluster`** (crafty ×2, gateway, pihole-dns ×2, plex,
|
||||
unifi ×2) — so this is a cluster-wide pattern, not a Plex-specific choice.
|
||||
- **The eTP:Local incompatibility is upstream-documented for v1.20 specifically**, quoted above from
|
||||
the docs for the running version — not inferred from the crafty incident.
|
||||
- **`routing-mode: tunnel`** and **`bpf-lb-mode-annotation: false`** in the live `cilium-config`,
|
||||
which is what rules DSR out.
|
||||
- **The chart's value paths and defaults**, read from cilium v1.20.1's own `values.yaml`:
|
||||
`hubble.export.static.{enabled,filePath,fieldMask,fieldAggregate,aggregationInterval,allowList,denyList,fileMaxSizeMb,fileMaxBackups,fileCompress}`,
|
||||
with `filePath` defaulting to `/var/run/cilium/hubble/events.log`. The keys used here exist.
|
||||
- **Alloy's pipeline shape**, read from `platform/monitoring/alloy.yaml`: a DaemonSet with
|
||||
`loki.source.kubernetes "pods"` → `loki.process` → `loki.write` at
|
||||
`http://loki.monitoring.svc.cluster.local:3100`. It already tails kube-system pods, which is why
|
||||
stdout needs no Alloy change.
|
||||
- **All three value files agree** (one `destination_port` occurrence each) and both YAML files parse,
|
||||
with `export.static` resolving to the intended object.
|
||||
- **Toolbox gate**: **rc 3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||
`render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two known environmental skips
|
||||
(`gitea-settings` — no local `SETTINGS_API_TOKEN`; `conformance` L2/L3 — `CLUSTER_AVAILABLE`
|
||||
unset). **Not green. Reported as rc 3.**
|
||||
- **No sensitive paths touched** — `platform/cilium/**` and `infra/ansible/**` are not in
|
||||
`infra/gitea/approval-policy.json`, so this needs no approval override, unlike PR #48.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE OUTCOME. Nothing has been reconciled and no flow has ever been exported.** `cilium-config`
|
||||
still has no `hubble-export-*` key. Every claim is about inputs.
|
||||
- **Whether `filePath: stdout` is accepted as written.** This is the single largest risk. The value
|
||||
comes from a verbatim upstream doc sentence (*"`stdout` can be defined as
|
||||
`hubble-export-file-path`"*), but I did not confirm the exact notation in cilium 1.20.1's source,
|
||||
and the chart default is a real filesystem path. If the literal string is not special-cased, the
|
||||
agent may create a FILE named `stdout` in its working directory and nothing reaches Loki — a
|
||||
silent no-op, not a crash. **Fallback if so:** leave `filePath` at the chart default and add a
|
||||
`local.file_match` + `loki.source.file` target to Alloy with a hostPath mount for
|
||||
`/var/run/cilium/hubble`.
|
||||
- **Whether the FlowFilter JSON is accepted.** `{"destination_port":["32400"]}` follows the proto
|
||||
(`destination_port` is a repeated string) and the documented shape (`'{"verdict":["DROPPED"]}'`),
|
||||
but upstream gives **no port example** and I did not validate it against the running agent. A
|
||||
malformed filter could be rejected — or, worse, be silently treated as "no match" and export
|
||||
nothing.
|
||||
- **THIS CHANGE ROLLS CILIUM.** Editing the HelmRelease restarts the agent DaemonSet across all
|
||||
three nodes. BPF maps are preserved so established traffic should survive, but prod-3 currently
|
||||
announces the Gateway VIP, **PiHole's DNS VIP** and Plex's VIP, so a lease change during the
|
||||
rollout could briefly interrupt LAN DNS for the whole house. Not measured, and worth doing at a
|
||||
quiet moment.
|
||||
- **Log volume in practice.** Estimated "a few hundred to a few thousand lines a day" from the
|
||||
observed request counts; never measured as flows. Both directions are exported and no `fieldMask`
|
||||
is set, so each record is a full flow object. If it is noisy, narrow with `fieldMask` rather than
|
||||
by dropping a filter.
|
||||
- **Loki's retention for this data**, and whether the `{namespace="kube-system", app="cilium"}` label
|
||||
set actually matches (it depends on the cilium pods carrying
|
||||
`app.kubernetes.io/name`, which Alloy maps to `app` — I did not confirm that label exists on the
|
||||
cilium DaemonSet pods). The query in the comment may need adjusting to `pod=~"cilium-.*"`.
|
||||
- **That the exported flow actually contains the pre-SNAT address.** Hubble's *live* stream does
|
||||
(measured). I am assuming the exporter sees the same flow objects; I did not confirm the exporter
|
||||
is not fed a post-translation view.
|
||||
- **The parity convention has no automated check.** I kept all three files in sync by hand because
|
||||
the comments demand it; nothing in `ci/` or `conformance/` verifies cilium Helm↔Ansible parity, so
|
||||
a future edit can still drift them silently.
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: fix/oauth2-sidecar-metrics-scrape-netpol
|
||||
title: The forward-auth gate sidecars' /metrics was NetworkPolicy-blocked — the renderer now opens 44180 to ns monitoring
|
||||
---
|
||||
|
||||
> **Added 2026-10-05, after merge:** the top item under *What I did NOT verify* below — "that the
|
||||
> scrape now succeeds" — **has since been verified on prod**. All 6 targets read `up == 1`. The
|
||||
> original text is left exactly as written; the measurements are in
|
||||
> [[2026-10-05-oauth2-sidecar-scrape-verified]].
|
||||
|
||||
## What changed
|
||||
|
||||
`catalog/render.py`'s `r_monitoring_ingress_policy` now emits its CiliumNetworkPolicy for a
|
||||
**second** reason: the forward-auth gate sidecar's own `/metrics` port (44180), keyed on
|
||||
`_needs_oauth2_sidecar(s)`. Previously it fired only from `observability.metricsEndpoint`.
|
||||
|
||||
That matters more than it sounds, because **no ServiceDefinition in this catalog declares a
|
||||
`metricsEndpoint`** (measured: zero grep hits across `catalog/*.yaml`). So the function was wired
|
||||
in at the call site and had **never emitted a single object on any branch** — correct, and
|
||||
entirely latent. Its presence in the tree read as "scrape ingress is handled". It was not.
|
||||
|
||||
Re-rendered `apps/` from that change: five new CNPs, one per forward-auth app
|
||||
(`jackett`, `pihole`, `qbittorrent`, `radarr`, `sonarr`), each opening **only** 44180 to **only**
|
||||
`fromEndpoints: [k8s:io.kubernetes.pod.namespace: monitoring]`.
|
||||
|
||||
The bug being fixed: `platform/oauth2-proxy/podmonitor.yaml` landed 2026-10-02T04:58:44Z and
|
||||
discovered 6 targets that have **never once scraped successfully**. Each app namespace ships a
|
||||
`default-deny` NetworkPolicy with `policyTypes: [Ingress, Egress]` and **no `ingress` key at all**
|
||||
— deny-all-ingress in NetworkPolicy semantics — and the only ingress allowance is
|
||||
`<app>-allow-gateway-ingress`: `fromEntities: [ingress]` to port 4180. Prometheus matched no
|
||||
clause, so its packets were dropped.
|
||||
|
||||
Two deliberate choices, both recorded in the function's docstring:
|
||||
|
||||
- **Clause 2 keys on `_needs_oauth2_sidecar`, not on `observability.metrics`.** It has to match
|
||||
the condition the *scrape* keys on: `podmonitor.yaml` selects pods by the
|
||||
`homelab.keithjackson.dev/auth-mode: forward-auth` pod label and nothing else, so a target
|
||||
exists iff the sidecar exists. Keying on `observability.metrics` would let `metrics: false`
|
||||
produce a discovered-but-blocked target — the same "two halves authored apart" failure this
|
||||
function exists to prevent, moved one field over.
|
||||
- **The policy is rendered here, not added to `platform/oauth2-proxy/`.** podmonitor.yaml's own
|
||||
header records why that tier cannot host it: it would name app namespaces created by the
|
||||
*sibling* `apps-wave-0/1` tiers, so it would apply on a running cluster and fail on a cold
|
||||
bootstrap. Rendered per-app it ships in the same tier that creates the namespace.
|
||||
|
||||
## What I verified
|
||||
|
||||
Measured on prod 2026-10-04, read-only, via the pinned toolbox:
|
||||
|
||||
- **The cause, not a guess.** All 6 sidecar targets in pool
|
||||
`podMonitor/oauth2-proxy/oauth2-proxy-sidecars/0`: `health=down`,
|
||||
`lastError: context deadline exceeded`, `lastScrapeDuration` exactly `10.0s` — the configured
|
||||
`scrapeTimeout`. Hitting the timeout precisely means packets **dropped, not refused**.
|
||||
- **The listener is fine.** jackett's `oauth2-proxy` container carries
|
||||
`--metrics-address=0.0.0.0:44180` and a named port `metrics: 44180`.
|
||||
- **The deny is universal.** All six namespaces (`jackett radarr sonarr qbittorrent pihole
|
||||
crafty`): `default-deny` with `ingress` key absent, `nIngress=0`; the only ingress CNP allowance
|
||||
is 4180 `fromEntities: [ingress]`. **Zero** namespaces had any `-allow-monitoring-scrape` object.
|
||||
- **Frequency.** Single continuous episode, no flapping: `ALERTS_FOR_STATE` = 229343s (63.7h),
|
||||
firing since 2026-10-02 05:09 UTC — 11 minutes after the PodMonitor was created, i.e. TargetDown's
|
||||
`for: 10m`. A 15-day `query_range` shows **1 episode** per namespace, not many. Alertmanager:
|
||||
`group_by: [alertname, namespace]`, `repeat_interval: 12h` on `email-warning` → 5 TargetDown
|
||||
groups = 10 emails/day, plus 6 `OAuth2ProxyGateUnobserved` groups = 12/day, ~22/day total.
|
||||
0 silences.
|
||||
- **The re-render is deterministic and the diff is exactly the intended one.** `apps/` is
|
||||
byte-identical to `render.py --all catalog -o apps --wave-layout --no-image-automation`
|
||||
(12/12 SAME before my edit; the 11-file "drift" against a default render is solely the
|
||||
ImageRepository/ImagePolicy pair, which `apps/` is generated without). After the edit:
|
||||
5 files changed, +21 lines each, nothing else touched.
|
||||
- `render.py --selftest`: **PASS**.
|
||||
- **Three L1 goldens needed regenerating, and the diff is +63 lines / -0.** The first gate run
|
||||
caught `container-workload-render` (R-PLAT-02) and `egress-class-render` (R-NET-05/06/07) as
|
||||
FAIL with exactly the new CNP as the drift. Regenerated with the same invocations the checks
|
||||
use (`render.py <fixture>`, no flags). Evidence the regen is faithful rather than a rubber
|
||||
stamp: `default-deny-baseline-render`'s golden came back **byte-identical** (its fixture is not
|
||||
forward-auth), and the other three gained only the one CNP, port 44180, ns monitoring.
|
||||
- Toolbox gate `./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`: **rc 3 — UNDETERMINED,
|
||||
NOT rc 0, and I am not reporting it as green.** Every stage that ran is green
|
||||
(spec-immutability, approval-policy, decisions-log, lint, l0, render-golden, l1); `l1` went
|
||||
FAIL -> pass once the goldens were regenerated. The gate's own words: "Stages that did not run:
|
||||
gitea-settings" — skipped for want of `GITEA_SETTINGS_TOKEN`, which is an environment gap on
|
||||
every local run and unrelated to this change. L2/L3 conformance is a `[warn]`, not a not-run
|
||||
stage: UNVERIFIED for all 8 touched requirements (R-GITOPS-02 R-IDP-02 R-IDP-06 R-NET-01
|
||||
R-NET-05 R-NET-06 R-NET-07 R-PLAT-02), no cluster.
|
||||
- `--emit-waves` was **not** run (it re-onboards `vm-demo` + `keycloak`); only the per-service
|
||||
render path was used, so no wave kustomization changed.
|
||||
|
||||
## This PR needs a human approval (sensitive path) — MEASURED, not predicted
|
||||
|
||||
CI run #230 on PR #49, head `f9446be3`: **`MERGE GATE: BLOCKED`, and `approval-policy` is the ONLY
|
||||
failing stage.** Read from the job log, not from the status tick:
|
||||
|
||||
[skip] stage spec-immutability: SKIPPED (not run) <- sibling spec repo absent in CI; expected
|
||||
[ ok ] stage decisions-log: pass
|
||||
[ ok ] stage lint: pass
|
||||
[ ok ] stage l0: pass
|
||||
[ ok ] stage render-golden: pass
|
||||
[ ok ] stage l1: pass
|
||||
[fail] stage approval-policy: FAIL
|
||||
[ ok ] stage gitea-settings: pass <- could NOT run locally; green in CI
|
||||
[skip] stage conformance: SKIPPED (not run)
|
||||
|
||||
sensitive paths touched:
|
||||
conformance/tests/l1-rendering/container-workload-render/golden/expected.yaml
|
||||
conformance/tests/l1-rendering/egress-class-render/golden/egress-internet.expected.yaml
|
||||
conformance/tests/l1-rendering/egress-class-render/golden/egress-vpn.expected.yaml
|
||||
|
||||
Exactly the three regenerated goldens, nothing else. `l1` and `render-golden` both pass in CI,
|
||||
which is the independent confirmation that the regenerated goldens are right rather than merely
|
||||
self-consistent with my local run.
|
||||
|
||||
One local-reproduction note worth recording, because it wasted a step: running
|
||||
`PR_NUMBER=49 SETTINGS_API_TOKEN=... ci/approval-policy.sh` with the credential from
|
||||
`git credential fill` reports `could not determine PR author — BLOCKING`. That is an **artifact**,
|
||||
not a finding — `~/.git-credentials` holds a *password*, and `Authorization: token <password>`
|
||||
returns 401 `user does not exist [uid: 0]`. The stage needs a real API token. I nearly reported
|
||||
that 401 as the CI failure; the job log is what settled it.
|
||||
|
||||
Regenerating the L1 goldens means this branch touches `conformance/**`, which is in
|
||||
`sensitive_paths` in `infra/gitea/approval-policy.json` ("the tests and the harness; changing them
|
||||
changes what green MEANS"). So `ci/approval-policy.sh` will require an approving review from a
|
||||
non-author, and the local gate **cannot** see that: it reports `approval-policy: green` because
|
||||
offline it evaluates N/A.
|
||||
|
||||
The golden update is not optional — a renderer change that adds an object necessarily drifts the
|
||||
goldens that pin the renderer's output — but it does mean the gate correctly refuses to
|
||||
self-approve this one. **An agent must not clear this with `/approve-override`**, even though the
|
||||
`gitea-admin` account holds `owner`: that is precisely the self-approval the stage exists to
|
||||
prevent. The operator posts the override as a PR *review* of type "Comment", or approves normally.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the scrape now succeeds.** This is a git-only change and nothing is applied until Flux
|
||||
reconciles `main`. The 5 CNPs have never existed on the cluster, so `up == 1` for those targets
|
||||
is **UNVERIFIED** — it is a prediction from the measured drop signature, not an observation.
|
||||
After merge, confirm `up{container="oauth2-proxy"}` goes to 1 for all 6 targets and that the 11
|
||||
alerts resolve. `send_resolved: true` is set, so expect ~11 resolved emails.
|
||||
- **That the CNP `endpointSelector` matches pihole's pods.** It selects
|
||||
`app.kubernetes.io/name: pihole`, identical to the shape of `pihole-allow-gateway-ingress`
|
||||
which demonstrably works for 4180 — so this is inference from a working sibling, not a
|
||||
measurement of the new object.
|
||||
- **`crafty` is still unobserved and will keep firing `OAuth2ProxyGateUnobserved`.** It is
|
||||
hand-managed under `platform/crafty`, outside the renderer, and has **no `--metrics-address`
|
||||
at all** — so there is no port to open and this change cannot help it. Separate work.
|
||||
- **Whether 44180 is reachable in the Cilium data path once allowed.** Only the policy was
|
||||
reasoned about; no L2 probe was run (CI has `vars.CLUSTER_AVAILABLE` unset, so L2/L3 did not
|
||||
run here either — the gate proves the L0/L1 offline subset only).
|
||||
- **Why `OAuth2ProxyGateUnobserved` started at 2026-10-04 06:44** rather than 10-02 with
|
||||
TargetDown. Alertmanager did not restart (TargetDown's `startsAt` is still 10-02) and the rule's
|
||||
`for` is 1h, so neither explains it. Something changed on the monitoring side that morning which
|
||||
I did not identify. It does not affect this fix.
|
||||
- I did not re-run a port-forward to 44180 to prove the endpoint serves *today*; I confirmed the
|
||||
flag and named port on the live pod spec. The 200/10208-byte measurement is podmonitor.yaml's,
|
||||
from 2026-10-01.
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: feat/pihole-declare-state
|
||||
title: PiHole's operator-set state is now declared in git — the prerequisite for running more than one replica
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Phase 1 of making LAN DNS survive a node failure. The operator asked for multi-replica PiHole;
|
||||
measurement showed that cannot be done safely first, because PiHole's important state was not
|
||||
reproducible from git. A second replica with a fresh PVC would have come up **serving DNS and
|
||||
blocking nothing, with a different admin password** — answers varying by which pod replied. This
|
||||
branch closes that; the replica work is phase 2 and is deliberately NOT here.
|
||||
|
||||
**The undeclared-state inventory, measured not guessed.** `pihole.toml` marks every non-default
|
||||
setting `### CHANGED`, and those that came from the environment `### CHANGED (env)`. The complete
|
||||
live list on 2026-10-04:
|
||||
|
||||
| Setting | Status |
|
||||
|---|---|
|
||||
| `dns.upstreams`, `dns.listeningMode`, `dns.rateLimit.count`, `misc.dnsmasq_lines` | `### CHANGED (env)` — already declared in `catalog/pihole.yaml` |
|
||||
| `files.macvendor` | `### CHANGED` — set by the image itself (container path), not operator state |
|
||||
| `webserver.api.pwhash` | **`### CHANGED` — set out of band. Declared NOWHERE.** |
|
||||
|
||||
So exactly **one** operator-set value lived only on the PVC. Separately, the **adlists** were also
|
||||
undeclared: `gravity.db` held one list (StevenBlack's hosts) and 72,234 gravity domains, with nothing
|
||||
in this repo naming it.
|
||||
|
||||
- **`apps/wave-0/pihole-bootstrap-secret.sops.yaml` (new, SOPS/age).** Secret `pihole-bootstrap`,
|
||||
key `webui-password`. Named to the `<svc>-bootstrap-secret.sops.yaml` convention so
|
||||
`render.py --emit-waves` places it with no renderer change. The value is a 32-char random password
|
||||
generated at authoring time and **never printed to a transcript**.
|
||||
- **`catalog/pihole.yaml`:** `FTLCONF_webserver_api_password` sourced from that Secret.
|
||||
`webserver.api.password` takes CLEARTEXT and FTL hashes it itself — verified in the running image,
|
||||
where `pihole setpassword` *is* `setFTLConfigValue "webserver.api.password"`, commented upstream
|
||||
"pihole-FTL will automatically hash the password". The PVC's `pwhash` becomes a derived artefact.
|
||||
- **`catalog/pihole.yaml`:** a `lifecycle.postStart` hook that **reconciles** the declared adlist set
|
||||
into `gravity.db` on every container start — adds missing, re-enables disabled, and **disables**
|
||||
(never deletes) anything enabled but undeclared, running `pihole -g` only when something moved.
|
||||
Same level-triggered idiom, and the same reasoning, as `catalog/qbittorrent.yaml`'s postStart.
|
||||
- **`apps/wave-0/pihole.yaml`, `apps/wave-0/kustomization.yaml`:** re-rendered / secret registered.
|
||||
|
||||
**Why a postStart hook and not a file.** PiHole v6 keeps adlists as DB rows. `/etc/pihole/adlists.list`
|
||||
still exists in `gravity.sh` but is **migration-only**: its import sits inside `migrate_to_database()`
|
||||
behind `if [ ! -e "$gravityDBfile" ]`, so it fires only while *creating* a database. A ConfigMap
|
||||
mounted there would be silently ignored on every pod this cluster runs today. There is also no
|
||||
`pihole adlist` CLI subcommand, so SQL is the actual interface; FTL ships an embedded shell
|
||||
(`pihole-FTL sqlite3`) so nothing had to be added to the image.
|
||||
|
||||
**The password is kept rather than set empty, deliberately.** The admin UI is already behind the
|
||||
oauth2-proxy sidecar (`--allowed-group=platform-admins`), so an empty password would have been
|
||||
simpler. But the `pihole` Service still publishes **port 80** (PiHole direct, bypassing the proxy on
|
||||
`:4180`) and only the CiliumNetworkPolicy closes it — and this repo has a documented default-deny
|
||||
drift history. An unauthenticated admin UI that can rewrite LAN DNS should not rest on one policy
|
||||
staying correct. Narrowing that Service port is follow-up, not done here.
|
||||
|
||||
**SENSITIVE PATHS — this PR adds a `**/*.sops.yaml`, so `ci/approval-policy.sh` will BLOCK it.** It
|
||||
needs the operator's `/approve-override <reason>` as a PR **review** of type "Comment". Not posted by
|
||||
me: CLAUDE.md section 1 forbids an agent clearing this on its own PR.
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, 2026-10-04, read-only except where noted.
|
||||
|
||||
- **The undeclared-state inventory above** — read from `pihole.toml`'s own `### CHANGED` markers, with
|
||||
the password value masked. This is the measurement the whole branch rests on.
|
||||
- **A fresh `gravity.db` seeds NO adlists.** The image's own schema template
|
||||
`/etc/.pihole/advanced/Templates/gravity.db.sql` creates the `adlist` table and contains no
|
||||
`INSERT INTO adlist` at all — grepped in the running image. Confirmed empirically below (0 rows).
|
||||
This is why the adlist declaration is load-bearing and not cosmetic.
|
||||
- **The adlists.list path really is migration-only** — read the guard in `/opt/pihole/gravity.sh`
|
||||
(`migrate_to_database()`, `if [ ! -e "$gravityDBfile" ]`).
|
||||
- **`webserver.api.password` is a real settable key** — `pihole-FTL --config webserver.api.password`
|
||||
returns a masked value, and `/usr/local/bin/pihole`'s `setpassword` sets exactly that key.
|
||||
- **THE RENDERED HOOK ACTUALLY WORKS — all four paths, extracted from `apps/wave-0/pihole.yaml` and
|
||||
run in the live pod against THROWAWAY COPIES of `gravity.db` in `/tmp`** (production `gravity.db`
|
||||
was never written, and `pihole -g` was stubbed so no 70k-domain download could start):
|
||||
1. *Steady state* → `already matches the declared set - nothing to do`. No writes, no rebuild.
|
||||
2. *True fresh DB built from the image's schema template* → 0 adlists before; hook added the
|
||||
declared list and triggered the rebuild. **This is the fresh-PVC/new-replica case.**
|
||||
3. *An undeclared list enabled* → disabled it (`enabled=0`), left the declared one alone, and did
|
||||
not delete the row.
|
||||
4. *Declared list present but disabled* → re-enabled it.
|
||||
- **Required tools exist in the image:** `bash`, `expr`, `grep`, `printf`, `sleep`, `pihole-FTL`.
|
||||
- **The Secret is genuinely encrypted** to the repo's age recipient; `stringData.webui-password`
|
||||
starts `ENC[`. The gate's `no-plaintext-secrets (R-SEC-01)` L0 check passes.
|
||||
- **Schema:** `check-jsonschema` against `schemas/servicedefinition.schema.json` passes.
|
||||
- **Render determinism:** the canonical
|
||||
`render.py --all catalog -o apps --wave-layout --no-image-automation` changed **only**
|
||||
`apps/wave-0/pihole.yaml`. `render-golden` passes, so the hook's text also satisfies
|
||||
`assert_substitution_safe` (no braced dollar expansion — the Flux envsubst contract).
|
||||
- **Toolbox gate:** rc **3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||
`render-golden`, `l1`, `approval-policy` all **pass**. The rc 3 is the two usual environmental
|
||||
skips — `gitea-settings` (no local `SETTINGS_API_TOKEN`) and `conformance` L2/L3
|
||||
(`CLUSTER_AVAILABLE` unset). **Not green; reported as rc 3.**
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Nothing here has been reconciled.** Flux has not applied any of it, so the password has never
|
||||
been set by this mechanism and the hook has never run during a real container start. Everything
|
||||
above is the rendered artifact tested out-of-band, plus the image's own source.
|
||||
- **THE CURRENT ADMIN PASSWORD WILL STOP WORKING, and that is the intent.** FTL re-hashes
|
||||
`webserver.api.password` on start, overwriting the existing `pwhash`. Whoever knows the present
|
||||
password (nobody, which is why this started) loses it; the declared one replaces it. I have not
|
||||
observed that replacement happen.
|
||||
- **Cold-start cost is unmeasured and could matter.** `postStart` must complete before the container
|
||||
is reported Running. On a fresh PVC the hook waits for `gravity.db` (bounded, up to 180s) and then
|
||||
runs a real `pihole -g` (72k domains, network downloads). `apps-wave-0` is `wait: true` and
|
||||
`apps-wave-1` `dependsOn` it, so a slow cold start **delays the app tier**. The steady-state path
|
||||
exits in about a second (scenario 1), so this is a rebuild/first-boot cost only — but it is not
|
||||
measured, and the stub in testing means `pihole -g`'s real duration here is unknown.
|
||||
- **Whether `pihole -g` succeeds from inside the hook's environment.** Its failure is caught and the
|
||||
existing `gravity.db` retained, so a failure degrades rather than breaks — untested live.
|
||||
- **Concurrency.** Nothing serialises the hook against FTL's own scheduled gravity update. SQLite
|
||||
handles the locking, and the hook's writes are three small statements, but no interleaving was
|
||||
exercised.
|
||||
- **MULTI-REPLICA — PHASE 2, NOT STARTED.** This branch only removes the blocker. The remaining work,
|
||||
and it is substantial: a `replicas`/placement field on the ServiceDefinition schema (currently
|
||||
`additionalProperties: false` with no such field), `render.py` emitting a StatefulSet with
|
||||
`volumeClaimTemplates` (the config PVC is **ReadWriteOnce**, so a 2-replica Deployment cannot
|
||||
work), pod anti-affinity, a PodDisruptionBudget (there is none today), re-rendered goldens, and
|
||||
`conformance/**` updates — the last making phase 2 sensitive-path as well. The PVC rename a
|
||||
StatefulSet forces will discard the existing `gravity.db`, which is only safe **because** of this
|
||||
branch, and should be done only after this one is confirmed working in-cluster.
|
||||
- **The real risk the operator named is NOT fixed by phase 2 either.** Replicas address *node*
|
||||
failure. LAN DNS still depends on the cluster as a whole — etcd quorum, Cilium, Longhorn, a bad
|
||||
reconcile. Quantified on 2026-10-04: with `tolerationSeconds=300` plus node-NotReady detection
|
||||
plus a Longhorn RWO force-detach, a failure of the node running the pod is roughly **6-10 minutes
|
||||
of no LAN DNS**; a failure of the node holding the `pihole-dns-*` L2 lease (currently a
|
||||
*different* node — lease on k3s-prod-2, pod on k3s-prod-1) is tens of seconds. An out-of-cluster
|
||||
secondary resolver carrying the same `address=` lines is the only option that removes the
|
||||
dependency, and it was considered and not chosen.
|
||||
- **Port 80 on the `pihole` Service is still published** and still closed only by policy. Not narrowed.
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: docs/pihole-ha-verified
|
||||
title: PiHole HA is VERIFIED on live prod — including a failover drill with zero dropped DNS queries
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**No manifests changed. This entry exists to correct the log.** The three PiHole/Plex entries merged
|
||||
earlier today each say, correctly at the time, that nothing had been reconciled and that the outcome
|
||||
was unverified:
|
||||
|
||||
- `decisions/2026-10-04-pihole-declare-state.md` — "Nothing here has been reconciled."
|
||||
- `decisions/2026-10-04-pihole-multi-replica.md` — "Nothing has been reconciled. No StatefulSet
|
||||
exists, no second replica has ever run, the anti-affinity has never been evaluated..."
|
||||
|
||||
All three are now **merged, reconciled and measured**, so those sentences are stale in exactly the way
|
||||
§2b of CLAUDE.md warns about — a reader who found them would conclude the work is unproven. This entry
|
||||
is the current state; it does not edit the earlier files (separate files, nothing to merge).
|
||||
|
||||
Delivered and live as of 2026-10-04, from PRs #43 (plex tcp/32400), #44 (declare PiHole state) and
|
||||
#45 (PiHole 2 replicas + the mountPath bug):
|
||||
|
||||
- PiHole is a **2-replica StatefulSet**, one pod per node, with per-replica Longhorn volumes, a
|
||||
PodDisruptionBudget, and an `up` SLI that follows the rendered workload kind.
|
||||
- PiHole's admin password and adlists are **declared in git** and reproduce onto a fresh volume.
|
||||
- PiHole's config PVC is mounted where PiHole actually writes (`/etc/pihole`), which it was not before.
|
||||
|
||||
## What I verified
|
||||
|
||||
All measured against live prod on 2026-10-04, after the merge of #45 (`dce20b7`).
|
||||
|
||||
**Reconcile.** `GitRepository` artifact `main@sha1:dce20b7`; `apps-wave-0`
|
||||
`lastAppliedRevision=main@sha1:dce20b7`, `Ready=True/ReconciliationSucceeded`. It completed in roughly
|
||||
**90 seconds**, well inside the Kustomization's `timeout: 5m` — the risk flagged in the multi-replica
|
||||
entry ("whether `pihole -g` finishes inside the 5 m timeout") did **not** materialise. A transient
|
||||
`Ready=False/DependencyNotReady: platform-config` seen mid-flight was the dependency cascade, not a
|
||||
failure; `platform-config` was already `Ready=True` at the new revision moments later.
|
||||
|
||||
**Topology and objects.**
|
||||
|
||||
| Measured | Value |
|
||||
|---|---|
|
||||
| pods / nodes | `pihole-0` → **k3s-prod-3**, `pihole-1` → **k3s-prod-2** (2/2 Running each) — the required anti-affinity was satisfied |
|
||||
| per-replica claims | `config-pihole-0`, `config-pihole-1` — both `Bound`, 2Gi |
|
||||
| old `pihole-config` PVC | **pruned** (it held only `lost+found`, so nothing was lost) |
|
||||
| old `pihole` Deployment | **pruned** — `No resources found in pihole namespace` |
|
||||
| PodDisruptionBudget | `minAvailable 1`, `disruptionsAllowed 1`, `currentHealthy 2` |
|
||||
|
||||
**The mountPath bug is fixed.** `df` inside both pods reports `/etc/pihole` on
|
||||
`/dev/longhorn/pvc-...` with **6.9 M used** — previously it was on `overlay` while the Longhorn volume
|
||||
sat unused at `/data/config`. `backup.policy: daily-offcluster` now has real content to back up
|
||||
instead of reporting success over an empty volume.
|
||||
|
||||
**Phase 1's declarations reproduce onto a FRESH volume — the point of doing it first.** Both replicas
|
||||
booted with an empty `/etc/pihole`:
|
||||
|
||||
- **72,234 gravity domains** on each, rebuilt from the adlist declared in `catalog/pihole.yaml`.
|
||||
- **The declared SOPS secret authenticates on both pods.** `POST /api/auth` using the container's own
|
||||
`FTLCONF_webserver_api_password` returned `"valid":true` on `pihole-0` **and** `pihole-1` — so
|
||||
Secret → env → FTL's own hashing → working login, identically on both. (The secret's value was never
|
||||
printed; an earlier reading of `pwhash` as "unset" was a quoting bug in the check, disproven by an
|
||||
empty password being rejected with `password incorrect` and then by this positive test.)
|
||||
|
||||
**User-facing behaviour through the VIP (192.168.2.23), from a LAN host.**
|
||||
|
||||
plex.home.keithjackson.dev -> 192.168.2.20 split-horizon intact
|
||||
home.keithjackson.dev -> 192.168.2.20
|
||||
unifi.home.keithjackson.dev -> 192.168.2.21
|
||||
google.com -> 142.250.115.102 recursion to 8.8.8.8 works
|
||||
doubleclick.net -> 0.0.0.0 blocking LIVE in FTL, not just in the DB
|
||||
googleadservices.com -> 0.0.0.0
|
||||
12 consecutive queries -> 12/12 answered
|
||||
|
||||
**THE FAILOVER DRILL — observed by the operator, 2026-10-04.** A single StatefulSet replica was
|
||||
deleted. It was recreated in **~30 seconds**, and **DNS never dropped a request** throughout. This is
|
||||
the first direct evidence that the replica count delivers what it was added for, rather than merely
|
||||
rendering correctly.
|
||||
|
||||
**The SLI is not inert, and re-deriving it mattered.** Measured in Prometheus:
|
||||
`kube_statefulset_status_replicas_ready{namespace="pihole", statefulset="pihole"}` returns **1 series,
|
||||
value 2**; `kube_deployment_status_replicas_available{namespace="pihole"}` now returns **0 series**;
|
||||
and Prometheus has `pihole-down` loaded as `state=inactive health=ok` with the StatefulSet expression.
|
||||
Had the rule stayed Deployment-shaped, its `or absent(...)` branch would have matched the now-absent
|
||||
series and **fired permanently** — a standing false page rather than silence. That is the concrete
|
||||
payoff of defaulting `livenessWorkload.kind` from the rendered kind.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **NODE failure, as opposed to pod failure.** The drill deleted a pod, which does not exercise the
|
||||
`tolerationSeconds: 300` path at all — that timer only applies when a node goes NotReady. The
|
||||
mechanism protecting DNS is the same one just proven (the surviving replica plus
|
||||
`externalTrafficPolicy: Cluster` on the DNS LoadBalancers), so user-visible impact should likewise be
|
||||
nil; what the 300 s still delays is the *replacement* replica, and during that window there is no
|
||||
redundancy left. Untested. Pulling power from a node is the only real test.
|
||||
- **DNS during the cutover itself.** I was not sampling resolution while #45 reconciled, so whether
|
||||
there was a brief gap between the Deployment being pruned and the StatefulSet becoming Ready is
|
||||
**unmeasured**. The drill above covers the steady-state failover case, not that one-off transition.
|
||||
- **Two-node loss.** The hard anti-affinity means `replicas: 2` needs two schedulable nodes; losing
|
||||
two would leave a replica `Pending` and, with `wait: true`, stall the app tier. Reasoned, not
|
||||
exercised — and it coincides with etcd quorum loss on a 3-node control plane.
|
||||
- **Longhorn capacity headroom.** Two 2Gi claims at 3 replicas each is ~12Gi where there was ~6Gi.
|
||||
Both claims are `Bound` and healthy; free space was not checked.
|
||||
- **Query history is now split per replica** and the admin UI shows only whichever pod answered.
|
||||
Adlists and password are identical, so blocking and auth are consistent; the *reporting* is not, and
|
||||
whether that is acceptable long-term is the operator's call.
|
||||
- **The residual risk is unchanged, and it is the larger one.** Replicas address node failure. LAN DNS
|
||||
still depends on the cluster — etcd quorum, Cilium, Longhorn, a bad reconcile — and only a resolver
|
||||
outside the cluster carrying the same `address=` lines removes that dependency. Considered and not
|
||||
chosen (operator decision, 2026-10-04).
|
||||
- **Plex remote access (#43) is NOT covered by this entry.** Its two manual steps (the WAN tcp/32400
|
||||
forward and Plex's "manually specify public port") and the outside-in path remain as
|
||||
`decisions/2026-10-03-plex-remote-access-l4.md` describes them. The operator reported it working;
|
||||
I have not measured it from off-LAN.
|
||||
- **`--emit-waves` drift is still open.** Re-running it still wants to onboard `vm-demo.yaml` and
|
||||
`keycloak.yaml`, both listed as intentionally not onboarded in `ci/render-golden.sh`. Unfixed; see
|
||||
`decisions/2026-10-03-plex-remote-access-l4.md`.
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: feat/pihole-multi-replica
|
||||
title: PiHole runs two replicas on different nodes — and its config PVC was mounted where PiHole never writes
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Phase 2 of making LAN DNS survive a node failure (phase 1:
|
||||
`decisions/2026-10-04-pihole-declare-state.md`). PiHole was one pod on one node, which for a
|
||||
whole-house resolver cost more than "until it reschedules" — measured 2026-10-04:
|
||||
|
||||
| Failure | Recovery |
|
||||
|---|---|
|
||||
| the node running the pod dies | ~40s to mark NotReady + `tolerationSeconds: 300` before eviction + reschedule + a Longhorn RWO force-detach → **roughly 6-10 minutes with no LAN DNS** |
|
||||
| the node holding the `pihole-dns-*` L2 lease dies | Cilium re-elects an announcer, tens of seconds |
|
||||
|
||||
Those were **different** nodes (lease on k3s-prod-2, pod on k3s-prod-1), so two of three nodes were
|
||||
single points of failure for LAN DNS with wildly different blast radii.
|
||||
|
||||
**A new schema field, because this is intent and not a primitive.** `availability.replicas`
|
||||
(schema **1.10.0**, mechanism in `schemas/renderer-contract.md` `### availability`). `1` or absent
|
||||
renders a Deployment exactly as before — every pre-existing golden is byte-identical, verified. `>1`
|
||||
renders, together:
|
||||
|
||||
- a **StatefulSet** instead of a Deployment. This is forced, not preferred: a longhorn PVC is
|
||||
**ReadWriteOnce**, and one RWO claim cannot attach on two nodes, so a 2-replica Deployment does not
|
||||
degrade — the second pod never schedules.
|
||||
- longhorn storage as **per-replica `volumeClaimTemplates`** (`config-pihole-0`, `config-pihole-1`);
|
||||
the standalone PVC is no longer emitted, since it would be an orphan nothing mounts. A `bulk`
|
||||
entry stays a standalone PVC — it is a shared static NFS export, not per-replica state.
|
||||
- **required** `podAntiAffinity` on `kubernetes.io/hostname`. `preferred` would let both replicas
|
||||
land on one node and deliver none of the intended tolerance while reporting healthy.
|
||||
- `podManagementPolicy: Parallel`, so a cold start costs the max of the replicas' first-boot work
|
||||
rather than the sum — `apps-wave-0` has `timeout: 5m`.
|
||||
- a **PodDisruptionBudget**, `minAvailable: 1`, so a node drain cannot take the last replica.
|
||||
- `observability.livenessWorkload.kind` defaulting to `StatefulSet`, so the `up` SLI reads
|
||||
`kube_statefulset_status_replicas_ready`. A Deployment-shaped rule against a StatefulSet
|
||||
`absent()`s forever — an alert that can never fire.
|
||||
|
||||
`catalog/pihole.yaml` sets `replicas: 2`, not 3: two tolerates any single node loss and leaves a
|
||||
spare node for the evicted replica under the hard anti-affinity. Three would pin one per node with no
|
||||
spare, so a single node failure would leave a replica permanently `Pending` and — `apps-wave-0` being
|
||||
`wait: true` — stall the app tier for as long as the node was down.
|
||||
|
||||
### The bug found on the way, and fixed here
|
||||
|
||||
**PiHole's config PVC was mounted at a path PiHole never writes, so it held nothing and all of its
|
||||
state was ephemeral.** With no explicit `mountPath` the renderer defaults to `/data/<name>`; PiHole
|
||||
keeps everything in `/etc/pihole`. Measured on live prod 2026-10-04: `/etc/pihole` (7.1 M —
|
||||
`gravity.db`, `pihole.toml`, the query DB) was on the **overlay** filesystem, while the 2 Gi Longhorn
|
||||
volume sat at `/data/config` holding `lost+found` and 24 K of nothing. Consequences, all live until
|
||||
now:
|
||||
|
||||
- every restart silently discarded PiHole's entire state and re-downloaded 72 k gravity domains;
|
||||
- `backup.policy: daily-offcluster` has been faithfully backing up an **empty** volume, which is
|
||||
worse than no backup because it reports success;
|
||||
- it also means phase 1 was understated: that state was not merely undeclared, it was **ephemeral**.
|
||||
|
||||
Same class of bug `catalog/plex.yaml` documents for its own `config` entry. Fixed in this branch
|
||||
rather than its own because with the wrong path the new per-replica volumes would be two 2 Gi
|
||||
Longhorn volumes (×3 Longhorn replicas each) holding only `lost+found` while all state stayed
|
||||
ephemeral — paying for persistence and getting none. It also removes this change's main operational
|
||||
risk: with `gravity.db` persisted, a restart is cheap instead of re-downloading 72 k domains inside a
|
||||
wave with a 5 m timeout.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Back-compatibility, the thing most likely to break quietly:** after the renderer change and
|
||||
before `catalog/pihole.yaml` declared anything, the canonical
|
||||
`render.py --all catalog -o apps --wave-layout --no-image-automation` produced **zero diff** across
|
||||
the whole `apps/` tree. `render-golden` passes.
|
||||
- **The new behaviour is pinned by ~20 new `render.py --selftest` assertions** (so `ci/render-golden.sh`
|
||||
hard-fails on a regression, and no `conformance/**` change was needed): `replicas: 1` still renders a
|
||||
Deployment + standalone PVC + no PDB; `replicas: 2` renders a StatefulSet and **no** standalone
|
||||
longhorn PVC, carries the replica count, omits `strategy`, sets `Parallel` and `serviceName`, names
|
||||
the claim template exactly as the mounted volume, keeps RWO/longhorn on it, does **not** duplicate
|
||||
that volume in `pod.volumes`, still mounts it on the container, renders required hostname
|
||||
anti-affinity, renders exactly one `minAvailable: 1` PDB, and switches the SLI to
|
||||
`kube_statefulset_status_replicas_ready`. A mixed longhorn+bulk case asserts the bulk share stays a
|
||||
standalone PVC and an ordinary pod volume.
|
||||
- **Rendered pihole output, read back:** `replicas: 2`, `serviceName: pihole`,
|
||||
`podManagementPolicy: Parallel`, no `strategy`, claim template `config` (RWO/longhorn/2Gi),
|
||||
anti-affinity `kubernetes.io/hostname`, `pod.volumes: []`, mount `config -> /etc/pihole`,
|
||||
PDB `minAvailable: 1`, SLI `kube_statefulset_status_replicas_ready`.
|
||||
- **The `/etc/pihole` mount is safe, evidenced from the image's own init rather than assumed:**
|
||||
`/usr/bin/start.sh` is PID 1 and runs as **uid 0**; its line 5 is
|
||||
`chown -R pihole:pihole /etc/pihole/ /var/log/pihole/`, so a fresh root-owned volume has its
|
||||
ownership fixed before anything else starts (**no `fsGroup` needed, and one would be inert**); it
|
||||
calls `migrate_gravity`, whose `migrate_to_database()` creates the gravity database when
|
||||
`[ ! -e "$gravityDBfile" ]`; FTL writes `pihole.toml` itself from the `FTLCONF_*` env; and the
|
||||
image's own default for `files.macvendor` was moved **out** of that directory, which is what a
|
||||
maintainer does to a path they expect to be a volume.
|
||||
- **The PVC really is unused:** `df` showed `/etc/pihole` on `overlay` and
|
||||
`/dev/longhorn/pvc-849edb86-...` on `/data/config`; `ls -A /data/config` → `lost+found` only.
|
||||
- **The gate caught a real regression and it is fixed.** The first run came back **rc 1**:
|
||||
`alert-rules-unit-tested` failed because `apps/wave-0/tests/app-slis.test.yaml` fed pihole a
|
||||
`kube_deployment_status_replicas_available` series and expected a `deployment: pihole` label. Three
|
||||
input series, the firing expectation and the file header were updated;
|
||||
`platform/monitoring/tests/run.sh apps/wave-0/tests/app-slis.test.yaml` → **SUCCESS** (6 rules).
|
||||
- **Schema:** `check-jsonschema` passes over all of `catalog/*.yaml`.
|
||||
- **Toolbox gate:** rc **3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||
`render-golden`, `l1`, `approval-policy` all **pass**. The rc 3 is the two environmental skips —
|
||||
`gitea-settings` (no local `SETTINGS_API_TOKEN`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE`
|
||||
unset). **Not green; reported as rc 3.**
|
||||
- **No sensitive paths touched**, so unlike the previous two branches this one needs no
|
||||
`/approve-override` — confirmed against `infra/gitea/approval-policy.json`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Nothing has been reconciled.** No StatefulSet exists, no second replica has ever run, the
|
||||
anti-affinity has never been evaluated by the scheduler, the PDB has never been consulted, and the
|
||||
`/etc/pihole` mount has never been exercised on a real pod. Everything above is the rendered
|
||||
artifact plus the image's own source.
|
||||
- **THE ROLLOUT WILL BRIEFLY DROP LAN DNS, and I have not measured for how long.** `postStart` must
|
||||
complete before a container is reported Running, so a pod is not Ready — and the Service has no
|
||||
endpoints — until phase 1's adlist hook finishes. On this transition both replicas start with an
|
||||
empty `/etc/pihole`, so the hook finds a fresh `gravity.db` with zero adlists and runs a real
|
||||
`pihole -g` (72 k domains, network download) before either pod becomes Ready. **Do this at a quiet
|
||||
time.** Two mitigating facts: every pihole restart has *already* paid this cost (state was
|
||||
ephemeral), and after this change `gravity.db` persists so later restarts should not. If it turns
|
||||
out slow, backgrounding the gravity rebuild inside the hook would decouple readiness from it, at
|
||||
the cost of a window where DNS resolves but does not block — not done here.
|
||||
- **Whether `pihole -g` finishes inside `apps-wave-0`'s 5 m `timeout`.** `Parallel` was chosen to
|
||||
make this the max rather than the sum of two first boots, but the real duration is unmeasured (it
|
||||
was stubbed during phase 1's testing). If the wave times out, the Kustomization reports failed even
|
||||
though the pods may come up seconds later.
|
||||
- **The old PVC's deletion.** `apps-wave-0` has `prune: true`, so removing `pihole-config` from the
|
||||
rendered output should delete it. That destroys nothing (it holds `lost+found`), but I have not
|
||||
observed the prune, and I did not check whether Longhorn retains the backing volume afterwards.
|
||||
- **The transition overlap.** For a moment the pruned Deployment's pod and the new StatefulSet pods
|
||||
may both match the Service selector. Both are configured identically from git so this should be
|
||||
benign — unobserved.
|
||||
- **Longhorn capacity.** Two 2 Gi claims at 3 Longhorn replicas each is ~12 Gi of real storage where
|
||||
there was ~6 Gi. Not checked against free space.
|
||||
- **Whether two independent query databases are acceptable to the operator.** Stats and query history
|
||||
now split per replica, and the admin UI shows only the pod that answered. Adlists and password are
|
||||
identical (phase 1), so blocking and auth are consistent; the *reporting* is not.
|
||||
- **The residual risk is unchanged and is the larger one.** Replicas address node failure. LAN DNS
|
||||
still depends on the cluster — etcd quorum, Cilium, Longhorn, a bad reconcile — and only a resolver
|
||||
outside the cluster carrying the same `address=` lines removes that. Considered and not chosen
|
||||
(operator decision, 2026-10-04).
|
||||
- **`tolerationSeconds: 300` is untouched.** With two replicas the surviving one serves immediately, so
|
||||
the 5-minute eviction delay no longer gates recovery — but it still delays the *replacement* replica,
|
||||
and during that window there is no redundancy left.
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: fix/plex-advertise-plexdirect
|
||||
title: Plex vanished from the plex.tv account — every advertised connection was uncompletable; advertise the plex.direct name instead of the bare VIP
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Reported symptom: the server no longer appears on the operator's plex.tv account; `plex.home.keithjackson.dev`
|
||||
shows "not authorized"; connecting straight to the VIP + port works.
|
||||
|
||||
**Root cause, measured: `customConnections` held two hand-typed URLs and NEITHER could ever complete,
|
||||
so Plex had nothing publishable.** Because `customConnections` OVERRIDES Plex's own connection
|
||||
publishing, the server ended up advertising only dead endpoints. Live `Preferences.xml`, 2026-10-04:
|
||||
|
||||
customConnections="https://plex.home.keithjackson.dev:32400/,https://192.168.2.24:32400/"
|
||||
ManualPortMappingMode="1" ManualPortMappingPort=<ABSENT>
|
||||
|
||||
- `https://192.168.2.24:32400` — **TLS can never validate.** Plex serves 32400 with
|
||||
`CN=*.130072f20c334d6c8f63d0dfef87309b.plex.direct` and **no IP SAN**, so a bare-IP https URL fails
|
||||
for every client. Not theoretical: Plex's log carries a continuous storm of
|
||||
`CERT: incomplete TLS handshake from <node> ... sslv3 alert certificate unknown`, every few seconds,
|
||||
from **real LAN clients** arriving through the new `plex-l4-tcp` VIP.
|
||||
- `https://plex.home.keithjackson.dev:32400` — **nothing listens.** The shared Gateway serves that host
|
||||
on 443; measured `http=000` (connect failed) against `192.168.2.20:32400`.
|
||||
|
||||
Consequence chain, each link measured: no completable connection -> plex.tv could publish nothing
|
||||
(`MyPlex: mapping state set to 'Mapped - Not Published'`, repeatedly) -> Plex fell back to **Plex Relay**
|
||||
(`startRelay`, remote forward to 127.0.0.1:32401) -> **the server disappeared from the account**, and
|
||||
clients reported "not authorized".
|
||||
|
||||
The fix advertises Plex's own `<dashed-ip>.<serverhash>.plex.direct` form, which is public DNS resolving
|
||||
to the embedded address — so it points at the LAN VIP *and* matches the wildcard cert.
|
||||
|
||||
- **`catalog/plex.yaml`:** `ADVERTISE_IP: ${PLEX_ADVERTISE_URL}` added, replacing the long-standing
|
||||
DEFERRAL note. The deferral had weighed only the L7 Gateway URL; the operator chose the native L4 path
|
||||
on 2026-10-03, so the open question was never "Gateway or not" but "which URL has a valid cert".
|
||||
- **`clusters/base/cluster-vars.yaml`:** new `PLEX_ADVERTISE_URL` knob (+ substitution-inventory comment
|
||||
updated to name `plex: PLEX_ADVERTISE_URL`). The base default is an explicit **non-functional
|
||||
placeholder** — dev is decommissioned and has no claimed server, and
|
||||
`StrictPostBuildSubstitutions` makes an undefined var a hard failure, so the default only has to exist
|
||||
and be correctly shaped.
|
||||
- **`clusters/prod/kustomization.yaml`:** prod value
|
||||
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`.
|
||||
- **`apps/wave-0/plex.yaml`:** re-rendered with the canonical command
|
||||
(`python3 catalog/render.py --all catalog -o apps --wave-layout --no-image-automation`). Two added
|
||||
lines, nothing else.
|
||||
|
||||
**Why a whole-URL cluster-var and not something built from `${PLEX_L4_VIP}`:** postBuild substitution is
|
||||
plain envsubst and cannot turn `192.168.2.24` into `192-168-2-24`, and `<serverhash>` is the server's
|
||||
certificate identity, derivable from nothing in this tree. The dashed IP and `${PLEX_L4_VIP}` are two
|
||||
halves of one address and **nothing checks that they agree** — noted in both files.
|
||||
|
||||
**SENSITIVE PATHS — this PR touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK it.**
|
||||
It needs the operator's `/approve-override <reason>` posted as a PR **review** of type "Comment".
|
||||
Deliberately not posted here: CLAUDE.md §1 forbids an agent clearing this on its own PR, and the agent
|
||||
account holds `owner`, so the prohibition is a rule rather than a limit.
|
||||
|
||||
## What I verified
|
||||
|
||||
All measurements against live prod, 2026-10-04, read-only. Secrets handled per CLAUDE.md §6: preference
|
||||
attributes were read by name-scoped grep, never by dumping `Preferences.xml` (it holds `PlexOnlineToken`),
|
||||
and the token was reported only as `<PRESENT,len=22>`.
|
||||
|
||||
- **THE FIX ITSELF, with full TLS verification (no `curl -k`)** — the one measurement the whole change
|
||||
rests on:
|
||||
- `https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400/identity` -> **`http=200`, TLS verify OK**
|
||||
- `https://192.168.2.24:32400/identity` -> **`http=000`, cert validation FAILED**
|
||||
- that name resolves to `192.168.2.24` (`getent hosts`), confirming the dashed-IP mechanism works from
|
||||
inside this network and is not blocked by DNS-rebinding protection on the resolver path tested.
|
||||
- **The server is still claimed — this was never a lost claim.** `/identity` -> `claimed="1"` with the real
|
||||
`machineIdentifier`; `PlexOnlineToken` present, `PlexOnlineUsername="jakman85"`.
|
||||
- **The certificate, read off the live listener:** exactly one SAN,
|
||||
`DNS:*.130072f20c334d6c8f63d0dfef87309b.plex.direct`. No IP SAN. This is why the bare-IP URL fails.
|
||||
- **The publish payload Plex actually sent to plex.tv**, which is what proves the connection list was the
|
||||
problem rather than a guess: `PUT https://servers.plex.tv/devices/<id>?Connection[][uri]=https://plex.home.keithjackson.dev:32400&Connection[][uri]=https://192.168.2.24:32400&Connection[][uri]=http://136.51.115.252:32400&Connection[][uri]=http://10.0.2.136:32400&httpsEnabled=1...`
|
||||
— all four unreachable from a LAN client or from plex.tv.
|
||||
- **`Mapped - Not Published`** at every reachability check across the whole log, and an active **Plex Relay**
|
||||
session — the documented fallback when nothing is directly publishable.
|
||||
- **The `certificate unknown` TLS failures come from real client traffic, not a probe.** The source
|
||||
`10.0.1.78` resolves via `cilium-dbg bpf ipcache get` to **identity 1 = `reserved:host`**, i.e. LAN
|
||||
clients SNAT'd through `plex-l4-tcp` (`externalTrafficPolicy: Cluster`) on prod-3.
|
||||
- **The Gateway is NOT broken, and my first reading of it was wrong.** A 403 `Access denied` from
|
||||
`server: envoy` on the Gateway path turned out to be an artifact of probing from inside the plex pod:
|
||||
**all 16** Gateway hostnames return 403 from there, because Cilium's Gateway preserves the original
|
||||
source identity and enforces the destination's ingress policy. Plex's own log shows a concurrent,
|
||||
**working, signed-in** browser session through the Gateway returning 200s. The HTTPRoute is
|
||||
`Accepted=True`/`ResolvedRefs=True` with no filters, and it is the only route claiming that hostname.
|
||||
- **Cilium is not denying the Gateway->Plex hop:** `cilium-dbg bpf policy get 3796` shows
|
||||
`Allow Ingress reserved:ingress 32400/TCP`, PROXY PORT `NONE`, with 141k packets counted. No L7/HTTP
|
||||
rules and no CCNPs exist anywhere in the cluster.
|
||||
- **In-cluster consumers were never affected:** Seerr's recently-added scans and watchlist syncs complete
|
||||
cleanly with no auth errors; there are **zero** HTTP 401s in Plex's log (the five ` 401 ` hits are
|
||||
transcoder segment numbers).
|
||||
- **`ADVERTISE_IP`'s mechanism, in the running image rather than from docs:**
|
||||
`/etc/cont-init.d/40-plex-first-run` line 127 does `setPref "customConnections" "${ADVERTISE_IP}"`,
|
||||
outside the claim guard, and `/.firstRunComplete` (lines 9/139) is on the container overlay, not the
|
||||
`/config` PVC — so it re-applies on every pod start. Editing the env rolls the Deployment, which applies it.
|
||||
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||
**pass**. The rc 3 is the two known environmental skips — `gitea-settings` (no local
|
||||
`SETTINGS_API_TOKEN`) and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3.**
|
||||
- **`render-golden` passing is the specific proof** that the catalog edit and the committed golden agree;
|
||||
the rendered diff is exactly two lines and no other service's manifest moved.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE OUTCOME. Nothing here has been observed to put the server back on the plex.tv account.** The
|
||||
change is unreconciled: `ADVERTISE_IP` is absent from the live Deployment, the live
|
||||
`customConnections` still holds the two broken URLs, and Plex has not been restarted. What is proven is
|
||||
that the *new URL answers 200 with a valid certificate* and that *every currently advertised URL cannot
|
||||
complete* — a necessary-and-plausibly-sufficient argument, not a demonstrated fix.
|
||||
- **Whether plex.tv will publish and list the server once this lands.** plex.tv's decision is made
|
||||
off-network and cannot be measured from here. A LAN-only connection may still leave the account showing
|
||||
the server as remotely unreachable even when local clients work.
|
||||
- **REMOTE (off-LAN) ACCESS IS NOT FIXED BY THIS, and may still be fully broken.** The advertised URL
|
||||
resolves to an RFC1918 address, so it serves LAN clients only. The off-repo WAN tcp/32400 port-forward
|
||||
(`runbooks/plex-remote-access.md`) is still **unverified** — `http://136.51.115.252:32400` was
|
||||
unreachable from inside the LAN, but 2026-10-03 established there is no NAT hairpin here, so that is
|
||||
**inconclusive**, not proof of absence. This repo has no off-LAN vantage point.
|
||||
- **`ManualPortMappingMode="1"` with `ManualPortMappingPort` ABSENT is left as-is.** Manual public-port
|
||||
mapping is enabled with no port set — a half-configured state I did not change and whose exact effect on
|
||||
publishing I did not isolate. There is **no GitOps path** to it (2026-10-03: the image supports no
|
||||
`PLEX_PREFERENCE*` of any kind), so it is an operator UI step.
|
||||
- **Whether the operator intended the hand-typed URLs** to override the 2026-10-03 native-L4 design, or
|
||||
typed them while debugging. I did not alter live preferences; the env var will overwrite
|
||||
`customConnections` on the next pod start, which is a deliberate transfer of ownership from the web form
|
||||
to git and will **silently discard** anything typed there since.
|
||||
- **The `plex.direct` hash's stability.** Treated as server identity and read once from the live cert. I
|
||||
did not establish what reissues it, so this URL could go stale with no alert; nothing in CI or the
|
||||
conformance suite checks that the dashed IP still matches `${PLEX_L4_VIP}` or that the hash is current.
|
||||
- **Resolver behaviour for the whole LAN.** `plex.direct` resolved correctly from a *pod* (cluster DNS
|
||||
path). LAN clients resolve via PiHole, and dnsmasq's `stop-dns-rebind` would reject a public answer
|
||||
carrying a private address. I did **not** test resolution from a LAN client, and this is the most likely
|
||||
way the fix still fails.
|
||||
- **The CI approval-policy outcome.** The local stage reported green, but that is an offline N/A, not a
|
||||
verdict: this PR touches `clusters/**`, so CI will block pending the operator's override.
|
||||
- **Why PMS restarted.** The process came up at 05:26 UTC while the pod is 2d2h old with 0 restarts, so
|
||||
something restarted Plex inside the container. Unexplained and not investigated.
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: fix/plex-unblock-app-tier
|
||||
title: Unblock the app tier — my Certificate stalled apps-wave-0, and the advertised https URL had no servable certificate
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**I broke the app tier with PR #48 and this reverts the part that did it.** Two faults, both mine,
|
||||
both measured after the merge:
|
||||
|
||||
1. **Kyverno blocks cert-manager in app namespaces.** The temporary private-key Secrets
|
||||
(`plex-vip-<random>`) were denied by `secret-provenance/secrets-must-be-flux-managed`:
|
||||
|
||||
resource Secret/plex/plex-vip-rp4qj was blocked due to the following policies
|
||||
secret-provenance:
|
||||
secrets-must-be-flux-managed: Secrets in application namespaces must be delivered by
|
||||
Flux from SOPS-encrypted git ... or be exempt controller-generated Secrets.
|
||||
|
||||
The policy matches every Secret in a namespace labelled
|
||||
`homelab.keithjackson.dev/app-namespace: true` and excludes only Secrets labelled
|
||||
`homelab.keithjackson.dev/secret-managed-by: controller` or carrying a Flux label. **The `plex`
|
||||
namespace has that label; the `gateway` namespace — where the working wildcard cert lives — does
|
||||
NOT.** That asymmetry is why the existing Certificate was never affected and mine was, and I did
|
||||
not check it. cert-manager's temp key Secrets carry neither exempting label, and `secretTemplate`
|
||||
cannot label them (the keymanager controller creates them, not the Certificate template). Result:
|
||||
**no CertificateRequest, Order or Challenge was ever created.**
|
||||
|
||||
2. **Flux health-checks the Certificate — guarding the pod was not enough.** `optional: true` did
|
||||
exactly what it was designed to do: Plex rolled and ran fine without the Secret (verified). But
|
||||
`apps-wave-0` is `wait: true`, so Flux waits on every applied object with a known health type:
|
||||
|
||||
health check failed after 5m0.045564329s: timeout waiting for:
|
||||
[Certificate/plex/plex-vip status: 'InProgress']
|
||||
|
||||
apps-wave-0 never went Ready, and apps-wave-1/-2 plus everything downstream of platform-config
|
||||
sat in `DependencyNotReady` behind it. I reasoned about the pod and missed the object.
|
||||
|
||||
A third, independent problem the same PR introduced: **the advertised URL was `https://` with no
|
||||
servable certificate.** Plex answers 32400 with its `*.<serverhash>.plex.direct` cert, which cannot
|
||||
cover `plex-vip.${BASE_DOMAIN}` — the precise failure mode of the outage earlier that day.
|
||||
|
||||
- **`apps/wave-0/plex-cert.yaml` DELETED**, and `plex-cert.yaml` removed from
|
||||
`apps/wave-0/kustomization.yaml` `resources`, so Flux prunes the stuck Certificate and the health
|
||||
gate clears. **Deleted rather than merely unregistered** — my first attempt kept the file as
|
||||
"tracked but not onboarded" (the shape `keycloak.yaml` and `vm-demo.yaml` use) and **the repo's own
|
||||
gate rejected it**: `tls-lifecycle-automated` (R-TLS-02) fails any Certificate in the tree that no
|
||||
Flux path reconciles — *"nothing reconciles it, so a deleted Certificate/issuer/controller would
|
||||
stay deleted until a human re-applied it."* That pattern is legal for a Deployment and not for a
|
||||
TLS object, which is a distinction I had not known.
|
||||
- **`catalog/render.py`:** the now-dangling `plex-cert` EXTRA_UNITS entry removed.
|
||||
- **The re-enable recipe is recorded at the call site** — in `catalog/plex.yaml` beside the scheme
|
||||
it is coupled to — rather than in the deleted file: the Kyverno exemption, the non-gating
|
||||
Kustomization, and the `https` flip, as one change.
|
||||
- **`catalog/plex.yaml`:** `ADVERTISE_IP` → **`http://`**`plex-vip.${BASE_DOMAIN}:32400`, explicitly
|
||||
temporary, with the restore condition written at the call site.
|
||||
|
||||
**Everything else from PR #48 is kept and working** — the hostname design, the PiHole split-horizon
|
||||
answer, the `LanNetworksBandwidth` hook, the `optional` schema field, and the TLS env (inert without
|
||||
the Secret, so re-enabling is additive).
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, 2026-10-04, read-only, after the #48/#50 merges.
|
||||
|
||||
- **The stall and its exact cause**: the health-check timeout message above, and the full dependency
|
||||
cascade (`cilium` → "dependency 'flux-system/flux-system' revision is not up to date", everything
|
||||
else `DependencyNotReady`). The root `cilium` wait was a normal revision-gated cascade; the
|
||||
*Certificate* was the real blocker.
|
||||
- **The Kyverno denial text, verbatim**, from the cert-manager controller log, repeating every ~30s
|
||||
across at least 15 distinct Secret names.
|
||||
- **The policy's own structure**, read from `platform/kyverno/admission/secret-provenance.yaml`:
|
||||
match = Secrets in `app-namespace: true` namespaces; exclude = `secret-managed-by: controller` OR
|
||||
a `kustomize.toolkit.fluxcd.io/name` label; validate = unconditional `deny`.
|
||||
- **The namespace-label asymmetry**: `plex` carries
|
||||
`homelab.keithjackson.dev/app-namespace: "true"`; `gateway` does not.
|
||||
- **cert-manager's controller ServiceAccount is `cert-manager`** in namespace `cert-manager` — the
|
||||
identity a future exemption should name.
|
||||
- **`optional: true` worked**: the new pod (`plex-c4f87c84d-zgjq5`) is `1/1 Running` with no Secret
|
||||
present, and `/config/certs` was never created.
|
||||
- **The postStart hook RAN and took effect** — the thing the whole hook was written for:
|
||||
`LanNetworksBandwidth="10.0.0.0/8,192.168.0.0/16"` is now set in the live `Preferences.xml`, and
|
||||
`customConnections="https://plex-vip.home.keithjackson.dev:32400"` was applied from the env. The
|
||||
certificate prefs are correctly **ABSENT**, i.e. the hook's "no bundle → leave them alone" guard
|
||||
behaved as designed.
|
||||
- **PiHole's split-horizon answer is correct**, asked of `pihole-0` directly:
|
||||
`plex-vip.home.keithjackson.dev -> 192.168.2.24`, alongside `plex -> .20` and `crafty-mc -> .22`,
|
||||
and the live `/etc/pihole/dnsmasq.conf` carries
|
||||
`address=/plex-vip.home.keithjackson.dev/192.168.2.24`. **This corrects my own earlier check**,
|
||||
which read `.20` and which I nearly reported as a failure — that test resolved via CLUSTER DNS
|
||||
(CoreDNS) from inside a pod and never consulted PiHole at all.
|
||||
- **The gate caught my first attempt and I fixed it rather than working around it.** Keeping the
|
||||
file unregistered produced `MERGE GATE: BLOCKED` (rc 1) with
|
||||
`CHECK FAIL (tls-lifecycle-automated): 1 of 612 assertions failed`. After deleting the file and
|
||||
the dangling EXTRA_UNITS entry: **rc 3**, with `spec-immutability`, `decisions-log`, `lint`, `l0`,
|
||||
`render-golden`, `l1` and `approval-policy` all **pass** — the rc 3 being only the two known
|
||||
environmental skips (`gitea-settings`, `conformance` L2/L3). **Not green. Reported as rc 3.**
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Whether this actually unblocks the tier.** Unreconciled at time of writing. The reasoning is
|
||||
that pruning the Certificate removes the only failing health check, but I have not seen
|
||||
apps-wave-0 go Ready.
|
||||
- **Whether `http://` restores client connectivity.** PiHole resolves the name and Plex serves 32400
|
||||
(both measured), but I have not watched a Plex client complete a connection over the advertised
|
||||
URL, and I did not re-check whether plex.tv publishes it.
|
||||
- **THE REMOTE BADGE — the symptom that started this.** `LanNetworksBandwidth` is set, but the log
|
||||
still tags traffic `Allowed Network (WAN)`: **82 WAN, 0 LAN**, with entries continuing after the
|
||||
pref was written. So either Plex needs a restart for it to take effect, or that log tag is not
|
||||
driven by this pref at all. **I do not know which, and the user-visible badge is the only real
|
||||
test** — it is in the Plex client, which I cannot see.
|
||||
- **The Hubble flow export is NOT active.** The cilium agents are still the pre-merge pods, so the
|
||||
`filePath: stdout` question is still open and no flow has been exported. The Cilium rollout — and
|
||||
its LAN-DNS risk, since prod-3 holds the Gateway, PiHole-DNS and Plex VIPs — is still AHEAD.
|
||||
- **Whether a cert-manager exemption is the right shape**, or whether it widens
|
||||
`secret-provenance` more than the operator wants. It is `platform/kyverno/**`, a sensitive path,
|
||||
and deliberately not attempted here.
|
||||
- **Whether the pruned Certificate leaves residue** — I did not check for orphaned
|
||||
`plex-vip-<random>` Secrets or CertificateRequests after the prune. Note Kyverno DENIED every one
|
||||
of those Secrets, so there may be nothing to clean, but that is an inference, not a check.
|
||||
- **Whether `tls-lifecycle-automated` would also have objected to the Certificate living in a
|
||||
`wait: false` Kustomization** (the recommended re-enable shape). It demands a reconciling Flux
|
||||
path, which that provides, but I did not test it.
|
||||
- **The unencrypted advertised URL.** `http://` is tolerable only while remote access does not work
|
||||
(no WAN forward). Nothing enforces that assumption, and nothing will complain if `http` is still
|
||||
there the day the port-forward appears.
|
||||
@@ -0,0 +1,151 @@
|
||||
---
|
||||
date: 2026-10-04
|
||||
branch: feat/plex-vip-hostname-and-lan-networks
|
||||
title: Plex — one advertised hostname for local and remote, its own LE certificate, and a LAN definition so LAN clients stop being reported as remote
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Two operator-reported symptoms, after the 2026-10-04 "server missing from the account" fix
|
||||
(decisions/2026-10-04-plex-advertise-plexdirect.md) had restored publishing:
|
||||
|
||||
1. **LAN clients were shown as Remote.** Root cause, measured: `LanNetworksBandwidth` was **ABSENT**,
|
||||
so Plex had no definition of a local network and classified *everything* as WAN — **468 requests
|
||||
logged `Allowed Network (WAN)` and ZERO `(LAN)`**, including Seerr dialling from inside the
|
||||
cluster. `allowedNetworks` (the `ALLOWED_NETWORKS` env) only waives **auth**; it does not make a
|
||||
client local. Two different settings, and only one of them has an env hook.
|
||||
2. **The advertised URL was a single-purpose, fragile value.** The previous fix advertised
|
||||
`https://<dashed-ip>.<serverhash>.plex.direct:32400`, which works, but hard-codes the VIP *and*
|
||||
the server's certificate hash into a cluster-var — two values nothing could keep in sync.
|
||||
|
||||
**Operator design adopted (2026-10-04): ONE hostname, `plex-vip.${BASE_DOMAIN}`, serving both
|
||||
audiences by split-horizon DNS** — PiHole answers it with `${PLEX_L4_VIP}` on the LAN, Cloudflare
|
||||
answers it publicly with the WAN address, NATed to the same VIP.
|
||||
|
||||
- **`catalog/plex.yaml`:** `ADVERTISE_IP` → `https://plex-vip.${BASE_DOMAIN}:32400`. New
|
||||
`lifecycle.postStart` hook (level-triggered, same idiom as pihole's and qbittorrent's) that
|
||||
re-asserts on **every** start: `LanNetworksBandwidth`, and the three `customCertificate*` prefs
|
||||
after converting the PEM to the PKCS#12 bundle Plex requires. New `PLEX_TLS_CRT`/`PLEX_TLS_KEY`
|
||||
env from the `plex-vip-tls` Secret, both `optional: true`.
|
||||
- **`apps/wave-0/plex-cert.yaml` (new, hand-written):** a cert-manager `Certificate` for exactly
|
||||
`plex-vip.${BASE_DOMAIN}` from ClusterIssuer `letsencrypt-prod`. Non-catalog because the schema has
|
||||
no field for a Certificate. A single name, not the wildcard: the wildcard's Secret lives in
|
||||
`gateway` and Secrets are not readable across namespaces, and re-requesting `*.${BASE_DOMAIN}`
|
||||
would burn a duplicate-certificate slot for no benefit.
|
||||
- **`schemas/servicedefinition.schema.json` → 1.11.0:** adds `optional` to
|
||||
`env.valueFrom.secretKeyRef` / `configMapKeyRef`. **This is load-bearing, not tidiness.** The
|
||||
Certificate must be issued into the `plex` namespace, and that namespace is created by the
|
||||
rendered `apps/wave-0/plex.yaml` itself — so it cannot be issued in an earlier tier and lands in
|
||||
the *same* reconcile as the workload. With hard refs, Plex would sit in
|
||||
`CreateContainerConfigError` until a certificate issued, and `apps-wave-0` is `wait: true` with
|
||||
`apps-wave-1` dependsOn it, so a slow or failed DNS01 challenge would have **stalled the whole app
|
||||
tier** — the hazard `catalog/plex.yaml` already documents for its GPU resource.
|
||||
- **`catalog/pihole.yaml`:** `address=/plex-vip.${BASE_DOMAIN}/${PLEX_L4_VIP}` added to
|
||||
`FTLCONF_misc_dnsmasq_lines`. Required, not cosmetic: measured 2026-10-04,
|
||||
`plex-vip.${BASE_DOMAIN}` resolved to **`192.168.2.20`** (the Gateway VIP) via the zone wildcard,
|
||||
and a connect to `:32400` there returned **`http=000`**.
|
||||
- **`clusters/base/cluster-vars.yaml` + `clusters/prod/kustomization.yaml`:** `PLEX_ADVERTISE_URL`
|
||||
**deleted** — the hostname makes it unnecessary, which removes the cert-hash fragility outright.
|
||||
`PLEX_L4_VIP`'s note now records its second consumer (pihole).
|
||||
- **`catalog/render.py`:** `plex-cert` EXTRA_UNITS entry; **`apps/wave-0/kustomization.yaml`:**
|
||||
`plex-cert.yaml` hand-added (NOT via `--emit-waves`, which would also onboard `keycloak` and
|
||||
`vm-demo` — the pre-existing drift recorded on 2026-10-03).
|
||||
|
||||
**A correction to my own earlier reasoning, recorded because it was wrong in the handoff log.** The
|
||||
previous entry asserted a bare-IP URL "can NEVER validate" and treated a SAN match as a
|
||||
*requirement*. The operator pointed out it is not: `httpsRequired=0` and `secureConnections` default
|
||||
to Preferred, so clients can fall back to cleartext. The measurements still stand (a validating
|
||||
client did fail, and real clients did send `certificate unknown`) — but the correct framing is that a
|
||||
matching certificate keeps the remote leg **encrypted**, not that it is required for Plex to
|
||||
function. That is why this branch obtains a real certificate for the advertised name rather than
|
||||
choosing the name to suit Plex's built-in one.
|
||||
|
||||
**SENSITIVE PATHS — touches `clusters/**` (two files), so `ci/approval-policy.sh` will BLOCK until an
|
||||
approving review (or the operator's `/approve-override <reason>` posted as a PR *review* of type
|
||||
"Comment"). Deliberately not self-posted: CLAUDE.md §1.**
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, 2026-10-04, read-only. Secrets per CLAUDE.md §6: prefs were read by name-scoped grep,
|
||||
the server token was used inside the pod and never printed, and no `Preferences.xml` was dumped.
|
||||
|
||||
- **The LAN-classification root cause, directly:** `LanNetworksBandwidth=<ABSENT>`, and request
|
||||
tagging of **468 × `Allowed Network (WAN)` vs 0 × `(LAN)`** — including `10.0.2.33` (Seerr), an
|
||||
in-cluster pod. This is the measurement the whole hook rests on.
|
||||
- **All four prefs the hook sets EXIST on this build**, read from the server's own `/:/prefs`
|
||||
(token-authenticated, output filtered to ids): `LanNetworksBandwidth`, `customCertificatePath`,
|
||||
`customCertificateKey`, `customCertificateDomain`, all `type="text"`. I had earlier been unable to
|
||||
confirm this (an unauthenticated `GET /:/prefs` returns **401**) and said so rather than assuming.
|
||||
- **There is NO env path for these settings** — the reason the hook exists. Grep of the image's
|
||||
entire init surface: `setPref` is called for exactly six prefs (`MachineIdentifier`,
|
||||
`ProcessedMachineIdentifier`, `PlexOnlineToken`, `customConnections`, `allowedNetworks`,
|
||||
`TranscoderTempDirectory`) and the only `PLEX_*` env it reads are CLAIM/CLAIM_FILE/UID/GID/
|
||||
UPDATE_CHANNEL and five INFO/HOME vars. No `PLEX_PREFERENCE*` of any kind; zero files mention
|
||||
`LanNetworks`.
|
||||
- **The DNS gap was real:** `plex-vip.${BASE_DOMAIN}` resolved to `192.168.2.20` (Gateway VIP), and
|
||||
`https://192.168.2.20:32400` returned `http=000` (nothing listening). PiHole's wildcard is why.
|
||||
- **`openssl` is present in the image** (`/usr/bin/openssl`, 3.0.13) — the hook's PEM→PKCS#12
|
||||
conversion has a tool to run.
|
||||
- **The issuer exists and works:** ClusterIssuer `letsencrypt-prod` `READY=True` (28d), and the
|
||||
existing `gateway/wildcard-home` Certificate is `Ready`/not-expired, which is what makes a DNS01
|
||||
issuance for a new name in this zone a reasonable expectation.
|
||||
- **The renderer's substitution guard caught a real defect in my own work**, which is recorded
|
||||
because it is the second time this class of bug has appeared here: my hook's explanatory comment
|
||||
contained brace-wrapped names, and `assert_substitution_safe` refused to emit
|
||||
(`'PX' is not a declared cluster-vars key — envsubst will replace it with the EMPTY STRING`).
|
||||
Reworded; the rendered hook now contains exactly one brace-wrapped name, `BASE_DOMAIN`, verified
|
||||
by parsing the emitted manifest.
|
||||
- **A `set -u` bug I introduced and then removed.** With `optional: true` the TLS env vars are
|
||||
genuinely UNSET when no certificate has issued; under `set -u` the first `[ -n ... ]` would abort
|
||||
the hook, and a postStart hook exiting non-zero makes kubelet KILL the container — the exact
|
||||
failure `optional` exists to prevent. Verified by parsing the rendered manifest that no `set -u`
|
||||
**command** remains (only two mentions in comments).
|
||||
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||
**pass**; the rc 3 is the two known environmental skips (`gitea-settings` — no local
|
||||
`SETTINGS_API_TOKEN`; `conformance` L2/L3 — `CLUSTER_AVAILABLE` unset). **Not green. Reported as
|
||||
rc 3.** `l0` passing is the specific proof the schema edit admits `optional` and still validates
|
||||
every catalog file; `render-golden` passing is the proof catalog and committed goldens agree.
|
||||
- **Only plex and pihole goldens moved.** No other service's manifest changed, and no service was
|
||||
silently onboarded.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE OUTCOME, ENTIRELY. Nothing in this branch has been observed working.** It is unreconciled:
|
||||
no Certificate exists in prod, `plex-vip` still resolves to the Gateway VIP, `LanNetworksBandwidth`
|
||||
is still absent, and the hook has NEVER RUN. Every claim above is about the inputs, not the result.
|
||||
- **Whether the certificate will actually ISSUE.** DNS01 for a new name in this zone is inferred from
|
||||
the wildcard's success, not demonstrated. If the Cloudflare credential or solver is scoped in a way
|
||||
that fails for this name, Plex will start (by design) with no TLS and the advertised `https://` URL
|
||||
will be unservable — i.e. **this branch could leave the advertised URL broken in a new way**. First
|
||||
reconcile must be watched.
|
||||
- **Whether `openssl pkcs12 -export` produces a bundle PLEX ACCEPTS.** OpenSSL 3 defaults to AES-256
|
||||
+ SHA-256, which some consumers reject. Untested against Plex. If it is rejected, Plex may fail to
|
||||
load its TLS listener — the hook guards against a *missing* file, NOT against a present-but-
|
||||
unreadable one. **This is the single largest unverified risk in the change.**
|
||||
- **Whether the certificate prefs take effect without a restart.** Plex may need to re-initialise its
|
||||
listener after `customCertificatePath` changes. If so, the first reconcile applies the prefs and
|
||||
the SECOND start serves the cert. Converges either way, but the first start may look wrong.
|
||||
- **Whether `LanNetworksBandwidth` actually flips the Remote badge.** It is the correct setting by
|
||||
Plex's own documentation and the measured absence explains the symptom exactly, but I did not set it
|
||||
live and observe a client reclassify.
|
||||
- **SNAT makes the LAN definition coarser than it looks.** With `externalTrafficPolicy: Cluster`, Plex
|
||||
sees the ingress NODE's address for everything arriving via the VIP, so once `10.0.0.0/8` and
|
||||
`192.168.0.0/16` are LAN, GENUINE REMOTE clients arriving that way will ALSO be classified local
|
||||
and remote bandwidth limits will not bind on them. This is the metering consequence already
|
||||
accepted on 2026-10-03, now made stronger rather than weaker. Not measured.
|
||||
- **The public Cloudflare record for `plex-vip` is off-repo and absent from this branch**, as is the
|
||||
WAN tcp/32400 port-forward. Remote access cannot work until the operator creates both; nothing here
|
||||
can verify either, and this repo has no off-LAN vantage point.
|
||||
- **The private key is exposed in the container's environment** (`/proc/1/environ`) because the schema
|
||||
and renderer support no secret VOLUME — measured: `storage` accepts only
|
||||
[class, export, mountPath, name, readOnly, size, subPath] with `additionalProperties: false`, and
|
||||
no rendered app mounts a Secret. A volume would be the better shape; adding secret-volume support
|
||||
is the clean follow-up and I did not do it.
|
||||
- **The PKCS#12 passphrase is a hardcoded literal** (`plexvip`) in the rendered manifest. It protects
|
||||
nothing — Plex needs it in cleartext in `customCertificateKey` and it sits beside the bundle on the
|
||||
same PVC — but it is worth knowing it is not a secret and was never treated as one.
|
||||
- **Four places must agree and nothing enforces it:** `ADVERTISE_IP`, the hook's
|
||||
`customCertificateDomain`, `plex-cert.yaml`'s `dnsNames`, and PiHole's answer. No gate stage or
|
||||
conformance test checks that they match, or that PiHole's `plex-vip` answer still equals
|
||||
`${PLEX_L4_VIP}`.
|
||||
@@ -0,0 +1,147 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: docs/agents-work-in-their-own-workspace
|
||||
title: One workspace per agent run — the shared checkout is now documented as off-limits
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Agents have been doing their work in the operator's checkout at
|
||||
`/var/home/keith/source/home-network-k3s-impl` and clobbering each other. Nothing in the tree
|
||||
told them not to: worktrees appeared in CLAUDE.md only as §3's *gate workarounds*, i.e. as an
|
||||
awkward edge case, and `decisions/README.md` asserted worktree isolation as a fact about how
|
||||
agents already behave rather than as a rule they must follow. Nowhere was the rule stated.
|
||||
|
||||
Documentation only — no code, no manifests, no pipeline logic.
|
||||
|
||||
- **`CLAUDE.md`: new `## 0. Work in your OWN workspace. Never in the shared checkout.`**
|
||||
Placed before §1 so the section numbers every other file and handoff entry cites (§1, §2b,
|
||||
§3, §7) all keep their meaning. It gives the three commands (`git fetch origin main`;
|
||||
`git worktree add -b <type>/<slug> <path outside the repo> origin/main`; `cd`), an explicit
|
||||
do-not list for the shared checkout (no `checkout`/`switch`, no edits, no
|
||||
`add`/`commit`/`stash`/`restore`, no removing others' worktrees), a measurement table, the
|
||||
"put it outside the repo root" rule, cleanup, what to do if you are already standing in the
|
||||
shared checkout with edits, and what a PM must put in a brief it writes for another agent.
|
||||
- **`CLAUDE.md` intro** now says "Start with §0", with the reason: it is the rule you can
|
||||
break before you have read anything else.
|
||||
- **`CLAUDE.md` §3** retitled "Running the gate from a worktree" and reframed as the normal
|
||||
path rather than an edge case, since §0 now makes every gate run a worktree run. Its list
|
||||
grew from two breakages to three: `spec-immutability`'s skip is *avoidable*, not merely
|
||||
tolerated, if the `ENGINE` shim also mounts the sibling spec repo read-only and passes
|
||||
`-e SPEC_REPO=/home-network-k3s`.
|
||||
- **`CLAUDE.md` §4** gained "One branch, one workspace, one PR".
|
||||
- **`README.md`**: a fourth non-negotiable rule ("No work in a shared checkout"), and
|
||||
"Working on this repo" now opens with how to get out of the checkout before describing the
|
||||
dev container that runs in it.
|
||||
- **`decisions/README.md`**: the worktree-isolation sentence now cites §0 as the requirement
|
||||
instead of describing it as something agents happen to do.
|
||||
|
||||
I did this work the way the new §0 requires — a worktree at
|
||||
`<session scratchpad>/wt-workspace-rule`, outside the repo root, branched from `origin/main`,
|
||||
with the shared checkout left on the branch and with the untracked files I found in it.
|
||||
|
||||
## What I verified
|
||||
|
||||
Every number in the new §0 table was measured in the shared checkout on 2026-10-05, not
|
||||
recalled:
|
||||
|
||||
| Claim | Command | Value |
|
||||
|---|---|---|
|
||||
| shared HEAD is yanked between branches | `git reflog \| grep -c 'checkout: moving'` | **107** |
|
||||
| it is not sitting on `main` | `git worktree list` | `feat/pm-agent-issue-to-pr` |
|
||||
| other runs' uncommitted work is in it | `git status --porcelain` | **2** trees: `infra/agent/` (22:04), `platform/pm-agent/` (22:09) — `stat -c %y` |
|
||||
| its `main` is stale | `git rev-parse main` vs `origin/main` | `4386d14` vs `22c94ef`, **2 behind** |
|
||||
| worktrees registered / already merged | `git worktree list --porcelain` + `git merge-base --is-ancestor <b> main` | **42** branches, **31** merged |
|
||||
| nested worktrees are a gate hazard | `grep -rl '\.claude' conformance/tests platform/monitoring/tests` | **9** scripts carry an explicit exclusion |
|
||||
| the 18× double-count | read `platform/monitoring/tests/run.sh:115-123` | in-tree comment, observed 2026-09-12 |
|
||||
|
||||
**The failure mode was observed live, mid-session, not just reconstructed.** The two untracked
|
||||
trees I measured at 22:04 and 22:09 were committed at 22:20 by another run — `5334abe`
|
||||
`feat(pm-agent): an in-cluster AI Program Manager...`, 1,000+ lines across `infra/agent/`,
|
||||
`clusters/`, `catalog/`, `apps/`, `docs/plans/` and its own `decisions/` entry — onto the shared
|
||||
checkout's checked-out branch. Measured after the fact: the shared checkout's HEAD moved
|
||||
`22c94ef` → `5334abe` and its working tree went clean between my first `git status` and my last,
|
||||
with `git log -1` naming a commit that is not mine. That is §0's "shared HEAD" row happening
|
||||
during the session that wrote §0, and it is now cited in the doc.
|
||||
|
||||
**A claim of mine was wrong and is corrected in the doc.** I had it recorded that
|
||||
`git worktree remove` is blocked by the agent sandbox. Provoked rather than assumed: created
|
||||
a throwaway detached worktree in the scratchpad and removed it — **rc 0**, and
|
||||
`git worktree list` no longer shows it. §0 therefore tells agents to remove their own, and
|
||||
scopes the "blocked" note to what it actually covers: *other* sessions'
|
||||
`.claude/worktrees/agent-*`, some of which are additionally `locked` on git's own terms. The
|
||||
`rework/<pr-number>` escape hatch is now scoped to someone else's stale worktree holding a
|
||||
branch, which is a real and separate problem (31 of them). Both of my own worktrees in this
|
||||
session were removed cleanly at rc 0 after pushing.
|
||||
|
||||
`spec-immutability` is avoidable-not-tolerated: taken from
|
||||
`decisions/2026-10-01-render-selftest-runs-in-gate.md`, which records
|
||||
`stage spec-immutability: pass` under the two-mount shim plus `-e SPEC_REPO=/home-network-k3s`.
|
||||
|
||||
**Gate — `rc 3`, UNDETERMINED, and I am not calling it green.** Pinned toolbox, from this
|
||||
worktree, `DEV_KUBECONFIG=/dev/null` and an `ENGINE` shim bind-mounting both the main
|
||||
checkout's `.git` (at its own host path) and the sibling spec repo read-only with
|
||||
`-e SPEC_REPO=/home-network-k3s`:
|
||||
|
||||
ENGINE=<two-mount shim> DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh' # rc 3
|
||||
|
||||
| stage | result |
|
||||
|---|---|
|
||||
| spec-immutability | **pass** — it genuinely RAN; the second mount is what makes it run from a worktree |
|
||||
| approval-policy | pass, but **N/A**: `event='none'`. It cannot see a PR locally, so this green says nothing. See below |
|
||||
| decisions-log | pass — the three required headings and the frozen-archive pin |
|
||||
| lint · l0 · render-golden · l1 | pass (L1: 3 offline checks, 0 failures, 0 skips, 0 unwired) |
|
||||
| gitea-settings | **SKIPPED** — `SETTINGS_API_TOKEN` unset; it exists only in CI |
|
||||
| conformance (L2/L3) | SKIPPED — `CLUSTER_AVAILABLE!=true`; the decision notes no cluster-needing requirement was touched |
|
||||
|
||||
`gitea-settings` is the *only* stage that did not run, and it cannot be made to run from a
|
||||
workstation, so rc 3 is the ceiling for any local run — not a defect in this branch. The
|
||||
first gate attempt returned rc 3 for a different and real reason (`HEAD is not ahead of
|
||||
origin/main`: I had not committed yet, so the touched-requirement set was empty by default
|
||||
rather than by verification); it was re-run after committing, and the table above is the
|
||||
post-commit run. §3 now records both expected skips, because it previously named only
|
||||
`spec-immutability`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **GATE: UNVERIFIED at the time of writing.** The pinned-toolbox result for this branch and
|
||||
its exit code are reported in the PR, not predicted here. **A local rc 3 is not green** and
|
||||
will not be described as one.
|
||||
- **CI gate: RED, measured — `MERGE GATE: BLOCKED`, one failing stage.** Run 245 on PR #56,
|
||||
head `b988da8b`, 1m47s. The only failure is `approval-policy`: *"a sensitive change lacks a
|
||||
non-author approval"*, `sensitive paths touched: CLAUDE.md`, author `gitea-admin`. Every other
|
||||
stage passed — `decisions-log`, `lint`, `l0`, `render-golden`, `l1`, and `gitea-settings`
|
||||
(which can only run in CI, and reported `required_approvals = 0`,
|
||||
`dismiss_stale_approvals = true`). `spec-immutability` SKIPPED in CI (no sibling spec repo in
|
||||
the CI workspace — the inverse of the local run, where the two-mount shim made it pass) and
|
||||
`conformance` SKIPPED. **Note `gate / conformance (pull_request)` reports status `success`
|
||||
with description "Has been skipped"** — CLAUDE.md §2b's trap, live on this very PR. Nothing
|
||||
in this branch is broken; the red is the structural human gate, below. *Measured at
|
||||
`b988da8b`; this entry was then amended to record it, so the head SHA in the PR is newer than
|
||||
the run quoted here and CI re-ran on the amended commit.*
|
||||
- **This PR is a sensitive-path PR and is CI-RED on `approval-policy` until a human acts.**
|
||||
`CLAUDE.md` is in `infra/gitea/approval-policy.json`'s `sensitive_paths` ("the delivery
|
||||
contract agents follow"), so the stage requires an approving review from someone who is not
|
||||
the author, or an `/approve-override <reason>` posted by an administrator **as a PR review
|
||||
of type "Comment"**, not in the plain comment box. `ci/approval-policy.sh` run locally
|
||||
reports N/A (it cannot see a PR), so the local gate cannot catch this and a green local run
|
||||
says nothing about it. **I have not posted an override and must not** — CLAUDE.md §1 forbids
|
||||
an agent clearing this on its own PR even though `gitea-admin` holds `owner` and could.
|
||||
- **Nothing here is enforced.** §0 is prose. There is no gate stage that detects work done in
|
||||
the shared checkout, no check that a worktree is outside the repo root, and no check that a
|
||||
branch was cut from `origin/main`. Every other rule in CLAUDE.md that agents kept breaking
|
||||
(`DECISIONS-LOG.md` appends, the three handoff headings) ended up as a gate stage, and that
|
||||
is the honest follow-up here. I did not write one: the ask was documentation, and an
|
||||
enforcement stage lands in `ci/**`, which is itself a sensitive path and deserves its own PR.
|
||||
- **I did not clean up the 31 stale merged worktrees or the 35 nested under
|
||||
`.claude/worktrees/`.** They are other sessions' workspaces; removing them is an operator
|
||||
action, and §0 explicitly tells agents not to remove worktrees they did not create.
|
||||
- **I did not touch the untracked `infra/agent/` and `platform/pm-agent/` trees** sitting in the
|
||||
shared checkout. They are another run's in-flight work. Whose, and whether they are wanted,
|
||||
is unknown to me.
|
||||
- **`docs/plans/ssd-etcd-execution-runbook.md:18` still points at a `.claude/worktrees/` path**
|
||||
inside the repo. Left as-is deliberately: that runbook is marked COMPLETED/historical and
|
||||
describes the now-decommissioned dev cluster, so the path is a record of what was done, not
|
||||
an instruction. It does mean a grep for in-repo worktree paths is not yet clean.
|
||||
- **No runtime, cluster or Flux behaviour was touched or checked.** This branch changes three
|
||||
Markdown files; `kubectl` was not run.
|
||||
@@ -0,0 +1,88 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: feat/cilium-roll-out-on-config-change
|
||||
title: Cilium config changes were silently inert — roll the agents when cilium-config changes, which also finally activates the Hubble exporter
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**A merged Cilium config change did nothing, and everything reported success.** The Hubble flow
|
||||
exporter (PR #50, merged 2026-10-04) wrote its settings into `cilium-config` and never took effect.
|
||||
|
||||
| Measured 2026-10-05, over a day after the merge | |
|
||||
|---|---|
|
||||
| HelmRelease `cilium` | `Ready=True`, `UpgradeSucceeded`, *"Helm upgrade succeeded for release kube-system/cilium.v6"* |
|
||||
| `cilium-config` keys | `hubble-export-file-path: stdout`, `hubble-export-allowlist: {"destination_port":["32400"]} {"source_port":["32400"]}` — **all present** |
|
||||
| cilium agent pods | **all three still the PRE-MERGE pods**, `startTime 2026-10-04T07:11Z` |
|
||||
| exported flow lines in agent stdout | **0, 0, 0** |
|
||||
| Flux Kustomizations | all `Ready=True` |
|
||||
|
||||
Cause: the chart's `rollOutCiliumPods` defaults to **`false`** (cilium 1.20.1 `values.yaml`: *"Roll
|
||||
out cilium agent pods automatically when configmap is updated"*), so Helm rewrites the ConfigMap and
|
||||
leaves the DaemonSet's pod template untouched — and **cilium-agent reads that ConfigMap only at
|
||||
startup**. The feature was merged, believed delivered, green everywhere, and doing nothing at all.
|
||||
|
||||
That is the same class of failure this repo already guards against elsewhere: a green signal sitting
|
||||
over something that never ran.
|
||||
|
||||
- **`platform/cilium/helmrelease.yaml`** (authoritative): `rollOutCiliumPods: true`.
|
||||
- **`platform/cilium/values.yaml`** (reference mirror) and
|
||||
**`infra/ansible/roles/cilium/templates/cilium-values.yaml.j2`** (the `[parity]` source of truth):
|
||||
the same, so a bootstrap render does not differ from what Flux reconciles.
|
||||
|
||||
**The cost is real and is the point.** Every future Cilium config change now triggers a cluster-wide
|
||||
agent rollout instead of waiting for someone to remember a manual restart. That is strictly more
|
||||
honest — the change applies when merged, or it fails visibly — but it makes config edits disruptive
|
||||
by default, so they want a quiet moment. Mitigation already in place:
|
||||
`updateStrategy.rollingUpdate.maxUnavailable: 1` means the DaemonSet will not proceed past an unready
|
||||
agent, so a bad render costs one node rather than the cluster.
|
||||
|
||||
**Merging this is itself one rollout**, because adding the ConfigMap checksum annotation changes the
|
||||
pod template — and that rollout is what finally activates the Hubble exporter.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **Every row of the table above**, read from the live HelmRelease status, the live `cilium-config`
|
||||
ConfigMap, the agent pods' `startTime`, and a grep for `"flow"` in each agent's stdout.
|
||||
- **The chart value name, default and semantics**, quoted from cilium **v1.20.1**'s own
|
||||
`values.yaml` — the version actually running here (`quay.io/cilium/cilium:v1.20.1`), not a guess
|
||||
from another release: `rollOutCiliumPods: false` with the comment *"Roll out cilium agent pods
|
||||
automatically when configmap is updated."*
|
||||
- **All three value files carry the key** and both YAML files still parse, with
|
||||
`spec.values.rollOutCiliumPods` resolving to `true`.
|
||||
- **The blast-radius mitigation is genuinely present**, not assumed:
|
||||
`updateStrategy.rollingUpdate.maxUnavailable: 1` is already set in the HelmRelease, with a long
|
||||
existing comment explaining that a CNI rollout has no healthy-peer fallback.
|
||||
- **THE MECHANISM IS CONFIRMED BY CONTRAST, not just reasoned.** The cgroup fix (PR #42,
|
||||
2026-10-03) DID take effect: both the DaemonSet and the running pod mount
|
||||
`cilium-cgroup hostPath=/sys/fs/cgroup`, matching what the HelmRelease declares. The difference is
|
||||
exactly the one claimed above — #42 changed the POD TEMPLATE (a volume hostPath), which rolls the
|
||||
DaemonSet on its own, while #50 changed ONLY the ConfigMap, which does not. It also explains the
|
||||
agents' `startTime 2026-10-04T07:11Z`: that was #42's rollout, and nothing has rolled them since.
|
||||
So the inertness is specific to ConfigMap-only changes, which is precisely what this flag fixes.
|
||||
- **Flux is healthy before this change** — every Kustomization `Ready=True`, so this lands on a clean
|
||||
graph rather than into an existing cascade.
|
||||
- **Toolbox gate**: recorded in the PR.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE OUTCOME, ENTIRELY. Unreconciled.** No agent has rolled, no flow has been exported. This
|
||||
change is an argument that the rollout will happen, not evidence that it did.
|
||||
- **Whether `filePath: stdout` actually works** — still the open question from #50, and it cannot be
|
||||
answered until the agents restart. If the literal is not special-cased, the agent may create a
|
||||
FILE named `stdout` and nothing reaches Loki: a silent no-op, not a crash. Fallback documented in
|
||||
#50: keep the chart's default path and add a `loki.source.file` target to Alloy with a hostPath
|
||||
mount. **Check for flow lines in the agent logs immediately after the rollout.**
|
||||
- **THE ROLLOUT'S IMPACT ON LAN DNS IS UNMEASURED AND IS THE MAIN RISK.** The node holding the L2
|
||||
lease for the Gateway VIP, **PiHole's DNS VIP** and Plex's VIP loses them briefly as it cycles.
|
||||
`maxUnavailable: 1` bounds it to one node at a time, and BPF maps persist across an agent restart,
|
||||
but I have not measured how long a VIP is unreachable during a lease move, nor whether the two
|
||||
PiHole replicas cover the gap. Merge it at a quiet moment.
|
||||
- **Whether enabling this is the operator's preferred trade.** The alternative — leaving it `false`
|
||||
and restarting by hand — keeps config edits non-disruptive at the cost of them being silently
|
||||
inert. I judged silent inertness the worse failure, but it is a judgement, not a measurement.
|
||||
- **Whether anything ELSE in `cilium-config` is currently inert.** I checked the one other recent
|
||||
Cilium change and it is fine (see below), but I did not audit every key against the running
|
||||
agents' actual state.
|
||||
- **`hubble.relay.rollOutPods` / `hubble.ui.rollOutPods`** are left at their `false` defaults. Both
|
||||
have the same silently-inert property for their own ConfigMaps; not addressed here.
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: docs/oauth2-sidecar-scrape-verified
|
||||
title: The gate-sidecar scrape is verified working on prod — and four places in the tree still said it wasn't
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Documentation only. No manifest, no rendered output, no behaviour.
|
||||
|
||||
PR #49 merged as `21c88b0` and the fix works, but four places in the tree still asserted the
|
||||
*pre-fix* state as current — in the present tense, in capitals, in the files a reader would consult
|
||||
first. This is the stale-documentation failure mode this repo has been burned by twice already
|
||||
(`git push origin main` in the runbooks, "append to DECISIONS-LOG.md" in the old handoffs), so:
|
||||
|
||||
- **`platform/oauth2-proxy/podmonitor.yaml`** — the banner read *"READ THIS BEFORE BELIEVING THE
|
||||
TARGETS ARE UP: THE SCRAPE IS CURRENTLY DENIED BY NETWORKPOLICY"* and *"SO THE TARGETS BELOW WILL
|
||||
READ `up == 0` UNTIL AN INGRESS ALLOWANCE LANDS"*. Both now false, and the first one actively
|
||||
instructs the reader to disbelieve a correct `up == 1`. Replaced with a `STATUS: THE SCRAPE WORKS`
|
||||
block carrying the post-fix numbers; the diagnosis is kept below it under an explicit `HISTORY`
|
||||
heading, because it is why the fix is shaped the way it is. Added the drop fingerprint to
|
||||
recognise if it regresses — `lastScrapeDuration` pinned at *exactly* the `scrapeTimeout` with
|
||||
`context deadline exceeded`, versus 1–6 ms when healthy.
|
||||
- **`catalog/render.py:159`** — *"THIS IS THE PREREQUISITE ONLY, AND IT IS NOT YET SCRAPED … Do not
|
||||
read this flag as 'metrics are working'."* Rewritten to enumerate the three pieces a working
|
||||
scrape needs and which PR landed each. The warning is **kept, deliberately**: the flag alone
|
||||
really is insufficient, and "1 without 3" is exactly what caused the 63.7h outage. Only the
|
||||
"not yet scraped" claim was false.
|
||||
- **`catalog/render.py:1365`** — the sidecar arg comment, same correction, one line.
|
||||
- **`platform/oauth2-proxy/README.md`** — its two observability subsections are *dated*
|
||||
("measured 2026-09-30", "Update 2026-10-01"), so they are honest records rather than lies. But a
|
||||
reader hits the stale body long before any correction, so a `> SUPERSEDED` pointer now sits
|
||||
directly under the heading, and an `### Update 2026-10-05` section at the end carries the
|
||||
before/after table, the TSDB series counts and the alert outcome.
|
||||
|
||||
Two substantive errors fixed while there, not just tense:
|
||||
|
||||
1. The README's *"To close the gap for an app, three things are needed"* listed **a Service port
|
||||
and a ServiceMonitor**. Wrong — a PodMonitor targets pod ports directly, which is the whole
|
||||
reason no Service exposes 44180 and the renderer needed no Service change. Following that
|
||||
paragraph would have sent someone to edit `catalog/render.py`'s Service rendering for no reason.
|
||||
2. `podmonitor.yaml` ended by deferring the fix to *"the catalog/renderer, which is in flight on
|
||||
another branch"* — that branch does not exist and never did; the work happened in PR #49. It now
|
||||
names `r_monitoring_ingress_policy` directly, and a new `CRAFTY` section explains why crafty is
|
||||
the one namespace this file cannot help.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **The fix itself, on prod 2026-10-05, read-only** (this is the evidence the docs now quote):
|
||||
Flux applied `21c88b0` through the real path — `apps-wave-0` `lastAppliedRevision` at 21:37:01Z,
|
||||
`apps-wave-1` at 21:37:46Z, in dependency order — and all five `<app>-allow-monitoring-scrape`
|
||||
CNPs exist, each `ports=['44180']`, `from=[['monitoring']]`.
|
||||
- **All 6 targets `up == 1`**, `health=up`, `lastError=''`, `lastScrapeDuration` 0.001–0.006s —
|
||||
against 10.0s and `context deadline exceeded` before.
|
||||
- **`oauth2_proxy_*` is in the TSDB for these namespaces for the first time:** 5 metric names,
|
||||
148 series; `sum by (namespace) (oauth2_proxy_requests_total)` = qbittorrent 35072, radarr 23560,
|
||||
sonarr 1284. (Queried with `count by (__name__)` plus a matcher, not label-values.)
|
||||
- **Alerts:** all 5 `TargetDown` and 5 of 6 `OAuth2ProxyGateUnobserved` resolved; one series
|
||||
remains, `OAuth2ProxyGateUnobserved{namespace="crafty"}`, firing.
|
||||
- **The rendered output is byte-identical.** `catalog/render.py` changed only in comments, so this
|
||||
branch touches no `apps/` file and no golden. Confirmed by `render-golden` and `l1` both passing
|
||||
with no regeneration, and by `git status` showing nothing under `apps/`.
|
||||
- **This branch touches no sensitive path**, unlike PR #49: `platform/oauth2-proxy/**`,
|
||||
`catalog/**` and `decisions/**` are none of them in `infra/gitea/approval-policy.json`. So it
|
||||
should merge on a green gate with no human approval — the opposite of the PR it documents, and
|
||||
the reason the two were kept apart.
|
||||
- `render.py --selftest`: PASS.
|
||||
- Toolbox gate: see the handoff's own rule — reported below rather than claimed here.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **I did not re-measure the 63.7h figure or the ~22 emails/day rate**; both are carried forward
|
||||
from the 2026-10-04 measurements, now quoted inside the docs as historical. The 15-day
|
||||
`query_range` that established "one episode, not flapping" was run once, on 2026-10-04.
|
||||
- **`oauth2_proxy_requests_total` is still absent for jackett and pihole.** I assert in the README
|
||||
that this is because the counter is not created until the gate serves its first request. That is
|
||||
the documented behaviour of a Prometheus counter with no initialisation, and it is consistent
|
||||
with the other three namespaces having traffic — but I did not force a request through jackett's
|
||||
gate and watch the series appear. If it is still absent after real use, that is a finding.
|
||||
- **The auth-churn rule is still unwritten.** The README now says it is "writable rather than
|
||||
decorative"; that is a claim about the metrics being present, not a rule anyone has authored or
|
||||
unit-tested. The thing the 2026-09-30 incident actually needed does not exist yet.
|
||||
- **crafty remains unobserved.** I read its container spec on 2026-10-05 and confirmed no
|
||||
`--metrics-address`; I did not attempt the fix, and nothing here changes its alert.
|
||||
- **I did not audit the whole tree for other stale "metrics are not scraped" claims** beyond the
|
||||
four sites a targeted grep found (`not yet reachable`, `UNTIL AN INGRESS`, `up == 0`,
|
||||
`not reachable by Prometheus` and neighbours). `runbooks/oauth2-proxy-auth-failures.md` is
|
||||
described in the README as "the by-hand substitute meanwhile" and I left it alone — the by-hand
|
||||
log procedure still works and is not wrong, but whether it should now be rewritten against the
|
||||
new metrics is unexamined.
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: fix/plex-resolver-independent-advertise
|
||||
title: Plex down LAN-wide — the advertised name existed only in PiHole, so clients that bypass it landed on the router's %D cert
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**I took Plex down for the whole LAN.** Reported symptom: every internal client showed a certificate
|
||||
error with `%D` as the subject name and could not connect.
|
||||
|
||||
**Root cause: the advertised connection depended on our own resolver.** `ADVERTISE_IP` was
|
||||
`http://plex-vip.${BASE_DOMAIN}:32400` — a name that exists ONLY in PiHole's split-horizon answers
|
||||
and in no public zone. A Plex client resolves the advertised host *itself*, with whatever resolver it
|
||||
happens to use, and on a real LAN that is frequently not PiHole: browsers ship DoH on by default,
|
||||
phones have "private DNS", guest/IoT devices take the router's DNS. Those clients could not resolve
|
||||
the name, the SOHO router answered the failed lookup with **itself**, and they all landed on the
|
||||
router's web UI.
|
||||
|
||||
| Measured 2026-10-05 | Result |
|
||||
|---|---|
|
||||
| `192.168.2.1:443` (router, LAN side) | `C = ZZ, ST = Somewhere, O = %D342e7e6b, CN = %D` |
|
||||
| `136.51.115.252:443` (router, WAN side) | the same `%D` cert |
|
||||
| PiHole **pihole-0** | `plex -> 192.168.2.20`, `plex-vip -> 192.168.2.24` ✅ |
|
||||
| PiHole **pihole-1** | identical ✅ (both behind the `.23` VIP) |
|
||||
| Gateway `.20:443` cert | `CN = *.home.keithjackson.dev`, valid to 2026-12-12 ✅ |
|
||||
| `http://192.168.2.24:32400/identity` | `200` ✅ |
|
||||
| Plex's published connections | all port **32400** — nothing on 443 |
|
||||
|
||||
**The DNS was right; the dependency on it was the bug.** PiHole answered correctly from both
|
||||
replicas the entire time. And because nothing Plex advertises listens on 443, the clients had plainly
|
||||
left the Plex path altogether and were talking to the router.
|
||||
|
||||
**The fix: go back to `<dashed-ip>.<serverhash>.plex.direct`, which is RESOLVER-INDEPENDENT.** This
|
||||
is Plex's own mechanism and the reason it exists — `plex.direct` is a public zone that returns the
|
||||
private address embedded in the label, so *every* resolver (PiHole, the router, 1.1.1.1, a DoH
|
||||
browser) answers `192-168-2-24.<hash>.plex.direct` with `192.168.2.24`. It needs no split-horizon
|
||||
entry, no Cloudflare record and no certificate work, because the wildcard cert Plex already serves on
|
||||
32400 covers exactly that name.
|
||||
|
||||
- **`catalog/plex.yaml`:** `ADVERTISE_IP` →
|
||||
`https://192-168-2-24.130072f20c334d6c8f63d0dfef87309b.plex.direct:32400`, with the
|
||||
resolver-independence requirement written at the top of the block as the first thing to check
|
||||
before anyone changes this field again.
|
||||
- **PiHole's `plex-vip` answer is KEPT.** It is harmless, costs nothing, and is useful for reaching
|
||||
the VIP by name from a LAN shell. It is simply not what Plex advertises.
|
||||
|
||||
**Why not keep the nicer hostname.** It cannot be made correct for clients that bypass PiHole: a
|
||||
public record for it resolves to the WAN address, and with no NAT hairpin on this router a LAN client
|
||||
sent there fails too. The name is right only for PiHole users and wrong for everyone else, and there
|
||||
is no configuration of it that is right for both. `plex.direct` is right for both.
|
||||
|
||||
**The honest cost, restated rather than buried:** the value embeds both the VIP and the server's
|
||||
certificate hash as a literal — envsubst cannot dash an IP and nothing in the tree can derive the
|
||||
hash, so nothing checks that they still agree. That fragility is exactly why the hostname was tried.
|
||||
It is accepted because an unresolvable name is a *worse* failure: silent, LAN-wide, and it points
|
||||
users at their router.
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, 2026-10-05, read-only.
|
||||
|
||||
- **Every row of the table above**, including the router's `%D` cert on both its LAN and WAN sides —
|
||||
which is what identifies the router as what the clients were reaching.
|
||||
- **THE FIX ITSELF, with full TLS validation (no `curl -k`)**: the plex.direct name resolves to
|
||||
`192.168.2.24` and `/identity` returns **200, TLS verified**. Re-checked today, not carried over
|
||||
from yesterday's measurement.
|
||||
- **Both PiHole replicas are identical and correct**, with the same five `address=` lines live in
|
||||
`/etc/pihole/dnsmasq.conf`, and both IPs present behind the `pihole-dns-tcp`/`-udp` endpoints. I
|
||||
checked only pihole-0 at first and deliberately went back for pihole-1, because a split-brain pair
|
||||
would have produced exactly this intermittent-looking symptom.
|
||||
- **The cluster was never the problem:** all eight LoadBalancer VIPs still assigned, L2
|
||||
announcements intact (prod-3 holds `.20`/`.23`/`.24`), `wildcard-home` Certificate `Ready=True`,
|
||||
and PR #51 had reconciled (`customConnections="http://plex-vip.home.keithjackson.dev:32400"` was
|
||||
live, confirming the bad value was in effect).
|
||||
- **`LanNetworksBandwidth="10.0.0.0/8,192.168.0.0/16"` is still set**, so the postStart hook survived
|
||||
the roll and keeps re-asserting.
|
||||
- **The Cilium rollout from #50 STILL has not happened** — agents are unchanged since
|
||||
2026-10-04T07:11, so the Hubble export is inactive and `filePath: stdout` remains unproven.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE OUTCOME. Unreconciled.** No client has been observed connecting after this change. The
|
||||
argument is that the advertised name now resolves for every resolver and serves a matching
|
||||
certificate — both measured — but I have not seen a Plex client complete a connection, and I
|
||||
cannot see the user's clients at all.
|
||||
- **THE CLIENT-SIDE RESOLVER PATH IS INFERRED, NOT OBSERVED.** I could not query a public resolver
|
||||
from here — my attempt returned NXDOMAIN even for a `plex.direct` name that certainly resolves, so
|
||||
the probe was broken (egress policy or a missing tool) and I discarded it rather than report it.
|
||||
The chain "client bypasses PiHole → NXDOMAIN → router answers with itself → `%D`" is the only
|
||||
explanation consistent with all the measurements, but the NXDOMAIN-hijack step in particular is
|
||||
deduced from the router serving `%D` on its LAN address, not from watching a lookup.
|
||||
- **Which clients bypass PiHole, or how many.** If in fact every client uses PiHole, then my
|
||||
explanation is wrong and something else sent them to the router — in which case the next thing to
|
||||
check is whether the clients were using `plex.home.keithjackson.dev` (port 443, the Gateway) rather
|
||||
than an advertised connection at all.
|
||||
- **Whether plex.tv will re-publish promptly** after the value changes, and whether stale connection
|
||||
lists are cached on clients. A client holding the old list may keep failing until it refreshes.
|
||||
- **Mixed content.** The previous `http://` value would also have been blocked by the browser for
|
||||
anyone using `https://app.plex.tv`; that is fixed incidentally by returning to `https`, but I did
|
||||
not test it.
|
||||
- **The Remote badge — the symptom that began this whole sequence — remains unconfirmed.**
|
||||
`LanNetworksBandwidth` is set, but Plex still tags traffic `Allowed Network (WAN)`. Unchanged and
|
||||
still unexplained.
|
||||
- **`ManualPortMappingMode="1"` with an absent port** is still there, untouched.
|
||||
@@ -0,0 +1,259 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: feat/pm-agent-issue-to-pr
|
||||
title: An in-cluster AI Program Manager that turns Gitea issues into pull requests — authored, nothing deployed
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
A new service, `pm-agent`, that runs in the cluster, reads the issues of this Gitea
|
||||
repository, decides what to do about each thread **as the Program Manager persona this
|
||||
repository already works under**, and runs one worker agent per assignment that authors a
|
||||
change, runs the merge gate and opens a pull request. It reacts to issue creation and to
|
||||
comments; the assignment and all correspondence travel through the issue thread and the PR.
|
||||
It authenticates with an Anthropic **subscription** token. It never pushes to `main` and
|
||||
never merges.
|
||||
|
||||
Design, alternatives and threat model: `docs/plans/pm-agent-issue-to-pr.md`. Operating
|
||||
surface: `platform/pm-agent/README.md`. Operator procedures:
|
||||
`runbooks/pm-agent.md`.
|
||||
|
||||
**New — the agent itself** (`infra/agent/`): one image (`agent-runner`) layered on the pinned
|
||||
dev toolbox plus node and the Claude Code CLI, with two fixed entrypoints and no argument
|
||||
surface. `pm-dispatch` is the PM; `pm-worker` is one assignment. `pm-persona.md` is the PM
|
||||
persona, assembled from `CLAUDE.md`, the frozen log's PM guardrails and the spec repo's
|
||||
`agents/roles.md`. `worker-job-template.yaml` is the only Job shape the dispatcher may
|
||||
create, baked into the image rather than mounted, because that template *is* the shape the
|
||||
admission policy protects. `versions.env` pins only what the toolbox lacks — deliberately not
|
||||
a second copy of the toolchain pins. `build-image.sh` builds, verifies in-image as uid 65532
|
||||
on a read-only rootfs, and **never pushes**.
|
||||
|
||||
**New — the deployment** (`platform/pm-agent/`): namespace (no `app-namespace` label — this
|
||||
is CI plumbing like `gitea-actions`; PodSecurity `restricted` is enforced), `rbac.yaml` (the
|
||||
dispatcher may `create/get/list/watch jobs` in its own namespace and nothing else; the worker
|
||||
identity is bound to **nothing** and mounts no token), the dispatcher Deployment (1 replica,
|
||||
`Recreate` — two dispatchers would double-trigger every thread), `configmap.yaml` (every knob,
|
||||
with its reasoning), `networkpolicy.yaml` (default-deny; egress to gitea:3000,
|
||||
kube-apiserver for the dispatcher only, tcp/443; **no ingress at all**), and
|
||||
`pm-agent-credentials.sops.yaml` holding **placeholders**.
|
||||
|
||||
**New — the boundary** (`platform/kyverno/admission/pm-agent-job-shape.yaml`, `Enforce` +
|
||||
`failurePolicy: Fail`): Kubernetes places no `use` check on `serviceAccountName`, so "may
|
||||
create Jobs" is "may run arbitrary code as the namespace's most privileged identity". Six
|
||||
rules admit only the committed shape. It is listed in `platform-config/kustomization.yaml`,
|
||||
which is the only list that actually applies ClusterPolicies — adding it to
|
||||
`platform/kyverno/admission/kustomization.yaml` alone is a silent no-op, which is exactly how
|
||||
the conformance boundary shipped green and never reached the cluster on 2026-09-29.
|
||||
|
||||
**Wiring:** `clusters/base/pm-agent.yaml` (dependsOn `apps-wave-0` — what it needs is Gitea,
|
||||
not Keycloak or the Gateway; `wait: false`, because the pod is *expected* to be unhealthy
|
||||
while its credentials and image digest are placeholders), its line in
|
||||
`clusters/base/kustomization.yaml`, a SOPS decryption patch in **both** overlays, and an
|
||||
`inClusterClients` entry for the `pm-agent` namespace on `catalog/gitea.yaml` with
|
||||
`apps/wave-0/gitea.yaml` re-rendered by the canonical command.
|
||||
|
||||
Three design choices worth naming, with the alternatives recorded in the plan: it **polls**
|
||||
rather than receiving webhooks (Gitea's webhook SSRF guard, lost deliveries, and no need for
|
||||
inbound reach); it keeps **no state** — the trigger is "the newest thing in this thread was
|
||||
not written by me", so the thread is the state and there is no PVC and no backup obligation;
|
||||
and the **model holds no tools** — its reply is one JSON object that this code validates, so
|
||||
nothing in the path executes text.
|
||||
|
||||
## What I verified
|
||||
|
||||
All offline, in the pinned toolbox (`./infra/devcontainer/dev.sh run`), never on the host.
|
||||
|
||||
- **`kyverno test platform/kyverno/tests/pm-agent` → 28 passed, 0 failed** under the pinned
|
||||
CLI (v1.19.0). The first run was **27 passed, 1 failed** and caught a real defect: the
|
||||
volume rule asserted `length(emptyDir) != 0`, which denies the valid `emptyDir: {}`. Fixed
|
||||
by asserting the volume's **key set** instead, which also denies every other source type
|
||||
without enumerating them. The suite includes the two cases a careless policy admits — a
|
||||
second container while `containers[0]` is compliant, and a volume *named* `workspace` whose
|
||||
source is a `hostPath`.
|
||||
- **`./ci/gate.sh` → exit 3, MERGE GATE: UNDETERMINED. Not green, and not reported as
|
||||
green.** Per-stage, quoted from the run: spec-immutability **pass** (the spec repo is a
|
||||
sibling of this checkout and is clean at the pinned SHA), decisions-log **pass**, lint
|
||||
**pass**, l0 **pass**, render-golden **pass** (10 services reproduce), l1 **pass**,
|
||||
approval-policy **pass** (N/A off a pull-request event), conformance **declared-skip** (no
|
||||
cluster — Phase 0), and `gitea-settings` **SKIPPED**: `SETTINGS_API_TOKEN` is not set on
|
||||
this workstation, so branch protection and runner registration are UNVERIFIED by this run.
|
||||
The gate names that single stage as the reason for rc 3 ("Stages that did not run:
|
||||
gitea-settings"), so nothing in the change is unexamined — but rc 3 is not a pass and this
|
||||
entry does not call it one.
|
||||
- **The PR's OWN CI run, read from its log rather than from the status tick** (PR #57, head
|
||||
`8b5e950b`, `.gitea/workflows/gate.yml` with `GATE_STRICT_SKIPS=true` and
|
||||
`REQUIRE_GITEA_SETTINGS=true`): decisions-log **pass**, lint **pass**, l0 **pass**,
|
||||
render-golden **pass**, l1 **pass**, **gitea-settings pass** — including
|
||||
`block_admin_merge_override = true`, which is the one stage my workstation cannot run at
|
||||
all — spec-immutability **SKIPPED** (the spec repo is absent in CI, as its own step
|
||||
tolerates), conformance **SKIPPED**, and **approval-policy FAIL → MERGE GATE: BLOCKED**.
|
||||
The failure is *only* the sensitive-path rule: "This PR changes the pipeline or the
|
||||
security posture it rests on, and has NO approving review from anyone other than its author
|
||||
('gitea-admin')", over eleven paths — `clusters/{base,dev,prod}/*`,
|
||||
`platform-config/kustomization.yaml`, `platform/kyverno/{admission,tests}/pm-agent*`,
|
||||
`platform/pm-agent/rbac.yaml` and `platform/pm-agent/pm-agent-credentials.sops.yaml`.
|
||||
**That is the gate working, not a defect in the change**, and it is the one thing an agent
|
||||
must not clear for itself: the operator posts `/approve-override <reason>` as a PR *review*,
|
||||
or a non-author reviews it. CI is therefore red and will stay red until a human acts.
|
||||
- **The commit-status API did not reflect any of that.** While the run completed and its log
|
||||
carried the verdict above, `GET /commits/{sha}/status` still reported
|
||||
`gate / gate (pull_request): pending — Waiting to run`. Worth knowing before anyone builds
|
||||
automation on that endpoint: on this Gitea the status tick lagged the run indefinitely, so
|
||||
the log is the source of truth. A fresh instance of §2b — "a green tick is not proof
|
||||
anything ran"; here the tick was not even wrong, it was absent.
|
||||
- **The gate caught two real defects in this change before I did**, which is worth recording
|
||||
because both were in the parts I was most confident about:
|
||||
1. `flux-postbuild-substitution` → 1 UNDEFINED + 1 FATAL-PARSE. My ConfigMap's *comment*
|
||||
explaining the envsubst hazard contained two dollar-brace tokens, and envsubst runs over
|
||||
comment lines too — so the paragraph warning about the trap was itself the trap. Rewritten
|
||||
without braces.
|
||||
2. `no-plaintext-secrets` → the unencrypted `.example` template was read as REFERENCED by a
|
||||
kustomization, because the kustomization's comment named it by filename. Correctly
|
||||
refused: the distance between a filename in a comment and a line under `resources:` is
|
||||
one careless edit. The comment now points at it in prose.
|
||||
- **`infra/agent/probe-validation.sh` → 17 refusals, 0 failures.** A new, committed
|
||||
negative-path probe for `pm-worker`'s input guards — the things that stand between an
|
||||
injected environment variable and a clone from the wrong host or a push to the wrong ref.
|
||||
Each case asserts a non-zero exit AND the specific message, so a guard that stopped firing
|
||||
cannot pass by failing somewhere else. It refuses: an argument on the pinned command;
|
||||
`main`, a wrong prefix, `feat/x;id`, `../../etc/passwd`, too-short slugs, and a branch equal
|
||||
to the base; a redirected git host, a real-and-reachable wrong one (`github.com` — the
|
||||
egress policy permits tcp/443, so this one is genuinely reachable), and the right host with
|
||||
a smuggled path; a malformed owner or repository; an unknown mode; a non-numeric and a zero
|
||||
issue number; and each missing credential. `build-image.sh` also runs it inside the built
|
||||
image, where the interpreter is the one that will run in the cluster.
|
||||
- **Writing that probe corrected an overclaim in the code.** The `PM_BRANCH != "main"` guard
|
||||
is UNREACHABLE while the pattern check above it holds (`main` has no `feat|fix|docs|chore/`
|
||||
prefix), so the comment claiming it as the defence against pushing to main was wrong about
|
||||
which line does the work. It is kept — as an assertion that does not depend on the regex
|
||||
being right — and the comment now says so.
|
||||
- **Two more defects found by self-review of the unattended paths, both fixed:** the worker's
|
||||
`EXIT` trap ended in a failing test, which under `set -e` can replace the script's real exit
|
||||
code with that failure; and a provenance *log line* read a file through a redirect that
|
||||
`set -o pipefail` would have turned into a dead worker if the file were absent.
|
||||
- **A self-review of the trigger found and fixed one real defect** before any of this ran:
|
||||
`last_activity()` compared RFC 3339 timestamp STRINGS to find the newest comment, which is
|
||||
only correct while every offset Gitea returns is identical — and the failure mode is the
|
||||
worst available one, the PM answering its own last comment in a loop. It now takes the last
|
||||
element of the comments list, which is the order the endpoint returns, so there is nothing
|
||||
about timezones to be right about.
|
||||
- **`check-jsonschema --schemafile schemas/servicedefinition.schema.json catalog/gitea.yaml`
|
||||
→ ok.**
|
||||
- **The golden reproduces:** `python3 catalog/render.py --all catalog -o apps --wave-layout
|
||||
--no-image-automation` leaves `apps/wave-0/gitea.yaml` byte-identical to what is committed,
|
||||
and the only diff versus `main` is the new `gitea-allow-pm-agent-ingress`
|
||||
CiliumNetworkPolicy.
|
||||
- **Syntax:** `bash -n infra/agent/pm-worker` and `ast.parse` on `pm-dispatch.py` both clean.
|
||||
`yaml.safe_load_all` parses every new manifest.
|
||||
- **The spec repo has no git remote** — `git remote -v` in `../home-network-k3s` prints
|
||||
nothing. That is *why* a worker's gate can never run `spec-immutability` in-cluster, and it
|
||||
is measured rather than assumed. The worker's acceptance rule names that stage and
|
||||
`gitea-settings` explicitly and refuses any other skip.
|
||||
- **`PM_SELFTEST=1 python3 infra/agent/pm-dispatch.py` → exit 0, 26 assertions, all ok**
|
||||
(run in the toolbox against the repository's own files, with `PM_WORKER_JOB_TEMPLATE` and
|
||||
`PM_PERSONA_FILE` pointed at `infra/agent/`). It covers the branch vocabulary in both
|
||||
directions (`feature/x`, `main`, `feat/AB`, `feat/x;rm -rf /` and `../../etc/passwd` all
|
||||
refused), the five result-envelope shapes `extract_decision` has to survive plus three it
|
||||
must reject, the committed Job template against its whole contract (pinned command, no
|
||||
args, the powerless SA, no mounted token, the volume set, the security context, and every
|
||||
env name inside the dispatcher's allowlist), and that the persona still says the four
|
||||
things it must. `build-image.sh` runs the same selftest inside the image; that invocation
|
||||
is the one that has not happened, because the image has not been built.
|
||||
- **Claude Code facts were read from the current documentation, not from memory:**
|
||||
`claude setup-token` mints a one-year subscription OAuth token consumed via
|
||||
`CLAUDE_CODE_OAUTH_TOKEN`; `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` rank **above** it in
|
||||
credential precedence (hence the unsets and the env-name allowlist); `--bare` does **not**
|
||||
read it (hence neither entrypoint uses that flag); `--permission-prompts none` requires
|
||||
>= 2.1.259, which is why the image asserts a version floor. The pin `2.1.285` is the npm
|
||||
registry's `stable` dist-tag, measured on 2026-10-05.
|
||||
|
||||
## Merged `main` in, after PR #55 and #56 landed
|
||||
|
||||
One conflict, in `clusters/base/kustomization.yaml`: PR #55 (vpn-ingress) and this branch
|
||||
both appended a resource line to the same tail. Resolved by keeping both, main's first —
|
||||
`vpn-ingress` is a platform-config-tier leaf and `pm-agent` dependsOn `apps-wave-0`, which
|
||||
is later. Nothing else conflicted; `runbooks/kustomization.yaml` auto-merged with both
|
||||
entries, and the handoff entries are separate files with nothing to merge, which is exactly
|
||||
what `decisions/` being a directory buys.
|
||||
|
||||
**PR #56 added `CLAUDE.md` §0 — work in your own worktree, never the shared checkout —
|
||||
while this branch was open, and it named this run's uncommitted directories as its
|
||||
measured example.** Two consequences were handled rather than noted:
|
||||
|
||||
- This merge, and everything after it, was done in a worktree outside the repository root
|
||||
(`/var/home/keith/source/wt-pm-agent`), per §0.
|
||||
- **§0 would have misled a worker agent, so `infra/agent/worker-rules.md` now says so
|
||||
explicitly.** A worker reads `CLAUDE.md` first, and §0's instruction is to create a
|
||||
worktree outside the shared checkout. There is no shared checkout in a worker's container
|
||||
— it holds a fresh clone of its own, which is what §0 is actually asking for — and a
|
||||
worker that obeyed §0 literally would `git worktree add`, edit somewhere the wrapper does
|
||||
not look, and have its work committed by nobody while the issue got a "changed nothing"
|
||||
report. The rules file now tells it §0 is already satisfied and not to make a worktree.
|
||||
|
||||
**The operator's approving review goes STALE on each new head, and the gate does not care.**
|
||||
I first wrote here that it would need re-approving. That was wrong, and the correction is
|
||||
worth keeping because it is a fact about this repository's gate rather than about this
|
||||
change:
|
||||
|
||||
- Measured on the live branch protection: `dismiss_stale_approvals = true`,
|
||||
`ignore_stale_approvals = false` — so pushing the merge did stale `jakman85`'s review
|
||||
(confirmed: `GET /pulls/57/reviews` → `APPROVED`, `stale: true`).
|
||||
- But `ci/approval-policy.sh` selects reviews on `.state == "APPROVED"` and
|
||||
`.user.login != author` and reads **nothing about staleness** (line 147). So a stale
|
||||
approval still satisfies the sensitive-path rule.
|
||||
- And `required_approvals = 0`, so Gitea's own merge button never required one either.
|
||||
|
||||
Measured, not inferred: CI run **#257** on the merged head printed
|
||||
`approved by 'jakman85' (author 'gitea-admin') — sensitive change is human-reviewed.` →
|
||||
`stage approval-policy: pass`, with `gitea-settings: pass` as well. The job's own exit was
|
||||
`MERGE GATE: UNDETERMINED` (rc 3 — `spec-immutability` and `conformance` skipped, as the
|
||||
workflow tolerates with `|| [ $? -eq 3 ]`), so the required check
|
||||
`gate / gate (pull_request)` is **green** and PR #57 reports `mergeable: true`.
|
||||
|
||||
Nothing is therefore waiting on the operator except **the merge itself**, which is theirs
|
||||
by CLAUDE.md §1 and never an agent's. Worth noting for whoever reads this later: the rule
|
||||
"push again and you must override again" applies to the `/approve-override` **directive**,
|
||||
which is staleness-checked against the head commit — not to an ordinary approving review,
|
||||
which this gate accepts stale.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
**Nothing in this change has run.** That is the headline, and everything below is a
|
||||
consequence of it.
|
||||
|
||||
- **The image has never been built.** The nodejs module-stream install, the `npm i -g
|
||||
@anthropic-ai/claude-code@2.1.285` layer, the version-floor assertion, the `COPY`
|
||||
ownership assertions and `build-image.sh`'s whole in-image verification (including
|
||||
`PM_SELFTEST=1 pm-dispatch`) are **unexercised**. Expect the build to need iteration.
|
||||
- **It has never been pushed,** so the committed image digests are the deliberate all-zero
|
||||
placeholder. Nothing has pulled this image.
|
||||
- **No pod has started.** The dispatcher has never authenticated to Gitea, never polled,
|
||||
never triaged an issue, never created a Job. No worker has cloned, branched, run a session,
|
||||
run the gate in-cluster, pushed a branch, opened a pull request or commented on an issue.
|
||||
Every claim about runtime behaviour is a design claim.
|
||||
- **The admission policy has not met a live API server.** 28 offline assertions prove the
|
||||
policy *refuses*; nothing proves the API server *enforces* it here. That is an L2 test and
|
||||
it needs a cluster. Note also that `CLAUDE.md` §2b applies to me as much as to anyone: I
|
||||
did not query whether Kyverno is healthy, whether `pm-agent-job-shape` reconciles, or
|
||||
whether `platform-config` is Ready.
|
||||
- **The network policies have not been exercised.** Whether `toEntities: kube-apiserver`
|
||||
actually permits `kubectl` from this namespace on k3s, and whether `world:443` reaches
|
||||
`api.anthropic.com` through this cluster's egress, are both unmeasured.
|
||||
- **Neither credential exists.** The Gitea bot account has not been created, so its
|
||||
permission has not been measured — and *that measurement is the control* that keeps the
|
||||
agent from clearing its own sensitive-path review. Do it before the first real run.
|
||||
- **Gitea's webhook `ALLOWED_HOST_LIST` was not queried.** It is cited as a reason webhooks
|
||||
were rejected; it is not a measured fact about this Gitea.
|
||||
- **The `write`-can-merge gap is real and unclosed.** No Gitea token scope separates "push a
|
||||
branch and open a pull request" from "merge it". The service issues no merge call, which is
|
||||
a property of the code, not of the credential.
|
||||
- **The worker's credentials are inside the model session's reach.** Same container, same
|
||||
uid; no isolation exists and none is claimed. Bounded by token scope and egress only.
|
||||
- **This branch touches sensitive paths** — `clusters/**`, `platform/kyverno/**`,
|
||||
`platform-config/kustomization.yaml`, `**/rbac.yaml`, `**/*.sops.yaml` — so it needs an
|
||||
approving review from a non-author, or an administrator's `/approve-override` **posted by
|
||||
the operator**. An agent must not clear this one on its own PR; that is precisely the
|
||||
self-approval the stage exists to prevent.
|
||||
- **No observability.** No metrics endpoint, no ServiceMonitor, no alert, and the namespace's
|
||||
default-deny would drop a scrape from `monitoring` if one were added. Follow-ups are listed
|
||||
in the plan, in the order they are worth doing.
|
||||
@@ -0,0 +1,127 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: docs/pm-agent-registry-pull-prereq
|
||||
title: pm-agent reconciled exactly as designed — and is the first workload ever to pull from the in-cluster registry, which does not work yet
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Documentation only. No manifest, no policy, no code.
|
||||
|
||||
PR #57 merged and `pm-agent` reconciled. I measured what landed, and the measurement turned
|
||||
up **two prerequisites for the next operator step that nothing in this repository
|
||||
declares** — and that the handoff entry for #57 could not have known, because it listed the
|
||||
image pull as unverified and that is exactly where it bit.
|
||||
|
||||
So this entry records the post-merge measurement, and
|
||||
`platform/pm-agent/README.md`, `docs/plans/pm-agent-issue-to-pr.md` and
|
||||
`runbooks/pm-agent.md` now name both prerequisites where they say "build, push, pin the
|
||||
digest". Before this, that step read as one action; it is three, and the two new ones are
|
||||
not an agent's to perform.
|
||||
|
||||
**The finding is not specific to pm-agent.** `conformance-runner` has the identical
|
||||
registry path (`gitea.home.keithjackson.dev/home-network/...`), the identical "operator
|
||||
pushes, a digest goes in a PR" model, and has never been pulled either — so the same two
|
||||
blockers sit in front of the L2/L3 conformance wiring. Measured: **no pod anywhere in the
|
||||
cluster references that registry** except the pm-agent dispatcher, which is therefore the
|
||||
first workload ever to try it.
|
||||
|
||||
## What I verified
|
||||
|
||||
Read-only, through the pinned toolbox, against the live prod cluster.
|
||||
|
||||
**The merge reconciled, completely and exactly as designed.**
|
||||
|
||||
- `GitRepository/flux-system` artifact revision: `main@sha1:95597bd6883637…` — the merge
|
||||
commit. `platform-config`, `pm-agent` and `apps-wave-0` all report that same
|
||||
`lastAppliedRevision` with `Ready=True`. Compared field to field, per CLAUDE.md §2b,
|
||||
rather than assumed from the merge.
|
||||
- **The security boundary is live.** `ClusterPolicy/pm-agent-job-shape`:
|
||||
`validationFailureAction=Enforce`, `failurePolicy=Fail`, `Ready=True`, and all six rules
|
||||
present (`worker-must-be-the-committed-shape`,
|
||||
`worker-must-be-one-container-with-no-args`,
|
||||
`worker-volumes-must-be-workspace-or-tmp-emptydirs`,
|
||||
`worker-may-not-declare-init-or-ephemeral-containers`,
|
||||
`worker-env-names-must-be-the-committed-set`, `worker-may-not-use-envfrom`). This is the
|
||||
check that mattered most: a ClusterPolicy added to the wrong kustomization list ships
|
||||
green and never reaches the cluster, which is what happened to the conformance boundary
|
||||
on 2026-09-29.
|
||||
- Namespace contents: both ServiceAccounts, `pm-agent-config` (17 keys), the
|
||||
`pm-agent-credentials` Secret, the `default-deny` NetworkPolicy, and both
|
||||
CiliumNetworkPolicies with `VALID=True`.
|
||||
- **SOPS decryption works through the new overlay patch.** Checked by KEY NAMES and VALUE
|
||||
LENGTHS only, never values (CLAUDE.md §6): keys `gitea-token`, `claude-code-oauth-token`,
|
||||
lengths 76 and 52 bytes — which are the base64 lengths of the committed placeholder
|
||||
strings, so the Secret holds the decrypted placeholders and not ciphertext. A
|
||||
ciphertext apply succeeds silently and would have left `ENC[AES256_GCM,…]` values an
|
||||
order of magnitude longer.
|
||||
- **`pm-agent-worker` really is bound to nothing.** The namespace has exactly one
|
||||
RoleBinding (`pm-agent` → Role `pm-agent`, subject `ServiceAccount/pm-agent`), and no
|
||||
ClusterRoleBinding anywhere names either pm-agent ServiceAccount.
|
||||
- `gitea-allow-pm-agent-ingress` is applied in the `gitea` namespace, matching
|
||||
`io.kubernetes.pod.namespace: pm-agent`.
|
||||
- The dispatcher is `ImagePullBackOff` on the all-zero placeholder digest — the designed,
|
||||
visible failure.
|
||||
|
||||
**The two prerequisites, measured.**
|
||||
|
||||
1. **The nodes cannot verify the registry's TLS certificate.** containerd's error on the
|
||||
pull is not about the digest:
|
||||
|
||||
failed to resolve reference "gitea.home.keithjackson.dev/home-network/agent-runner@sha256:000…":
|
||||
tls: failed to verify certificate: x509: certificate is not valid for any names,
|
||||
but wanted to match gitea.home.keithjackson.dev
|
||||
|
||||
"not valid for **any** names" means the certificate presented to the node carries no
|
||||
matching SAN at all. From the operator's workstation the same name is healthy: it
|
||||
resolves to `192.168.2.20` and serves a Let's Encrypt certificate
|
||||
`CN=*.home.keithjackson.dev` with SANs `*.home.keithjackson.dev, home.keithjackson.dev`
|
||||
— and `Certificate gateway/wildcard-home` is `Ready=True` for exactly those names. So
|
||||
the certificate that *should* be served is fine, and what the NODE reaches is not what
|
||||
the workstation reaches. **Correcting the digest will not clear this.**
|
||||
2. **Anonymous pulls from this registry are refused.** Not inferred from a bare 401 — that
|
||||
would have been wrong, because this Gitea answers 401 for *everything* anonymously:
|
||||
`/v2/home-network/agent-runner/manifests/latest`, `/v2/home-network/conformance-runner/…`,
|
||||
a randomly-named nonexistent repository, and `nosuchowner/nosuchimage` all return 401,
|
||||
and `/api/v1/packages/Home-Network` returns `{"message":"token is required"}`. The
|
||||
registry *does* issue a token to an anonymous client, but its `access` claim is `null` —
|
||||
no pull scope. So the conclusion that holds is the simple one: **an unauthenticated
|
||||
client gets nothing from this registry**, so a pull with no credential cannot succeed.
|
||||
And `grep` over the tree finds **no `imagePullSecrets` anywhere** — not in
|
||||
`platform/pm-agent/deployment.yaml`, not in `infra/agent/worker-job-template.yaml`, not
|
||||
in `conformance/incluster/`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Why the node sees a nameless certificate.** I measured that it does, and that the LAN
|
||||
does not. I did not diagnose it: I did not read the nodes' resolver or
|
||||
`/etc/rancher/k3s/registries.yaml` (node files, not reachable read-only through the API),
|
||||
and I did not check whether the node resolves the name to the same `192.168.2.20` the
|
||||
workstation does or to something else.
|
||||
|
||||
**A correction to a remark I first wrote here:** I cited
|
||||
`cluster-vars`'s `GATEWAY_VIP: 192.168.1.53` as suspicious next to the nodes'
|
||||
`192.168.2.x`. That was noise — the operator confirms those base `cluster-vars` values are
|
||||
**dev-era leftovers for a cluster that is decommissioned**, so the 192.168.1.x addresses in
|
||||
that file say nothing about prod and should not be read as evidence about anything. The
|
||||
measured facts stand on their own and none of them depended on that knob.
|
||||
- **Whether the package would be pullable once authenticated.** Nothing has ever been
|
||||
pushed to that path, so "a credential is sufficient" is untested. The only thing
|
||||
established is that no credential is insufficient.
|
||||
- **Which fix the operator wants.** Two routes, and they are not equivalent:
|
||||
(a) an `imagePullSecret` — a `dockerconfigjson` holding a Gitea token, SOPS-encrypted
|
||||
like `pm-agent-credentials`, referenced from the Deployment *and* from the committed
|
||||
worker Job template; note the Job template's shape is pinned by admission, and
|
||||
`imagePullSecrets` is a field that policy does not currently constrain, so adding one is
|
||||
a template change that needs a deliberate look at whether the policy should pin it too.
|
||||
(b) allowing anonymous reads of that package in Gitea — no manifest change at all, but it
|
||||
makes the image world-readable to anything that can reach the host.
|
||||
I have implemented neither: both need an operator action I must not take, and (b) is a
|
||||
posture decision. The docs now present both.
|
||||
- **Everything the #57 entry listed as unverified about RUNTIME remains unverified.** The
|
||||
image has still never been built or pushed. No dispatcher has authenticated to Gitea, no
|
||||
issue has been triaged, no worker Job has been created — so the admission policy, though
|
||||
now provably applied and Enforce, has still never refused or admitted a real Job. The
|
||||
network policies have not carried traffic.
|
||||
- **I did not re-run the merge gate against `main` after the merge.** This branch's own gate
|
||||
run is the evidence for this change; the merged state's is CI run on PR #57.
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: feat/vpn-remote-access-wireguard
|
||||
title: Remote-access VPN — WireGuard on udp/51820, hostNetwork because a pod identity would 403 every Gateway hostname
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
New platform component `platform/vpn-ingress/`: a **WireGuard** remote-access VPN so the operator's
|
||||
Android / Linux / Windows / macOS devices can dial in from anywhere on the internet and behave like
|
||||
machines on the LAN — full reach to `192.168.2.0/24` plus internet egress out the house WAN.
|
||||
|
||||
This is the **opposite direction** to the existing `platform/vpn-egress` (pods *out* through PIA,
|
||||
ADR-0015). They share nothing but the word "VPN"; both READMEs now say so, and `README.md`'s layout
|
||||
block labels the two.
|
||||
|
||||
**Transport: WireGuard, and the security argument is the handshake's failure mode, not "it is
|
||||
encrypted".** WireGuard's first handshake message carries a MAC keyed with the responder's own
|
||||
public key, so a sender that does not already know it gets **no reply at all** — no error, no reset,
|
||||
no banner. From the internet, udp/51820 is indistinguishable from a closed port: nothing to
|
||||
brute-force, no version string, no amplification. That is what makes an unfiltered WAN port
|
||||
defensible here, and it is why there is deliberately no `loadBalancerSourceRanges`-style allowlist
|
||||
(which would also break roaming clients). It also has first-party clients on all four named
|
||||
platforms, which OpenVPN (needs a CA + per-device certs + a CRL) and IKEv2 do not.
|
||||
|
||||
**`hostNetwork: true`, and this is THE decision — forced by a measured property of this cluster.**
|
||||
A pod-networked WireGuard server must SNAT client traffic to its own pod IP (Cilium drops a packet
|
||||
whose source is not the endpoint's address), so every remote client would have arrived wearing *that
|
||||
pod's* security identity. `decisions/2026-10-04-plex-advertise-plexdirect.md` measured what that
|
||||
costs: a request to the shared Gateway VIP **from inside a pod returns `403 Access denied` from
|
||||
Envoy for all 16 Gateway hostnames**, because Cilium's Gateway preserves the original source
|
||||
identity and enforces the *destination's* ingress policy, and every app admits `reserved:ingress`
|
||||
plus named peers — never an arbitrary pod. So the obvious build would have produced a tunnel that
|
||||
came up healthy, showed connected peers, routed packets, and 403'd every cluster web UI — and
|
||||
`*.home.keithjackson.dev` would not even have resolved, because `apps/wave-0/pihole-dns.yaml`'s
|
||||
ingress policy deliberately omits the `cluster` entity. In the host netns there is no pod identity
|
||||
to carry: forwarded traffic is SNATed to the node address and arrives as `host`/`remote-node`,
|
||||
byte-for-byte what a LAN client looks like. Three further problems vanish with it: `ip_forward` is
|
||||
already 1 on these nodes (so no `--allowed-unsafe-sysctls` kubelet change — it is *not* a safe
|
||||
sysctl); no LoadBalancer VIP, so no L2-announcement lease lottery and no `externalTrafficPolicy`
|
||||
trade-off; and no double encapsulation, which is what makes `MTU = 1420` simply correct.
|
||||
|
||||
`hostNetwork` is permitted by `restrict-host-namespaces`' own stated carve-out (it matches
|
||||
`app-namespace=true` only, and names Cilium/node-exporter as the precedent); `namespace.yaml` is
|
||||
deliberately unlabelled. **The cost is stated, not buried:** a hostNetwork pod has no Cilium
|
||||
endpoint, so **no NetworkPolicy constrains this workload**. `networkpolicy.yaml` says that at
|
||||
length rather than shipping an `allow world → 51820` rule that would read as the control and
|
||||
enforce nothing; the real boundary is WireGuard's peer authentication.
|
||||
|
||||
**A DaemonSet, not a Deployment.** The WAN forward names one node address by hand, off-repo (same
|
||||
shape as the plex tcp/32400 forward). A floating single replica would silently invalidate it on
|
||||
every reschedule; an identical listener on all three nodes makes **failover a one-line router
|
||||
change** with no cluster work and no re-issued client profiles. A pinned LoadBalancer VIP was the
|
||||
considered alternative and was rejected: it reintroduces the L2 lease, SNAT and an extra node hop
|
||||
into the one path whose failure mode is "I am away from home and cannot get in".
|
||||
|
||||
**Authorisation is split so that it stays reviewable.** Peer **public** keys live in
|
||||
`platform/vpn-ingress/peers.yaml` in **plaintext** — a public key grants nothing by itself, and this
|
||||
makes "who can get into my house" a PR diff with `git log` as its audit trail. The only real secret,
|
||||
the server's private key, is the `wg-server-key` Secret, **created out of band by the operator**: an
|
||||
agent must not mint a long-lived private key (CLAUDE.md §6), and a key that passed through a session
|
||||
transcript would have to be treated as compromised. The two halves are joined only at pod start, by
|
||||
an init container, into a `medium: Memory` emptyDir — so the assembled config holding the key never
|
||||
touches a node's disk and dies with the pod.
|
||||
|
||||
**There are no peers yet, and that is the correct initial state**, for the same reason. The VPN ships
|
||||
INERT-BUT-HEALTHY: a zero-peer interface listens and refuses every handshake.
|
||||
|
||||
Files:
|
||||
|
||||
- `platform/vpn-ingress/` (new): `namespace.yaml`, `peers.yaml` (the plaintext peer register),
|
||||
`daemonset.yaml` (hostNetwork listener + config assembly), `networkpolicy.yaml` (namespace
|
||||
baseline, with the honest caveat), `kustomization.yaml`, `README.md` (the design record).
|
||||
- `clusters/base/vpn-ingress.yaml` (new): its own **non-gating post-platform** Flux Kustomization
|
||||
(`dependsOn platform-config`, `wait:false`). Non-gating is load-bearing rather than conventional
|
||||
here: the DaemonSet *cannot* start until the operator creates the server key, so in the gating
|
||||
`platform` tier an unfinished manual step would have stalled every apps wave on merge.
|
||||
- `clusters/base/kustomization.yaml`: the new tier added.
|
||||
- `clusters/base/cluster-vars.yaml`: new `WG_CLIENT_CIDR` (`10.90.0.0/24`), declared next to
|
||||
`LAN_CIDR` because it is the one range that must be collision-checked against every other; the
|
||||
comment enumerates the five it avoids. **No prod override, deliberately** — unlike every VIP, this
|
||||
range is private to the tunnel, so the same value is correct in both environments and a prod patch
|
||||
would imply otherwise. Substitution inventory updated.
|
||||
- `infra/ansible/roles/node_baseline/defaults/main.yml`: `wireguard` added to
|
||||
`node_baseline_kernel_modules`. The pod has `NET_ADMIN` but deliberately **not** `SYS_MODULE`, so
|
||||
it cannot autoload the module — it CrashLoops with a clear error rather than quietly degrading to
|
||||
userspace wireguard-go (which would need `/dev/net/tun`, a device a hostPath mount cannot actually
|
||||
grant, as the `/dev/dri` attempt proved).
|
||||
- `infra/vpn-ingress/new-peer.sh` (new): enrols a device. Generates the keypair on the operator's
|
||||
machine, writes a 0600 profile **outside** the checkout (it refuses to write into the repo),
|
||||
prints a QR code for phones, and prints only the public `[Peer]` stanza. It also refuses a
|
||||
duplicate address, because two devices on one address does not error anywhere — the server routes
|
||||
to whichever handshook last, so both appear to work intermittently.
|
||||
- `runbooks/vpn-remote-access.md` (new) + its `kustomization.yaml` generator entry + `README.md`
|
||||
index line: the three manual steps, the four off-LAN verification checks, revocation, and why no
|
||||
connection alert exists.
|
||||
- `README.md`: `vpn-ingress` added to the layout, with the egress/ingress distinction spelled out.
|
||||
|
||||
**THREE MANUAL STEPS REMAIN AND THE VPN DOES NOT WORK UNTIL ALL THREE ARE DONE** (all off-repo by
|
||||
necessity; `runbooks/vpn-remote-access.md`): run `node_baseline` to load the kernel module, create
|
||||
the `wg-server-key` Secret, and forward WAN udp/51820 to one node address on the SOHO router.
|
||||
|
||||
**SENSITIVE PATHS — this PR touches `clusters/**` (three files), so `ci/approval-policy.sh` will
|
||||
BLOCK it.** It needs the operator's `/approve-override <reason>` posted as a PR **review** of type
|
||||
"Comment". I have deliberately not posted it: CLAUDE.md §1 forbids an agent clearing this on its own
|
||||
PR, and the agent account holds `owner`, so the prohibition is a rule, not a limit.
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, read-only, 2026-10-05, plus the offline gate.
|
||||
|
||||
- **Toolbox gate** (`./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`): **rc 3**, with
|
||||
`spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1` and `approval-policy` all
|
||||
**pass**. The rc 3 is two environmental skips — `gitea-settings` (no local `SETTINGS_API_TOKEN`)
|
||||
and `conformance` L2/L3 (`CLUSTER_AVAILABLE` unset). **Not green. Reported as rc 3, not as pass.**
|
||||
Run from an isolated git worktree with both CLAUDE.md §3 workarounds applied
|
||||
(`DEV_KUBECONFIG=/dev/null` plus an `ENGINE` shim bind-mounting the main checkout's `.git` and
|
||||
the sibling spec repo) — which is why `spec-immutability` is **green here and skipped in CI**,
|
||||
where the runner has no spec checkout. `approval-policy` passing locally is an offline N/A, not a
|
||||
verdict; the real verdict is the CI run below.
|
||||
- **The CI gate ran on PR #55 (run 244, head `1a67fc1b`) and `approval-policy` is the ONLY failing
|
||||
stage** — read from the job log, not from the red tick. Verbatim: "This PR changes the pipeline or
|
||||
the security posture it rests on, and has NO approving review from anyone other than its author
|
||||
('gitea-admin')", naming exactly the three files I predicted:
|
||||
`clusters/base/cluster-vars.yaml`, `clusters/base/kustomization.yaml`,
|
||||
`clusters/base/vpn-ingress.yaml`. Every other stage that ran reported green —
|
||||
`gitea-settings`, `decisions-log`, `lint`, `l0`, `render-golden`, `l1`. So the branch is blocked
|
||||
on the operator's `/approve-override <reason>` (posted as a PR **review** of type "Comment") and
|
||||
on nothing else. **I have not posted it** — CLAUDE.md §1.
|
||||
- **Branch protection is live, measured by CI's own `gitea-settings` stage** (it has the token this
|
||||
workstation lacks): `required_approvals = 0`, `enable_status_check = true`,
|
||||
`status_check_contexts = ["gate / gate (pull_request)"]`, `block_admin_merge_override = true`,
|
||||
`enable_push = false`, `dismiss_stale_approvals = true`, and **1 runner online**. That is the §2b
|
||||
re-measurement of CLAUDE.md §2's table for this branch, not a restatement of it.
|
||||
- **`flux-postbuild-substitution` (L0) actually exercised the new wiring, and caught a real defect
|
||||
in it.** On the first run it FAILED, naming `platform/vpn-ingress/daemonset.yaml:118` and
|
||||
attributing it to "Flux Kustomization 'vpn-ingress' (clusters/base/vpn-ingress.yaml, path
|
||||
./platform/vpn-ingress)". Two things are proven by that message: the new tier's
|
||||
`postBuild.substituteFrom` declaration is correctly detected (so `${WG_CLIENT_CIDR}` will be
|
||||
substituted, not shipped literally), and the check reads comments as well as values — the hazard
|
||||
was a comment that spelled out the forbidden braced form literally while claiming not to use it.
|
||||
Fixed; the check now passes 444/444 with 0 hazards.
|
||||
- **The `wireguard` kernel module is available on these nodes by construction**: all three run
|
||||
Debian 13 (trixie) on kernel `7.1.8+deb13-amd64` (measured), and WireGuard has been in-tree since
|
||||
5.6, so it is present in the distro kernel and only needs loading.
|
||||
- **`net.ipv4.ip_forward = 1` is already written by `node_baseline`**
|
||||
(`roles/node_baseline/tasks/main.yml`, `/etc/sysctl.d/99-k3s-cilium.conf`), and **no kubelet
|
||||
carries `--allowed-unsafe-sysctls`** (grepped across `roles/k3s_server`, `roles/node_baseline` and
|
||||
`group_vars`: zero hits). That pair is what makes the host netns the only option that does not
|
||||
require a node-restart change.
|
||||
- **Cilium's datapath, measured from `cilium-config` rather than from the values file**:
|
||||
`enable-ipv4-masquerade=true`, `enable-bpf-masquerade=true`, `routing-mode=tunnel`,
|
||||
`tunnel-protocol=vxlan`, `ipv4-native-routing-cidr` EMPTY, Cilium `v1.20.1`. The vxlan underlay is
|
||||
where `MTU = 1420` comes from, and the empty native-routing CIDR is why pod→LAN is masqueraded
|
||||
today.
|
||||
- **`WG_CLIENT_CIDR` collides with nothing.** Pod CIDR `10.0.0.0/16` (`CLUSTER_POD_CIDR`), service
|
||||
CIDR `10.43.0.0/16` (measured: `kubernetes` Service clusterIP is `10.43.0.1`), the PIA tunnel's
|
||||
`10.72.192.0/18` (`platform/vpn-egress/README.md`), and both LANs `192.168.1.0/24` /
|
||||
`192.168.2.0/24`. `10.90.0.0/24` is outside all five.
|
||||
- **The egress-class label matters and is set to `internet`.** Read live:
|
||||
`CiliumEgressGatewayPolicy vpn-egress-steering` selects `podSelector:
|
||||
homelab.keithjackson.dev/egress-class: vpn` with **no namespace scope**, so `vpn` here would have
|
||||
steered every remote client's internet traffic out PIA.
|
||||
- **All three nodes are untainted** (`spec.taints: None` on each), so the DaemonSet covers the
|
||||
cluster with no tolerations.
|
||||
- **`new-peer.sh` was run end to end** against a throwaway server key in the scratchpad (deleted
|
||||
immediately after; it never existed in the repo). It rendered a correct profile — `Address
|
||||
10.90.0.7/32`, `DNS 192.168.2.23`, `Endpoint wan.home.keithjackson.dev:51820`, `MTU 1420`,
|
||||
`PersistentKeepalive 25`, `AllowedIPs 0.0.0.0/0` — at mode 0600, printed the matching public-key
|
||||
stanza, and both argument-validation paths (missing name, out-of-range octet) refuse correctly.
|
||||
The private key was redacted from every line I displayed.
|
||||
- **The endpoint name is resolver-independent, which is the specific trap this avoids.**
|
||||
`wan.${BASE_DOMAIN}` is an unproxied public Cloudflare A record kept current by
|
||||
`platform/externaldns/ddns.yaml` (checked every 5 min), so it resolves from a cellular connection
|
||||
— unlike any name PiHole answers, which a remote client cannot resolve until it is already
|
||||
connected. (This is the `plex.direct` lesson applied.)
|
||||
- **Velero does cover this namespace.** `platform/velero/helmrelease.yaml`'s `daily` schedule
|
||||
excludes only `monitoring` and `unifi`, so `vpn-ingress` and the `wg-server-key` Secret are
|
||||
captured off-cluster to R2. Read from the manifest, not from a backup listing.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **THE ACTUAL FIX. Nothing in this branch has been proven to carry a single packet.** The
|
||||
DaemonSet has never been reconciled, no `wg0` has ever existed, and no handshake has ever been
|
||||
attempted. Three blockers are off-repo and manual (kernel module, server key, WAN forward), and
|
||||
this branch removes none of them — it only makes them the *only* remaining work.
|
||||
- **Outside-in reachability, at all.** This repo has **no off-LAN vantage point**, which is exactly
|
||||
the gap the plex remote-access work hit. Step 6 of the runbook (a phone on cellular) is the only
|
||||
honest test and it has not been run.
|
||||
- **The in-pod `iptables` → host-netfilter interaction, which is my biggest single doubt.** Debian 13
|
||||
uses nf_tables; the container writes MASQUERADE/FORWARD rules into the **host's** tables via the
|
||||
image's own `iptables`. If that binary is the legacy backend while the host is nft, the rules can
|
||||
apply to a table the kernel does not consult — and the symptom would be the nastiest available:
|
||||
handshakes succeed, `wg show` looks perfect, and every forwarded connection hangs. **Check this
|
||||
first if the tunnel comes up but nothing is reachable:** `iptables-save -t nat | grep MASQUERADE`
|
||||
and `nft list table ip nat` on the node, from the host, not from the pod.
|
||||
- **Whether `allowPrivilegeEscalation: false` is compatible with the image's s6 init.** It is the
|
||||
correct posture and I set it, but this repo has already been bitten by `no-new-privileges`
|
||||
misbehaving (`platform/externaldns/ddns.yaml`'s README note). If the pod fails before
|
||||
`wg-quick` runs, that flag is the first thing to try flipping.
|
||||
- **Whether `NET_ADMIN` alone is sufficient.** Reasoned from what `wg-quick` does (create the link,
|
||||
set a sysctl, write two netfilter rules), not measured. If `iptables` complains, `NET_RAW` is the
|
||||
likely addition — deliberately not granted pre-emptively.
|
||||
- **The image's custom-config path.** `lscr.io/linuxserver/wireguard:1.0.20260223-r0-ls124` (tag
|
||||
confirmed to exist and to be the current one, published 2026-10-01) is expected to bring up
|
||||
whatever is in `/config/wg_confs/` when `PEERS` is unset. That is its documented behaviour; it has
|
||||
not been run here, and the init container's assembly of that file has never executed.
|
||||
- **Whether the probe is right.** `wg show wg0` is asserted to exit non-zero when the interface is
|
||||
absent. Not executed.
|
||||
- **Cilium's reaction to a new `wg0` in the host netns.** `platform/cilium/values.yaml` does not pin
|
||||
`devices`, so Cilium auto-detects; whether it attaches to `wg0` — and whether that matters — is
|
||||
unknown. `wg` interfaces are already filtered out by `node_health_collect.py`, so something in
|
||||
the estate anticipates them, but that is not evidence about Cilium.
|
||||
- **Zero peers means the end-to-end authorisation path is untested**, including revocation. The
|
||||
first enrolment is also the first test of `peers.yaml`'s format being what `wg-quick` accepts.
|
||||
- **No peer-level observability exists.** An exporter publishing
|
||||
`wireguard_latest_handshake_seconds` is the top follow-up; until then "who is connected" is an
|
||||
on-demand `wg show` and nothing alerts on an unexpected session. Pod-down is covered only by
|
||||
kube-prometheus-stack's generic `KubePodNotReady` / `KubeDeploymentReplicasMismatch`, which I did
|
||||
not confirm fire for a DaemonSet in this namespace.
|
||||
- **The security consequence I am most keen for a human to review rather than take from me:** an
|
||||
enrolled device reaches the three k3s nodes themselves — apiserver 6443, kubelet 10250, SSH 22 —
|
||||
because "reach the entire local network" includes them. That is stated in the README and the
|
||||
runbook with the narrowing recipe, but it is a posture decision, not a verified fact, and it is
|
||||
the right thing to argue about on this PR.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: fix/vpn-runbook-ansible-command
|
||||
title: vpn-remote-access runbook — step 1 named a script that does not exist, and step 2 cited the wrong reason the key is gitignored
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Two defects in `runbooks/vpn-remote-access.md` (merged in #55), both authored by me, both reported
|
||||
by the operator the first time they tried to follow it.
|
||||
|
||||
**1. Step 1 named a command that does not exist.** It said:
|
||||
|
||||
cd infra/ansible && ./site.sh --tags system --limit k3s_prod
|
||||
|
||||
There is **no `site.sh` anywhere in this repo** (`find . -name site.sh` → nothing). The invocation
|
||||
was wrong in four separate ways at once, which is what makes it worth recording rather than just
|
||||
patching: wrong kind of thing (this repo drives Ansible with `ansible-playbook <playbook>`, never a
|
||||
wrapper script — `playbooks/site.yml` exists and is the full bring-up chain, not this); wrong
|
||||
playbook (the module work lives in `playbooks/node-baseline.yml`); wrong targeting flag (`--limit`
|
||||
instead of this repo's `-e k3s_hosts=...` convention, which `infra/ansible/README.md` "Targeting
|
||||
prod" documents for every playbook); and no toolbox wrapper, though Ansible exists only inside the
|
||||
pinned container and `ansible.cfg` is only read from `infra/ansible`.
|
||||
|
||||
Replaced with the real command, plus the three things about it that are load-bearing:
|
||||
|
||||
./infra/devcontainer/dev.sh run bash -c \
|
||||
'cd /work/infra/ansible && ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags system'
|
||||
|
||||
- `-e k3s_hosts=k3s_prod` — the playbook defaults to `k3s_servers`, which is the **dev** group;
|
||||
dev is decommissioned, so an unqualified run does nothing useful.
|
||||
- `--tags system`, never untagged — the two kernel-module tasks carry that tag, and an untagged run
|
||||
would also hit the `disks` tasks, which assert on block-device variables prod deliberately leaves
|
||||
as placeholders (the playbook's own header warns about this). `--tags system` is broader than the
|
||||
modules alone (sysctls, iscsid, qemu-guest-agent, swap masking) but each is idempotent and
|
||||
already in its desired state on a live node.
|
||||
|
||||
**2. Step 2 gave the wrong reason the server key is safe from git — and the right reason is
|
||||
fragile.** It told the operator to stage the key at `/work/.cache/server.key` "inside the repo's
|
||||
gitignored cache". `.cache/` is **not** gitignored: `git check-ignore -v .cache/serverkey.txt`
|
||||
matches nothing. The file was ignored only incidentally, by `.gitignore` line 11 `*.key` — so the
|
||||
path happened to be safe for a reason I had not identified, and **any operator who renamed it
|
||||
(`server.pem`, `wg-server`, `privatekey`) would have had a committable WireGuard private key sitting
|
||||
in the checkout.** The gate would not have caught it either; `no-plaintext-secrets` does not
|
||||
recognise a bare WireGuard key.
|
||||
|
||||
The step now states the real rule, marks the `.key` suffix as the thing doing the work, and gives
|
||||
the operator a verification they can run before trusting it (`git check-ignore -v` must print a
|
||||
rule, and `git status --short` must not list the file).
|
||||
|
||||
Nothing outside `runbooks/vpn-remote-access.md` changed; no manifest, no behaviour.
|
||||
|
||||
## What I verified
|
||||
|
||||
- **`site.sh` does not exist**: `find . -name 'site.sh' -not -path './.git/*'` → no output.
|
||||
- **The real surface**: `infra/ansible/playbooks/` contains `node-baseline.yml` and `site.yml` (and
|
||||
11 others); `node-baseline.yml`'s own header documents both
|
||||
`-e k3s_hosts=k3s_prod` and the tag-scoping hazard. The `-e <playbook>_hosts=` convention is
|
||||
confirmed by `infra/ansible/README.md` §"Targeting prod" and by the existing
|
||||
`runbooks/node-memory-swap.md`, which invokes `ansible-playbook playbooks/node-health.yml -e
|
||||
node_health_hosts=...`.
|
||||
- **`k3s_prod` is a real inventory group**: `[k3s_prod:children]` in `inventory/hosts.ini`, with
|
||||
`[k3s_prod_primary]`/`[k3s_prod_secondary]`.
|
||||
- **`--tags system` covers the kernel-module tasks**: both "Persist kernel modules…" and "Load
|
||||
kernel modules now" carry `tags: [system]` in `roles/node_baseline/tasks/main.yml`.
|
||||
- **The gitignore claim, measured both directions**:
|
||||
`git check-ignore -v .cache/server.key` → `.gitignore:11:*.key`;
|
||||
`git check-ignore -v .cache/serverkey.txt` → no match. So the suffix is the only thing ignoring it.
|
||||
- **Every path the component's docs reference resolves**: all six relative markdown links in
|
||||
`platform/vpn-ingress/README.md` (checked from that file's own directory), and both Ansible
|
||||
playbook paths.
|
||||
- **Toolbox gate** from an isolated worktree: **rc 3**, with `spec-immutability`, `decisions-log`,
|
||||
`lint`, `l0`, `render-golden`, `l1` and `approval-policy` all **pass**; the rc 3 is the two
|
||||
environmental skips (`gitea-settings` — no local token; conformance L2/L3 — no `CLUSTER_AVAILABLE`).
|
||||
**Not green. Reported as rc 3, not as pass.**
|
||||
- **This branch touches NO sensitive path** (`runbooks/**` and `decisions/**` are not in
|
||||
`infra/gitea/approval-policy.json`), so unlike #55 it needs no `/approve-override`.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **The corrected command has not been RUN.** Ansible is an operator action, and this is the
|
||||
decisive gap: I have verified the playbook, the group, the tag and the flag all exist and are
|
||||
the documented convention, but not that the run succeeds end to end or that
|
||||
`lsmod | grep '^wireguard'` then reports the module. It is a better-evidenced command, not a
|
||||
proven one.
|
||||
- **Whether `--tags system` is entirely side-effect-free on a live prod node.** Each task it runs is
|
||||
idempotent by construction, but I did not dry-run it (`--check`) to confirm it reports zero
|
||||
changes beyond the module load.
|
||||
- **The rest of the runbook's commands past step 2** are unchanged and still unexercised — in
|
||||
particular `kubectl exec ds/wireguard -- wg show`, which cannot be reached while the pods sit in
|
||||
`Init` waiting on the server key.
|
||||
- **Whether any OTHER document I wrote in #55 contains an invented command.** I re-checked every
|
||||
file path and markdown link in the component (all resolve) and every script the docs tell an
|
||||
operator to run, but "the referenced file exists" is weaker than "the command is correct" — which
|
||||
is exactly the gap that produced `site.sh`.
|
||||
|
||||
## Post-merge state of #55, recorded here since its own entry predates the merge
|
||||
|
||||
Measured on live prod after #55 merged as `ad3f56e`:
|
||||
|
||||
- Flux applied it by the real path: Kustomization `vpn-ingress` `lastAppliedRevision:
|
||||
main@sha1:ad3f56e`, equal to the GitRepository's artifact revision, `Ready=True`.
|
||||
- All **18** Flux Kustomizations `Ready=True` — the new tier disturbed nothing, and `wait: false`
|
||||
behaved as intended (`vpn-ingress` is Ready while its own pods are not, so nothing downstream
|
||||
stalled on it).
|
||||
- **`hostNetwork` is confirmed in effect, not merely configured**: the three pods report pod IPs
|
||||
`192.168.2.10/.11/.12` — the node addresses, one per node.
|
||||
- **It fails closed exactly as designed**, with a legible cause: all three pods `Init:0/1`,
|
||||
`MountVolume.SetUp failed for volume "wg-server-key" : secret "wg-server-key" not found`, and no
|
||||
Secret in the namespace. The #55 entry's "the DaemonSet has never been reconciled" is superseded
|
||||
by this paragraph; its remaining unverified claims (the tunnel carrying a packet, the
|
||||
iptables/nftables backend question) still stand.
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
date: 2026-10-05
|
||||
branch: fix/vpn-server-key-in-sops
|
||||
title: VPN server key moves into SOPS-encrypted git — the out-of-band Secret broke R-SEC-01 and the DR story
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
**Operator correction (2026-10-05): "secrets are kept in source through sops".** They are right, and
|
||||
#55 shipped the WireGuard server private key the wrong way: as a Secret the operator was told to
|
||||
`kubectl create` out of band, referenced by name only. This branch moves it into the tree as
|
||||
`platform/vpn-ingress/wg-server-key.sops.yaml`, where every other secret in this repo lives.
|
||||
|
||||
**Why the original choice was wrong, not merely unconventional.** Two concrete costs, both of which
|
||||
#55's own README half-admitted and then shipped anyway:
|
||||
|
||||
1. **R-SEC-01 / spec docs/07 says secrets live in source, SOPS-encrypted, reaching the cluster only
|
||||
through Flux.** An out-of-band Secret is outside the GitOps tree by construction.
|
||||
2. **It broke DR for the one credential that cannot be re-derived.** A cluster rebuild restores
|
||||
everything in git; it does not restore a hand-made Secret. Losing this key means **re-enrolling
|
||||
every device**, because each client profile pins its public half. #55 papered over this with
|
||||
"keep a copy in your password manager", which is a workaround for a self-inflicted gap.
|
||||
|
||||
Note `platform/kyverno/admission/secret-provenance.yaml` did **not** forbid the original shape —
|
||||
it scopes to `app-namespace=true` namespaces and `vpn-ingress` is deliberately a platform namespace,
|
||||
so admission would have accepted the hand-applied Secret. The rule it violated was the written
|
||||
requirement, not an enforced control. Recording that because "the gate let it through" was never
|
||||
evidence it was correct.
|
||||
|
||||
**How the unfillable-key problem is solved.** An agent still must not mint a long-lived private key
|
||||
(CLAUDE.md §6), so the file is committed **already encrypted, with `privatekey: REPLACE-ME`**. That
|
||||
makes the wiring complete and the gate green from the first commit — the resource line and both
|
||||
overlay decryption patches are present — and leaves the operator exactly one act: replace the value.
|
||||
The alternative, a commented-out resource line to uncomment later, is precisely the "file in git with
|
||||
no generator entry: reviewed, merged, apparently done, still broken" shape this repo keeps getting
|
||||
bitten by (`alert-runbooks-resolvable` exists because of it).
|
||||
|
||||
**The init-container guard is now a validation, not an emptiness check.** It was `[ -z "$key" ]`.
|
||||
Because the committed placeholder is non-empty, the likely failure is now a value that is present
|
||||
and *wrong*, so the guard pipes the value through `wg pubkey` — which accepts only a real 32-byte
|
||||
curve25519 private key — and names which of four cases it hit:
|
||||
|
||||
| case | why it matters |
|
||||
|---|---|
|
||||
| `REPLACE-ME` placeholder | the expected fresh-install state |
|
||||
| empty | blanked value |
|
||||
| **starts with `ENC[`** | **the Secret reached the cluster UNDECRYPTED.** A missing overlay decryption patch applies cleanly and leaves every Flux condition green — this is the silent failure the guard exists for |
|
||||
| rejected, none of the above | mangled value, or a *public* key pasted by mistake |
|
||||
|
||||
Files:
|
||||
|
||||
- `platform/vpn-ingress/wg-server-key.sops.yaml` (new) — SOPS-encrypted to the repo's age recipient,
|
||||
`stringData.privatekey` only, placeholder value.
|
||||
- `platform/vpn-ingress/wg-server-key.sops.yaml.example` (new) — plaintext template + operator
|
||||
hand-off, following the `crafty-oauth2-proxy-credentials.sops.yaml.example` convention.
|
||||
- `platform/vpn-ingress/kustomization.yaml` — the resource added; the "DELIBERATELY ABSENT: a
|
||||
Secret" block replaced with the overlay-patch warning.
|
||||
- `platform/vpn-ingress/daemonset.yaml` — the `wg pubkey` guard; volume comment rewritten.
|
||||
- `clusters/dev/kustomization.yaml`, `clusters/prod/kustomization.yaml` — **the `vpn-ingress`
|
||||
`decryption:` patch, in both.** This is the load-bearing half: #55's own kustomization comment
|
||||
predicted that forgetting it applies ciphertext cleanly.
|
||||
- `clusters/base/vpn-ingress.yaml` — its "NO decryption stanza is needed" comment was now false.
|
||||
- `platform/vpn-ingress/README.md`, `runbooks/vpn-remote-access.md` — step 2 rewritten.
|
||||
- `infra/vpn-ingress/new-peer.sh` — stale "step 1" pointer.
|
||||
|
||||
**A second finding: `wg` and `sops` were not in the same place — and the first fix for that was
|
||||
also wrong.** Measured: `wg` existed only on the host (`/usr/bin/wg`), `sops` only in the dev
|
||||
container, `qrencode` on neither. So `sops -d … | wg pubkey` could not run in one shell, and #55's
|
||||
step 2 — which assumed it could — would have failed.
|
||||
|
||||
My first fix was to **split every step by where it runs**, and to tell the operator to
|
||||
`dnf install qrencode` on their workstation. **Operator correction: "sop, wg, and any other tool
|
||||
requirement is supposed to be baked into the devcontainer."** That is CLAUDE.md §5 and the
|
||||
devcontainer README's "Don't install the toolchain on the host", and the split fix broke the
|
||||
invariant rather than honouring it — it also produced a procedure that could never be reproduced in
|
||||
CI, and which no one should have to follow.
|
||||
|
||||
**The real fix:** `wireguard-tools` and `qrencode` are now distro packages in
|
||||
`infra/devcontainer/Containerfile`, alongside ansible/openssh/diffutils. Both are in Fedora 44's
|
||||
repos (`wireguard-tools` 1.0.20260223 — the same upstream version as the container image this
|
||||
component runs — and `qrencode` 4.1.1), unpinned like every other dnf package there, because the
|
||||
pinned thing is the Fedora base and neither is a gate tool whose version changes what green means.
|
||||
|
||||
Three things got *simpler* rather than merely relocated, which is the tell that the operator's rule
|
||||
was the right one:
|
||||
|
||||
1. **Step 2 is now one pipeline in one shell:** `sops set … "\"$(wg genkey)\""` generates the key
|
||||
straight into the already-encrypted document. The plaintext never lands on disk, never reaches a
|
||||
terminal, and there is no staged copy to shred. The previous version needed a copy in
|
||||
`.cache/server.key`, a `git check-ignore` warning about the `*.key` suffix being the only thing
|
||||
protecting it, and a sha256 comparison to prove the right bytes arrived. All of that is gone.
|
||||
2. **`new-peer.sh` now derives the server's public key from `wg-server-key.sops.yaml` itself**
|
||||
(`sops -d --extract … | wg pubkey`), so there is no local key copy to keep in step with the
|
||||
encrypted one. It previously required the operator to keep the private key at a magic host path.
|
||||
3. **`.cache/` is now gitignored at the repo root** (mirroring `infra/ansible/.gitignore`), so the
|
||||
container has a persistent, non-committable scratch directory. `new-peer.sh` writes client
|
||||
profiles to `.cache/wg-peers/` by default — necessary because the repo bind-mount is the
|
||||
container's ONLY persistent read-write mount, so a profile written to `/tmp` in there is gone
|
||||
before it can be moved to a desktop. Its old blanket "refuse to write inside the checkout" guard
|
||||
is replaced by `git check-ignore`, which tests the actual property instead of a proxy.
|
||||
|
||||
**SENSITIVE PATHS — this PR touches `clusters/**` (two overlays) AND adds a `**/*.sops.yaml`, so
|
||||
`ci/approval-policy.sh` will BLOCK it.** It needs the operator's `/approve-override <reason>` as a
|
||||
PR **review** of type "Comment". I have deliberately not posted it (CLAUDE.md §1).
|
||||
|
||||
## What I verified
|
||||
|
||||
- **The encrypted file is real and correctly targeted.** `sops -e -i` in the pinned toolbox
|
||||
produced `stringData.privatekey: ENC[AES256_GCM,...]` with cleartext keys, `encrypted_regex
|
||||
^(data|stringData)$`, and exactly one age recipient:
|
||||
`age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg` — the recipient in the root
|
||||
`.sops.yaml`, i.e. the key Flux holds as `sops-age`.
|
||||
- **Round-trip, without exposing anything**: `sops -d` then a Python read of key names only →
|
||||
`stringData keys: ['privatekey']`, and `value is still the placeholder: True`.
|
||||
- **Both overlay patches are present and name the right Kustomization** (`name: vpn-ingress`,
|
||||
`namespace: flux-system`, `decryption.secretRef.name: sops-age`), inserted after each overlay's
|
||||
`velero` stanza, mirroring headlamp/crafty/velero.
|
||||
- **`kubectl kustomize platform/vpn-ingress` builds** and now emits five objects — Namespace,
|
||||
ConfigMap, **Secret**, DaemonSet, NetworkPolicy — with `${WG_CLIENT_CIDR}` still an unexpanded
|
||||
token for Flux.
|
||||
- **The tool gap, measured on both host and container before the fix**: `wg` host-only, `sops`
|
||||
container-only, `qrencode` on neither.
|
||||
- **Both packages exist in the image's repos**, queried from inside the running container:
|
||||
`wireguard-tools` 1.0.20260223 (repo `updates`) and `qrencode` 4.1.1 (repo `fedora`).
|
||||
- **The image REBUILDS with them and both tools resolve inside it** —
|
||||
`./infra/devcontainer/dev.sh build` succeeded, and `wg --version` / `qrencode --version` /
|
||||
`sops --version` all answer in the rebuilt container. This is the claim the change rests on, so
|
||||
it was executed rather than reasoned.
|
||||
- **`new-peer.sh`'s new SOPS path fails correctly against the placeholder.** Run in the rebuilt
|
||||
container it refused with "could not derive the server public key … it still holds the REPLACE-ME
|
||||
placeholder", which is both the expected pre-key behaviour and a live test of the
|
||||
`sops -d | wg pubkey` pipeline the whole design now depends on.
|
||||
- **`.cache/` is ignored regardless of filename** now: `git check-ignore -v .cache/anything.conf`
|
||||
and `.cache/serverkey.txt` both match `.gitignore:46:.cache/`. Before this change only `*.key`
|
||||
matched, which is the fragility the previous runbook had to warn about.
|
||||
- **Toolbox gate** from an isolated worktree: **rc 3**, with `spec-immutability`, `decisions-log`,
|
||||
`lint`, `l0`, `render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two
|
||||
environmental skips (`gitea-settings` — no local token; conformance L2/L3 — no cluster).
|
||||
**Not green. Reported as rc 3, not as pass.**
|
||||
- **The L0 gate caught THREE real defects in this branch before it could merge — including one
|
||||
in the fix for another — and I had asserted one of them was fine without running it.** Both are recorded because the gate's value here is the
|
||||
whole point of the exercise:
|
||||
1. **`no-plaintext-secrets` FAILED**: "`wg-server-key.sops.yaml.example`: an unencrypted .example
|
||||
template is referenced by a kustomization — it would be applied as a plaintext Secret". My
|
||||
kustomization named the template in a *comment*, and the check matches comments too (correctly:
|
||||
naming one is how a plaintext Secret gets applied by accident). `platform/crafty/kustomization.yaml`
|
||||
never names its own example either — the convention was there to copy and I missed it. Fixed by
|
||||
not naming the file; the hand-off lives inside the example, where it belongs.
|
||||
**An earlier draft of this entry claimed this check passed. It did not. The claim was written
|
||||
from expectation rather than from a run.**
|
||||
2. **`flux-postbuild-substitution` FAILED** on `${key#ENC[}` in my new guard
|
||||
(`daemonset.yaml:155`), flagged `[SILENT-EMPTY]`: envsubst consumes every `${...}`, so my
|
||||
shell prefix-strip would have rendered to the empty string — the ciphertext branch of the guard
|
||||
would never have fired, which is precisely the silent failure the guard was added to catch.
|
||||
Rewritten with `case`, which uses no braces and so has nothing to escape.
|
||||
3. **And then it failed a THIRD time, on the fix's own comment** — `[FATAL-PARSE]
|
||||
daemonset.yaml:156 ${...}`, the prose explaining the rule, written with the literal form. That
|
||||
class is worse than it looks: a `FATAL-PARSE` hazard fails the **entire** Kustomization, every
|
||||
resource in the path, not just the line. Rewritten as "dollar-brace expansion". This is the
|
||||
second time in this component that writing the hazard literally inside a comment about the
|
||||
hazard has been the defect (the first was in #55), so the habit to build is: **in any
|
||||
substituted path, describe the construct in words, never type it.**
|
||||
- **After both fixes, `no-plaintext-secrets` and `flux-postbuild-substitution` pass** (446 and 198
|
||||
assertions respectively), and `l0` is green.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That Flux decrypts this file on prod.** The overlay patches have never reconciled. This is the
|
||||
whole risk of the change and it has exactly one observable outcome: after merge the pods should
|
||||
CrashLoop reporting the **`REPLACE-ME` placeholder** (proving decryption worked), *not* reporting
|
||||
**`ENC[`** (which would prove a patch is wrong). I built the guard to tell those apart because I
|
||||
cannot test them.
|
||||
- **That `wg pubkey` accepts the operator's real key through this path.** The guard is reasoned from
|
||||
`wg pubkey`'s behaviour and has never run — `wg` is absent from both this workstation's container
|
||||
and the gate, so the first execution will be in the pod.
|
||||
- **The `sops set` command in the hand-off.** I verified `sops -e -i` and `sops -d` in the toolbox;
|
||||
I did **not** run `sops set` with the quoting shown, and nested quoting inside
|
||||
`dev.sh run bash -c` is exactly where a documented command tends to break. If it misbehaves, the
|
||||
editor form (`sops <file>`) is the fallback and does not depend on quoting.
|
||||
- **The sha256 comparison in step 2 step 4** is untested end to end (it needs the real key).
|
||||
- **Whether anything else in #55 assumed a tool is available where it is not.** I checked `wg`,
|
||||
`sops` and `qrencode` because step 2 depended on them. I did not audit every command in the
|
||||
runbook the same way — and that class of defect has now produced two corrections in a row
|
||||
(`site.sh`, then `sops | wg`), so assume more remain until each step has actually been run.
|
||||
- **The live VPN still carries no traffic**, and the earlier gaps stand unchanged: the
|
||||
iptables-vs-nftables question, `allowPrivilegeEscalation: false` versus the image's s6 init, and
|
||||
any off-LAN reachability.
|
||||
@@ -0,0 +1,73 @@
|
||||
---
|
||||
date: 2026-10-06
|
||||
branch: fix/new-peer-ignores-template-comments
|
||||
title: new-peer.sh refused the first VPN address because its duplicate check matched its own commented-out example
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
The operator's first real enrolment failed:
|
||||
|
||||
```
|
||||
$ ./infra/vpn-ingress/new-peer.sh keith-phone 2
|
||||
new-peer.sh: 10.90.0.2/32 is already allocated in platform/vpn-ingress/peers.yaml. Pick another octet.
|
||||
```
|
||||
|
||||
**Nothing was allocated.** `peers.yaml` had zero peers — the pod's own init container reported
|
||||
`wg0.conf assembled: 0 peer(s)`. The duplicate-address check grepped the WHOLE file, and
|
||||
`peers.yaml` line 66 is a **commented-out example stanza** I wrote, containing literally
|
||||
`# AllowedIPs = 10.90.0.2/32`. So `.2` — the first address anybody would ask for, and the one my
|
||||
own runbook uses in its example — was permanently un-allocatable, with a message asserting the
|
||||
opposite of the truth.
|
||||
|
||||
Both halves fixed, because either alone leaves the trap half-set:
|
||||
|
||||
- **`infra/vpn-ingress/new-peer.sh`**: the check strips comment lines before matching
|
||||
(`grep -vE '^[[:space:]]*#' | grep -q …`). That is the correct predicate rather than a patch: a
|
||||
real stanza's `AllowedIPs` line is never commented, while both the template and every peer's
|
||||
`# <name> — <owner>` label always are.
|
||||
- **`platform/vpn-ingress/peers.yaml`**: the example now uses placeholders that cannot match
|
||||
anything real — `# <device-name> — <owner>, enrolled <YYYY-MM-DD>` and
|
||||
`AllowedIPs = 10.90.0.<octet>/32`. A future grep cannot be fooled by it either.
|
||||
|
||||
The bug class is worth naming: **a documentation example that is byte-identical to real data will
|
||||
eventually be parsed as real data.** The same shape caused two earlier defects in this component —
|
||||
`flux-postbuild-substitution` flagged a `${...}` written inside a comment *explaining* that hazard,
|
||||
and `no-plaintext-secrets` flagged the `.sops.yaml.example` because the kustomization named it in a
|
||||
comment. Three instances now; the rule is to make examples structurally unmatchable, not merely
|
||||
commented out.
|
||||
|
||||
No sensitive paths (`platform/vpn-ingress/**`, `infra/vpn-ingress/**`, `decisions/**`), so this
|
||||
merges on a green gate with no override.
|
||||
|
||||
## What I verified
|
||||
|
||||
Run in the pinned toolbox against the live tree, 2026-10-06.
|
||||
|
||||
- **`.2` now works**, and it went through the REAL key path: `new-peer.sh keith-phone 2` rendered a
|
||||
profile with `Address 10.90.0.2/32`, `AllowedIPs = 10.90.0.2/32` in the printed stanza, full
|
||||
tunnel, at `.cache/wg-peers/keith-phone.conf` (0600). The throwaway keypair was deleted after.
|
||||
- **This incidentally closes a gap left open in #60**: the profile could only have been rendered by
|
||||
deriving the server's public key via `sops -d --extract … | wg pubkey` from
|
||||
`wg-server-key.sops.yaml` — so that pipeline is now proven **against the operator's real key**,
|
||||
not just against a placeholder it was expected to reject.
|
||||
- **A real duplicate is still refused.** Appending an uncommented
|
||||
`AllowedIPs = 10.90.0.5/32` stanza and asking for octet 5 produced the correct refusal; the tree
|
||||
was restored afterwards and `git diff --stat` showed only the intended template edit.
|
||||
- **Zero active allocations remain**: `grep -vE '^[[:space:]]*#' peers.yaml | grep -c AllowedIPs`
|
||||
→ `0`.
|
||||
- **Toolbox gate**: rc 3, with `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`,
|
||||
`l1` and `approval-policy` all pass; rc 3 is the two environmental skips (`gitea-settings`,
|
||||
conformance L2/L3). **Not green. Reported as rc 3.**
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **No peer is enrolled by this branch.** The keypair I generated was a throwaway and was deleted;
|
||||
the operator must run the script themselves, since the device's private key must exist only on
|
||||
the device. So the end of the enrolment path — a stanza merged into `peers.yaml`, Flux rolling
|
||||
the DaemonSet, and `wg show` listing a peer — is still unexercised.
|
||||
- **The duplicate-NAME warning** (`grep -qi "^ *# *${NAME} "`) deliberately still inspects
|
||||
comments, because per-peer labels are comments. It is no longer tripped by the template, but I
|
||||
did not test it against a real enrolled peer.
|
||||
- **Nothing about client traffic.** The WAN port-forward is still not configured and there is still
|
||||
no off-LAN vantage point, so the tunnel has carried no packet.
|
||||
@@ -0,0 +1,103 @@
|
||||
---
|
||||
date: 2026-10-06
|
||||
branch: fix/peers-configmap-content-hash
|
||||
title: A merged VPN peer never reached the tunnel — the peer register was a fixed-name ConfigMap, so nothing rolled the pods
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
The operator enrolled a phone, merged the peer, and **`wg show` listed no peers.** Measured on prod
|
||||
2026-10-06, with the peer already on `main`:
|
||||
|
||||
| observation | value |
|
||||
|---|---|
|
||||
| peer stanza on `main` | present (`AllowedIPs = 10.90.0.2/32`) |
|
||||
| GitRepository artifact | `main@sha1:e1bebb8` — Flux had fetched it |
|
||||
| `vpn-ingress` Kustomization `lastAppliedRevision` | `main@sha1:5928d81` — **behind**, 10m interval |
|
||||
| live `wg-peers` ConfigMap, active `AllowedIPs` lines | **0** |
|
||||
| DaemonSet volume reference | `configMap.name: wg-peers` — a **fixed** name |
|
||||
| DaemonSet `generation` / `observedGeneration` | **1 / 1** — it had **never rolled** |
|
||||
| init container, all three pods | `wg0.conf assembled: 0 peer(s)` |
|
||||
|
||||
The lag was only the reconcile interval. **The defect is the fixed name.** When Flux does update a
|
||||
fixed-name ConfigMap it updates it *in place*: the DaemonSet's pod template is byte-identical, so
|
||||
no rollout is triggered — and because the init container assembles `wg0.conf` **once at pod start**
|
||||
into a tmpfs, the running tunnel keeps the old peer list indefinitely. Adding or revoking a peer
|
||||
would have had **no effect at all** until something unrelated happened to restart the pods. For a
|
||||
*revocation* that is a security defect, not an inconvenience: the operator would delete a stanza,
|
||||
see a green PR, and the device would keep working.
|
||||
|
||||
My runbook and README both asserted "PR → merge → Flux rolls the DaemonSet (~1 min)". That was
|
||||
never true.
|
||||
|
||||
**This repo already documents the fix, and I had read the file.** `runbooks/kustomization.yaml`:
|
||||
*"The generated name carries kustomize's content hash, so editing any runbook rolls the server
|
||||
automatically and a stale projection is impossible."*
|
||||
|
||||
So the register becomes a **content-hashed generator**:
|
||||
|
||||
- `platform/vpn-ingress/peers.yaml` (inline ConfigMap) → **`platform/vpn-ingress/peers.conf`**, a
|
||||
plain file, plus a `configMapGenerator` in `kustomization.yaml`. The operator's enrolled peer was
|
||||
carried across verbatim. Every line is WireGuard config and `#` is a legal comment there, so the
|
||||
documentation now travels *inside the mounted file* instead of in a YAML wrapper that gets
|
||||
stripped.
|
||||
- **`namespace: vpn-ingress` added to the kustomization — REQUIRED, not tidiness.** A generated
|
||||
ConfigMap inherits no namespace; every other object here declares `vpn-ingress`; and kustomize's
|
||||
`nameReference` transformer will not rewrite a reference across a namespace boundary. Without it
|
||||
the build emitted `ConfigMap wg-peers-m9kcckcb5t` while the DaemonSet still mounted the unhashed
|
||||
`wg-peers`, which would not exist — the pods would wedge on a missing volume. I caught this only
|
||||
by building the output and reading the volumes block.
|
||||
- **`infra/vpn-ingress/new-peer.sh`**: `PEERS_FILE` repointed to `peers.conf`, and a missing
|
||||
register is now **fatal** instead of skipped. The old guard was
|
||||
`[ -r "$PEERS_FILE" ] && grep …`, so after the rename the only symptom would have been
|
||||
duplicate-address detection quietly never firing again — the silent-skip shape this repo treats
|
||||
as a defect in its own right.
|
||||
- Stale `peers.yaml` references swept from the README, runbook, `daemonset.yaml`,
|
||||
`networkpolicy.yaml`, the SOPS example, and the script's own **user-facing output** (it told the
|
||||
operator to paste into "peers.yaml, under data.peers.conf", which would no longer exist).
|
||||
- The runbook gained the verification that actually distinguishes this failure — the DaemonSet's
|
||||
`generation` must *increase* after the merge — and a troubleshooting row for `0 peer(s)` after a
|
||||
merge.
|
||||
|
||||
Flux's `prune: true` removes the orphaned fixed-name `wg-peers` ConfigMap on the next reconcile.
|
||||
|
||||
No sensitive paths (`platform/vpn-ingress/**`, `infra/vpn-ingress/**`, `runbooks/**`,
|
||||
`decisions/**`), so this merges on a green gate with no override.
|
||||
|
||||
## What I verified
|
||||
|
||||
Built and run in the pinned toolbox, 2026-10-06 (kustomize v5.8.1 via `kubectl kustomize`).
|
||||
|
||||
- **The reference is now rewritten end to end**: generated `ConfigMap wg-peers-m9kcckcb5t` in
|
||||
namespace `vpn-ingress`, and the DaemonSet mounts **`wg-peers-m9kcckcb5t`**. Before adding
|
||||
`namespace:` the same build mounted the unhashed `wg-peers` — so the namespace line is load
|
||||
bearing, measured both ways.
|
||||
- **The hash tracks content, which is the whole mechanism.** `wg-peers-m9kcckcb5t` →
|
||||
append a second peer → `wg-peers-dgg7ht9c74` → restore → `wg-peers-m9kcckcb5t`. Deterministic
|
||||
and reversible, so an edit necessarily changes the pod template.
|
||||
- **The operator's enrolled peer survived the file conversion byte-for-byte**: the active stanza in
|
||||
`peers.conf` (`PublicKey = pBRSHrRy…`, `AllowedIPs = 10.90.0.2/32`) is identical to the one on
|
||||
`main`, compared directly against `git show origin/main:platform/vpn-ingress/peers.yaml`.
|
||||
- **The build still emits all five objects**: Namespace, ConfigMap, Secret, DaemonSet,
|
||||
NetworkPolicy — and the `Namespace` object's own name is still `vpn-ingress` (the namespace
|
||||
transformer did not rewrite it).
|
||||
- **`new-peer.sh` works against the new register**: `spare-laptop 7` renders a profile and prints
|
||||
"append this to platform/vpn-ingress/peers.conf"; `dupe 2` is **refused** because the operator's
|
||||
real `.2` now occupies it — the first time the duplicate check has been exercised against real
|
||||
data rather than against its own example.
|
||||
- **Toolbox gate**: rc 3, with `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden`,
|
||||
`l1` and `approval-policy` all pass; rc 3 is the two environmental skips. **Not green. Reported
|
||||
as rc 3.**
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the roll actually happens on the cluster.** The whole point of the change is unproven
|
||||
until Flux applies it: the success criterion is the DaemonSet's `generation` increasing and
|
||||
`wg0.conf assembled: 1 peer(s)` in the init log, followed by `wg show` listing the peer.
|
||||
- **That the tunnel then carries traffic.** No handshake has ever been observed. The WAN
|
||||
port-forward is reportedly configured but has never been exercised by a packet I can see, and I
|
||||
have no off-LAN vantage point — runbook step 6 from cellular remains the only honest test.
|
||||
- **Revocation**, which is the case where this defect was a security problem. Deleting a stanza
|
||||
should now roll the pods and drop the peer; untested.
|
||||
- **Whether the orphaned fixed-name `wg-peers` ConfigMap is actually pruned.** Reasoned from
|
||||
`prune: true` on the Kustomization, not observed.
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
date: 2026-10-06
|
||||
branch: docs/pm-agent-registry-pull-prereq
|
||||
title: The nodes resolve gitea to the WAN, so the failing certificate is the router's — the second node process to need the issuer pin
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
The image pull for `pm-agent` failed TLS verification, and the previous entry on this branch
|
||||
honestly recorded it as **not diagnosed**. The operator asked for the diagnosis before any
|
||||
registry design. This is it, and the fix turns out to be a pattern this repository already
|
||||
has.
|
||||
|
||||
`infra/ansible/inventory/group_vars/k3s_prod/vars.yml` now pins
|
||||
`gitea.{{ k3s_base_domain }}` → `192.168.2.20` in `node_baseline_hosts_pins`, beside the
|
||||
Keycloak issuer pin that is there for the identical reason.
|
||||
|
||||
**It reaches the nodes the way every other node setting in this project does — through
|
||||
Ansible, from the inventory.** Node configuration is Ansible's (and, for the dev VMs,
|
||||
OpenTofu's); nothing here touches a node by hand. For prod specifically, Ansible is the
|
||||
*only* surface: `infra/PROD-PROVISIONING.md` records that prod is 3× bare metal and that the
|
||||
dev path (OpenTofu → Proxmox VM → cloud-init) does not apply, so there is no
|
||||
cloud-init/tofu half to keep in parity for this. The one scoped, idempotent command:
|
||||
|
||||
cd infra/ansible && ansible-playbook playbooks/node-baseline.yml \
|
||||
-e k3s_hosts=k3s_prod --tags hosts
|
||||
|
||||
`--tags hosts` runs ONLY the `/etc/hosts` task, so applying this pin cannot drift any other
|
||||
baseline setting. Running the play is an operator action (it has become/root on prod), but
|
||||
the configuration itself is declared, reviewed and version-controlled — not typed on a node.
|
||||
|
||||
The docs that described this as an undiagnosed blocker now describe the cause and the fix:
|
||||
`platform/pm-agent/README.md`, `runbooks/pm-agent.md` (its symptom row now names the pin and
|
||||
the play), and `docs/plans/pm-agent-issue-to-pr.md`.
|
||||
|
||||
Also recorded, in the two places an operator reads when creating the credential: **the agent
|
||||
authenticates to Gitea with a local account and a scoped PAT, by decision** (operator,
|
||||
2026-10-06), not with a federated identity. Written down because the alternative looks
|
||||
obviously better until you check whether it is possible — see below.
|
||||
|
||||
## What I verified
|
||||
|
||||
Every step measured, read-only, in this order.
|
||||
|
||||
**The failing certificate is the router's, not the Gateway's.**
|
||||
|
||||
- containerd's error names TLS, not the digest: `tls: failed to verify certificate: x509:
|
||||
certificate is not valid for any names, but wanted to match gitea.home.keithjackson.dev`.
|
||||
"not valid for **any** names" means the presented certificate carries no SANs at all.
|
||||
- `192.168.2.20` is the shared Gateway's own LoadBalancer VIP
|
||||
(`gateway/cilium-gateway-shared`, from the Cilium pool `192.168.2.20-30`), and its `https`
|
||||
listener serves `*.home.keithjackson.dev` with `wildcard-home-tls`. From the operator's
|
||||
workstation the name resolves there and serves the real Let's Encrypt
|
||||
`CN=*.home.keithjackson.dev` with correct SANs. **So the certificate and the Gateway are
|
||||
healthy.**
|
||||
- **The node asks a different resolver and gets a different answer.** Prod's
|
||||
`node_baseline_dns_servers` is the LAN gateway, not Pi-hole — deliberately, as that file
|
||||
explains. Public DNS for this zone is a CNAME to `wan.home.keithjackson.dev`: measured via
|
||||
both `1.1.1.1` and `8.8.8.8`, `gitea.home.keithjackson.dev` →
|
||||
`wan.home.keithjackson.dev.` → `136.51.115.252`, and the apex answers the same.
|
||||
- **That address serves a certificate with no names.** Measured from the workstation against
|
||||
the WAN address with the right SNI:
|
||||
|
||||
subject=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
|
||||
issuer=C=ZZ, ST=Somewhere, L=Unknown, O=%D342e7e6b, CN=%D
|
||||
No extensions in certificate
|
||||
|
||||
No extensions means no `subjectAltName`, which is exactly "not valid for any names". The
|
||||
`CN=%D` is the router's placeholder.
|
||||
|
||||
**This repository had already measured the same thing, for Keycloak, and I did not need to
|
||||
re-derive it.** `infra/ansible/inventory/group_vars/k3s_prod/vars.yml` says, above the pin:
|
||||
"Since platform/gateway/gateway.yaml set an external-dns `target`, every public name is a
|
||||
CNAME to wan.home.keithjackson.dev = the home WAN IP. The SOHO router does NOT hairpin: from
|
||||
the LAN, WAN:443 answers with the router's own self-signed placeholder cert (measured from
|
||||
k3s-prod-2/-3 2026-10-03: `curl` exit 60, ssl_verify_result=18)." Same cause, same symptom,
|
||||
one name later. The pin exists precisely for node processes that must reach the Gateway
|
||||
directly; containerd is one, and nobody had added it.
|
||||
|
||||
**The parity the pin has to satisfy:** `192.168.2.20` equals the live
|
||||
`GATEWAY_VIP`, and `k3s_base_domain` equals the live `BASE_DOMAIN` — both read from the
|
||||
cluster, not from the comment that claims prod serves `prod.home.keithjackson.dev` (it does
|
||||
not; `flux-system/cluster-vars` reports `BASE_DOMAIN=home.keithjackson.dev` and
|
||||
`GATEWAY_VIP=192.168.2.20`, and Ansible's `k3s_base_domain` is `home.keithjackson.dev`).
|
||||
That comment is stale history.
|
||||
|
||||
**The credential decision, and why the obvious alternative is not available.** Gitea's API
|
||||
accepts basic auth, PATs, SSH signatures and its own OAuth2 bearer tokens; as an OAuth2
|
||||
provider Gitea implements **only the Authorization Code Grant** (plus PKCE/OIDC), so there
|
||||
is no client-credentials grant, and nothing in Gitea validates a token issued by a
|
||||
third-party OIDC provider. A Keycloak service-account token therefore cannot authenticate to
|
||||
Gitea's API or to git-over-HTTP. Read from Gitea's own documentation
|
||||
(`docs.gitea.com/development/api-usage`, `/development/oauth2-provider`), not assumed.
|
||||
|
||||
**Gate:** `./ci/gate.sh` in the pinned toolbox from a §0 worktree — see the branch's run; rc 3
|
||||
with `gitea-settings` the only stage that did not run.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the pin fixes the pull.** It is IaC, and nothing has applied it: `/etc/hosts` on the
|
||||
nodes is written by the `node_baseline` play's `hosts`-tagged task, which is an operator
|
||||
action. I did confirm the mechanism is not inert — `roles/node_baseline/tasks/main.yml`
|
||||
renders `node_baseline_hosts_pins` into a marked `blockinfile` with `backup: true`, and
|
||||
`state: absent` when the list empties, so a removed entry really leaves the node. Until that runs
|
||||
and an image exists, "the node now reaches the Gateway" is a design claim. The evidence
|
||||
that it *will* is the Keycloak pin working for the same cause — which I also did not
|
||||
re-measure.
|
||||
- **Why public DNS answers at all for an `internal` service.** `catalog/gitea.yaml` sets no
|
||||
`exposure`, so it inherits `internal`, and the renderer contract says an internal service's
|
||||
HTTPRoute carries `external-dns.alpha.kubernetes.io/controller: none` and publishes no
|
||||
public record. Yet `gitea.home.keithjackson.dev` resolves publicly to the WAN CNAME. I did
|
||||
not chase that: it may be a zone-level wildcard rather than a published record, and it did
|
||||
not change the diagnosis. It is worth someone's attention, because "internal services
|
||||
publish no public DNS" is an L0-checked claim and this looks like a case where the *name*
|
||||
resolves regardless of what the renderer withheld.
|
||||
- **Registry authentication.** Unchanged and still open: an unauthenticated client gets
|
||||
nothing from this registry, and no `imagePullSecret` exists anywhere in the tree. With the
|
||||
credential model now decided (local Gitea accounts with PATs) the shape is a
|
||||
`dockerconfigjson` for an account whose token carries `read:package`, referenced from the
|
||||
Deployment and the committed worker Job template — and the worker Job template's shape is
|
||||
pinned by admission, so whether the policy should also pin `imagePullSecrets` deserves a
|
||||
deliberate look rather than a quiet addition. Not built: minting that token is an operator
|
||||
action, and I did not want to guess at a second credential's scope.
|
||||
- **Anything about runtime.** The image still does not exist. No dispatcher has
|
||||
authenticated, no issue has been triaged, no worker Job has been created, and the admission
|
||||
policy — live, `Enforce`, six rules — has still never admitted or refused a real Job.
|
||||
@@ -0,0 +1,113 @@
|
||||
---
|
||||
date: 2026-10-06
|
||||
branch: fix/vpn-ingress-ip-forward
|
||||
title: The VPN tunnel never came up — a hostNetwork pod cannot write net.ipv4.ip_forward, and wg-quick treats a failed PostUp as fatal
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
The remote-access VPN reconciled for the first time with a real server key (#60 then #62) and
|
||||
**the tunnel did not come up**. One line in my own manifest was the whole cause.
|
||||
|
||||
Measured in the `wireguard` container on prod, 2026-10-06:
|
||||
|
||||
```
|
||||
[#] ip link add dev wg0 type wireguard
|
||||
[#] wg addconf wg0 /dev/fd/63
|
||||
[#] ip -4 address add 10.90.0.1/24 dev wg0
|
||||
[#] ip link set mtu 1420 up dev wg0
|
||||
[#] sysctl -q -w net.ipv4.ip_forward=1
|
||||
sysctl: permission denied on key "net.ipv4.ip_forward"
|
||||
[#] ip link delete dev wg0
|
||||
**** Tunnel /config/wg_confs/wg0.conf failed, will stop all others! ****
|
||||
```
|
||||
|
||||
Everything up to that point worked. **The cause is not a missing capability.** With
|
||||
`hostNetwork: true` the runtime mounts `/proc/sys` **read-only** in the container — confirmed in the
|
||||
pod's own `/proc/mounts`: `proc /proc/sys proc ro,nosuid,nodev,noexec,relatime` — precisely because
|
||||
those sysctls are the *host's* globals and a container must not rewrite them. `CAP_NET_ADMIN` cannot
|
||||
lift a read-only mount, so this can never succeed from here. And `wg-quick` treats any failed
|
||||
`PostUp` as fatal, so it deleted the interface it had just finished building.
|
||||
|
||||
**The line was never needed.** `node_baseline` writes `net.ipv4.ip_forward=1` to
|
||||
`/etc/sysctl.d/99-k3s-cilium.conf`, and reading the value from inside the pod returns `1`. I had
|
||||
written the header comment *correctly* — "ip_forward is ALREADY 1 on these nodes … so no pod sysctl
|
||||
is needed" — and then emitted the sysctl anyway, justified in its own comment as "re-asserted so
|
||||
this component does not silently depend on another role's sysctl file surviving". That defensive
|
||||
re-assertion is the entire outage. **The lesson is narrow and reusable: in a hostNetwork pod,
|
||||
verify host state, never write it.**
|
||||
|
||||
Fix:
|
||||
|
||||
- **`platform/vpn-ingress/daemonset.yaml`**: the `PostUp = sysctl …` line is gone, replaced by a
|
||||
**read** in the init container — `grep -qx 1 /proc/sys/net/ipv4/ip_forward` — which fails closed
|
||||
with a message naming the `node_baseline` role. Reading `/proc/sys` is permitted; only writing is
|
||||
not. Fail-closed still matters: a tunnel that comes up but cannot forward would satisfy every
|
||||
probe in this component and silently drop every client's traffic.
|
||||
- The header bullet that said "no pod sysctl is needed" now also says **do not attempt one**, with
|
||||
the mechanism, so the next author does not re-add it for the same reason I did.
|
||||
- The `securityContext` comment claimed NET_ADMIN was for "create the wg0 link, set the host sysctl,
|
||||
and write the netfilter rules". The middle clause was false; corrected.
|
||||
- The image's `chown: … Operation not permitted` startup run — plus its own "Permissions could not
|
||||
be set … we will not provide support for it" — is now documented as **expected and harmless**, in
|
||||
both the manifest and the runbook, so nobody chases it. Capabilities are dropped to `NET_ADMIN`
|
||||
only, so its s6 init has no `CAP_CHOWN`; `/config` is a writable tmpfs, everything runs as root,
|
||||
and `wg-quick` proceeds past it (as it did here — the failure was 30 lines later).
|
||||
- **`runbooks/vpn-remote-access.md`**: a "what a healthy start looks like" block with the real log
|
||||
lines; `ip_forward is 0` added to the init-failure table; and the note that `0/1 Running` with
|
||||
`Startup probe failed: Unable to access interface: No such device` is the signature of `wg-quick`
|
||||
tearing the tunnel down — read the `[#]` line it died on.
|
||||
- **`platform/vpn-ingress/README.md`**: a table of what the first reconcile settled (below).
|
||||
|
||||
No sensitive paths: `platform/vpn-ingress/**`, `runbooks/**` and `decisions/**` only. This merges on
|
||||
a green gate with no override.
|
||||
|
||||
## What I verified
|
||||
|
||||
Live prod, read-only, 2026-10-06. **Four questions this component's design rested on are now
|
||||
measured rather than assumed — three went the right way, one was the defect above.**
|
||||
|
||||
- **The GitOps path is proven end to end.** `Kustomization vpn-ingress` `lastAppliedRevision:
|
||||
main@sha1:cf7d5e4` equals the GitRepository artifact revision. The Secret carries
|
||||
`kustomize.toolkit.fluxcd.io/name: vpn-ingress`, so Flux delivered it.
|
||||
- **SOPS decryption works, proven by digest rather than by eye.** The value in
|
||||
`platform/vpn-ingress/wg-server-key.sops.yaml` on `main`, decrypted, is 44 bytes, is **not** the
|
||||
placeholder, and **`wg pubkey` accepts it**. Its SHA-256 equals the SHA-256 of the live Secret's
|
||||
decoded `data.privatekey` (`d7c8f8de6f252649…` both sides). No key material was printed at any
|
||||
point — lengths, booleans and digests only.
|
||||
- **The `wg pubkey` guard fired correctly, and its `ENC[` branch was never needed.** During the
|
||||
kubelet window before the updated Secret propagated, all three pods reported *"It is still the
|
||||
committed REPLACE-ME PLACEHOLDER"* — the guard distinguishing a stale/unfilled value from
|
||||
ciphertext, exactly as designed. It cleared on its own once the mount refreshed; **a transient
|
||||
`REPLACE-ME` CrashLoop for a minute or two after the key PR merges is expected, not a fault.**
|
||||
- **The kernel module reaches the pod**: `ip link add dev wg0 type wireguard` succeeded and
|
||||
`/proc/modules` lists `wireguard`. So the `node_baseline` addition works and `CAP_SYS_MODULE` is
|
||||
correctly not required.
|
||||
- **`allowPrivilegeEscalation: false` does NOT break the image's s6 init** — the container reaches
|
||||
`wg-quick` normally. The flag stays; the fear recorded in #55 is closed.
|
||||
- **The iptables backends MATCH, which was my single biggest documented doubt.** The image ships
|
||||
`iptables v1.8.13 (nf_tables)` via `/usr/sbin/xtables-nft-multi`, and Debian 13 is nf_tables. The
|
||||
nightmare case — rules accepted into a table the kernel never consults, so handshakes succeed and
|
||||
every forwarded connection hangs — cannot occur on this pairing.
|
||||
- **`net.ipv4.ip_forward` is `1`** in the host netns the pod shares, and `/proc/sys` is `ro` there.
|
||||
Both read directly from the pod.
|
||||
- **Toolbox gate** from an isolated worktree: **rc 3**, with `spec-immutability`, `decisions-log`,
|
||||
`lint`, `l0`, `render-golden`, `l1` and `approval-policy` all **pass**; rc 3 is the two
|
||||
environmental skips (`gitea-settings`, conformance L2/L3). **Not green. Reported as rc 3.**
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **That the tunnel now comes up.** This fix has not reconciled. The next reconcile is the test, and
|
||||
its success criterion is specific: pods `1/1 Running`, and `wg show` reporting `interface: wg0`
|
||||
with `listening port: 51820`.
|
||||
- **That the MASQUERADE and FORWARD rules actually apply.** They are the next statements after the
|
||||
one that failed, so **they have never executed**. The backends matching (above) removes the worst
|
||||
failure mode but proves nothing about the rules themselves. First check after the pods go Ready:
|
||||
`iptables -t nat -S POSTROUTING | grep 10.90.0.0/24` on a node.
|
||||
- **Anything about client traffic.** Still zero peers enrolled, still no WAN port-forward, still no
|
||||
off-LAN vantage point. The VPN has carried no packet.
|
||||
- **Whether the transient `REPLACE-ME` window has an upper bound.** It cleared within ~2 minutes
|
||||
here; I did not determine the kubelet refresh behaviour, so a longer stall after a key change is
|
||||
uncharacterised.
|
||||
- **The `sops set` hand-off command**, still. The operator filled the key in by their own route
|
||||
(#62), so the exact quoting I documented inside `dev.sh run bash -c` remains unexercised.
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
date: 2026-10-07
|
||||
branch: fix/bulk-nfs-truenas
|
||||
title: Repoint bulk NFS at the TrueNAS (192.168.2.30); the PVs must be recreated, not reconciled
|
||||
---
|
||||
|
||||
## What changed
|
||||
|
||||
Both ZFS pools were physically moved off the Proxmox host onto a standalone TrueNAS Community
|
||||
Edition at **192.168.2.30**, which sits on prod's own `192.168.2.0/24`. `BULK_NFS_SERVER` moves from
|
||||
`192.168.2.102` (the pfSense DNAT prod used to mount through) to `192.168.2.30`.
|
||||
|
||||
- `clusters/prod/kustomization.yaml` — `BULK_NFS_SERVER` → `192.168.2.30`. The comment block that
|
||||
justified the DNAT, and the "NOT YET DECIDED / THIS PLACEHOLDER IS LOAD-BEARING" banner, are
|
||||
replaced with the measured export configuration and the recreate requirement below.
|
||||
- `clusters/base/cluster-vars.yaml` — base default `192.168.1.9` → `192.168.2.30`. That host no
|
||||
longer exists.
|
||||
- `clusters/dev/kustomization.yaml` — same; marked explicitly **inert**, since dev is decommissioned
|
||||
and its nodes are gone. It carries the real address only so nothing in the tree renders a dead one.
|
||||
- `docs/BULK-NFS-STORAGE.md` — §1 and §5 rewritten for TrueNAS (exports, service settings, the ZFS
|
||||
`sharenfs` properties that had to be cleared, and the ACL hazard); §7 rewritten now that the
|
||||
placeholder is gone; §9 retargeted from dev to prod; **new §10** documents the PV
|
||||
delete-and-recreate. §2/§3/§4/§6 are unchanged by the move.
|
||||
- `catalog/qbittorrent.yaml`, `platform/monitoring/node-pressure-dashboard.yaml` — two comment/prose
|
||||
references to `192.168.2.102` as the live export address.
|
||||
|
||||
**The headline operational fact: repointing this knob is NOT a plain reconcile.** A bound PV's NFS
|
||||
source is immutable, so Flux's apply fails rather than updating and the pods keep their old, dead
|
||||
mount. The five PVs must be deleted and recreated. That sequence is §10 and is an operator action;
|
||||
it is **not** performed by this PR.
|
||||
|
||||
## What I verified
|
||||
|
||||
**On the NAS (192.168.2.30), measured 2026-10-07 by the operator at my request:**
|
||||
|
||||
- `zfs list` — `Storage` → `/mnt/Storage`, `Storage/plex` → `/mnt/Storage/plex`,
|
||||
`Storage/nextcloud` → `/mnt/Storage/nextcloud`. The PV paths resolve unchanged.
|
||||
- Ownership intact: `/mnt/Storage/plex`, `tv/`, `downloads/`, `movies/`,
|
||||
`downloads/complete/`, `nextcloud/nextcloud/` all `drwxr-xr-x 1000:3000`. `movies/` 1171 entries
|
||||
and `tv/` 107, against 1165 / 107 measured on Proxmox.
|
||||
- `acltype=nfsv4` (SCALE's built-in default, `SOURCE: default`), `aclinherit=passthrough`, and no
|
||||
non-trivial ACLs — mode bits still govern.
|
||||
- Three stale `sharenfs` dataset properties (`plex`, `nextcloud`, `docker-volumes`) carried over from
|
||||
Proxmox; cleared with `zfs inherit`, re-checked empty.
|
||||
- `exportfs -v`: both shares to `192.168.2.10/.11/.12` with `sec=sys,secure,root_squash,no_all_squash`,
|
||||
`rw` on plex and `ro` on nextcloud.
|
||||
- `/proc/fs/nfsd/versions` → `+3 +4 +4.1 +4.2`, so the PVs' `nfsvers=4.1` negotiates.
|
||||
- **A defect was found and fixed during setup:** the plex share listed `192.168.2.13` — an address no
|
||||
node holds — and so omitted `192.168.2.12` (k3s-prod-3). Corrected and re-verified.
|
||||
|
||||
**From the cluster:**
|
||||
|
||||
- Node InternalIPs are `192.168.2.10/.11/.12` (`kubectl get nodes`), confirming `.13` was wrong.
|
||||
- All five NFS PVs currently name `192.168.2.102`.
|
||||
- `192.168.2.30:2049` and `:111` accept connections; `192.168.2.102:2049` does not.
|
||||
- **`spec.nfs` immutability, by server-side dry-run** — this is the finding that shapes the change:
|
||||
`spec.persistentvolumesource: Forbidden: spec.persistentvolumesource is immutable after creation`.
|
||||
- The pods report `Ready` while their mounts are dead: a `stat /media` answers from attribute cache,
|
||||
but a `readdir` blocks indefinitely (the PVs mount `hard`). Hence "scale to zero first" in §10.
|
||||
|
||||
**Gate:** see the PR; run in the pinned toolbox from this worktree.
|
||||
|
||||
## What I did NOT verify
|
||||
|
||||
- **Nothing has mounted 192.168.2.30.** No pod has touched the new server — the PVs still point at
|
||||
the old address and cannot be updated in place. Every §9 probe (numeric-id passthrough, the uid-1000
|
||||
write, the hardlink/EXDEV check, Plex's read-only posture) is **outstanding**, not passing. The
|
||||
equivalents passed against the old host on 2026-09-12; that is not evidence about this one.
|
||||
- **The §10 sequence is reasoned, not executed.** Retain + the renderer's pre-set `claimRef` are read
|
||||
from the manifests and from `catalog/render.py`; I have not watched a PVC rebind after a PV delete
|
||||
in this cluster.
|
||||
- **`root_squash` is a behaviour change from the Proxmox export**, which carried `no_root_squash`. The
|
||||
argument that nothing needs root on the tree (LSIO drops to 1000; the images chown only their
|
||||
Longhorn `/config`; both subPath directories already exist at 0755) is reasoned from the images and
|
||||
the measured permissions. It has not been exercised. If a mount or subPath resolution fails with
|
||||
EACCES, Maproot `root:root` reproduces the old export and is the first thing to try.
|
||||
- **Whether NFSv3 can be disabled.** Enabled Protocols is `NFSv3, NFSv4`; the PVs only need v4.1, so
|
||||
narrowing would close the rpcbind/mountd/statd surface. Not done — it is a change to make after the
|
||||
cutover is proven, not during it.
|
||||
- **Whether pfSense / the legacy stack is actually retired.** I measured only that
|
||||
`192.168.2.102:2049` stopped answering. The other legacy consumers of these datasets
|
||||
(`192.168.1.6`, `10.123.1.10`, `192.168.1.18`) were dropped from the new exports; if any is still
|
||||
live, it has lost access. Not checked.
|
||||
- **L2/L3 conformance.** `vars.CLUSTER_AVAILABLE` is unset, so CI proves the L0/L1 offline subset
|
||||
only. Nothing in the gate exercises an NFS mount.
|
||||
@@ -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,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,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.
|
||||
+2
-1
@@ -26,7 +26,8 @@ The fix is the obvious one for concurrent appenders: **stop sharing a file**. Ea
|
||||
handoff is its own file. Two agents writing handoffs touch disjoint paths, so there is
|
||||
nothing for git to merge and nothing to conflict. This scales to any number of parallel
|
||||
branches with zero coordination — which is the point, since the agents are deliberately
|
||||
isolated from each other in separate worktrees and cannot coordinate.
|
||||
isolated from each other in separate worktrees (required by `CLAUDE.md` §0) and cannot
|
||||
coordinate.
|
||||
|
||||
There is deliberately **no index file** and no counter. An index would be a shared
|
||||
mutable file — exactly the thing that just failed — and a sequence number would have
|
||||
|
||||
+222
-116
@@ -1,61 +1,43 @@
|
||||
# Bulk NFS storage — the media library and the legacy Nextcloud archive
|
||||
|
||||
The operator-facing companion to the `class: bulk` storage entries in `catalog/{plex,sonarr,radarr,nextcloud}.yaml`.
|
||||
Everything here was **measured** on the storage host on 2026-09-07, over read-only SSH; nothing is
|
||||
assumed. It resolves the long-standing **OQ-2** stub (`platform/longhorn/storageclasses.yaml`) for the
|
||||
The operator-facing companion to the `class: bulk` storage entries in
|
||||
`catalog/{plex,sonarr,radarr,qbittorrent,nextcloud}.yaml`.
|
||||
|
||||
**Rewritten 2026-10-07 for the move off Proxmox onto a standalone TrueNAS at 192.168.2.30.** The
|
||||
layout (§2), the mount design (§3, §4) and the uid model (§6) are unchanged by the move and were
|
||||
measured on the old host on 2026-09-07. Everything about the storage host itself — §1, §5, §10 —
|
||||
was re-measured on the NAS on 2026-10-07. Nothing here is assumed. It resolves the long-standing
|
||||
**OQ-2** stub (`platform/longhorn/storageclasses.yaml`) for the
|
||||
case that actually exists here — attaching datasets that already hold data — and leaves dynamic bulk
|
||||
provisioning unimplemented, because nothing needs it.
|
||||
|
||||
---
|
||||
|
||||
## 1. The storage host, and why prod cannot reach it
|
||||
## 1. The storage host
|
||||
|
||||
The bulk data lives on the **Proxmox host's ZFS pool**. That host has exactly one interface:
|
||||
The bulk data lives on a **standalone TrueNAS** (Community Edition) at **192.168.2.30** — the same
|
||||
`192.168.2.0/24` as prod's nodes (`192.168.2.10/.11/.12`), so it is reached directly, with no NAT
|
||||
and no routing decision.
|
||||
|
||||
```
|
||||
vmbr0 192.168.1.9/24 default via 192.168.1.1 (pfSense LAN)
|
||||
```
|
||||
|
||||
| | nodes | reaches 192.168.1.9? |
|
||||
|---|---|---|
|
||||
| **dev** | 192.168.1.40 / .41 / .42 | **Yes** — same L2 segment, and it already holds an NFS export from this host (`/mnt/Storage/k8s-volumes`, in `/etc/exports`) |
|
||||
| **prod** | 192.168.2.10 / .11 / .12 | **No** — 192.168.2.0/24 is pfSense's **WAN**. Ports 22 and 8006 answer only because pfSense port-forwards them via 192.168.2.102. 2049 and 111 do not. |
|
||||
|
||||
Opening the storage path for prod is a **pending operator decision**. Its two candidate outcomes give
|
||||
different values for the `BULK_NFS_SERVER` cluster-var:
|
||||
|
||||
| outcome | `BULK_NFS_SERVER` for prod |
|
||||
|---|---|
|
||||
| port-forward `192.168.2.102:2049` → `192.168.1.9:2049` | `192.168.2.102` |
|
||||
| a WAN→LAN route to 192.168.1.0/24 | `192.168.1.9` |
|
||||
|
||||
Until it lands, `clusters/prod` holds `TBD-PROD-BULK-NFS-SERVER`. **See §7 for why that placeholder
|
||||
behaves differently from the repo's other ones.**
|
||||
**This section used to be the hard part of this page.** The datasets were on the Proxmox host, whose
|
||||
only interface was `vmbr0 192.168.1.9/24` on pfSense's **LAN**, while prod sits on pfSense's **WAN**.
|
||||
tcp/2049 did not traverse that, so prod mounted through a DNAT at `192.168.2.102`, and which address
|
||||
belonged in `BULK_NFS_SERVER` depended on an undecided operator choice. Both pools were physically
|
||||
moved on 2026-10-07 (`docs/plans/proxmox-to-n5-air-migration.md`, Phase C/D) and all of that is
|
||||
simply gone. Measured 2026-10-07: **192.168.2.102:2049 no longer answers; 192.168.2.30:2049 and
|
||||
:111 do.**
|
||||
|
||||
### It only needs ONE port
|
||||
|
||||
The PVs mount `nfsvers=4.1`. **NFSv4 carries mount and lock in-protocol**, so the only thing that
|
||||
must be reachable is **tcp/2049** — no rpcbind/111, no mountd, no statd. NFSv3 would have needed
|
||||
four ports, three of them on ephemeral numbers, which is a materially worse firewall story. The
|
||||
server advertises `+3 +4 +4.1 +4.2` (`/proc/fs/nfsd/versions`), so v4.1 is available and is the
|
||||
conservative floor.
|
||||
four ports, three of them on ephemeral numbers. The NAS advertises `+3 +4 +4.1 +4.2`
|
||||
(`/proc/fs/nfsd/versions`, measured 2026-10-07), so 4.1 is available and is the conservative floor.
|
||||
|
||||
### The export's client list is the SAME under both outcomes — measured, not assumed
|
||||
|
||||
**pfSense DNATs the existing port-forward WITHOUT source NAT.** Connecting from this workstation
|
||||
(192.168.2.132) to `192.168.2.102:22`, the storage host logged the peer as `192.168.2.132`:
|
||||
|
||||
```
|
||||
SSH_CONNECTION=192.168.2.132 39768 192.168.1.9 22
|
||||
ss -tn -> 192.168.1.9:22 192.168.2.132:39768
|
||||
```
|
||||
|
||||
The original WAN-side source address survives the translation (return traffic finds its way back via
|
||||
the host's default route, 192.168.1.1). So under **either** outcome the NFS server will see prod's
|
||||
**real** node addresses — 192.168.2.10/.11/.12 — and the `sharenfs` change in §5 is the same either
|
||||
way. **It does not have to wait for the routing decision.**
|
||||
|
||||
---
|
||||
**NFSv4 is not on by default and its absence does not look like a storage problem.** TrueNAS ships
|
||||
NFSv3-only until NFSv4 is added to *Services → NFS → Enabled Protocols*. Without it the mount never
|
||||
completes and the pods sit in `ContainerCreating` — no permission error, nothing in the app logs,
|
||||
just no volume.
|
||||
|
||||
## 2. The layout, as found
|
||||
|
||||
@@ -107,16 +89,18 @@ why it is mounted read-only; see §6.
|
||||
| plex | `/mnt/Storage/plex` | `/media` | — | **ro** | ReadOnlyMany |
|
||||
| sonarr | `/mnt/Storage/plex` | `/media` | — | rw | ReadWriteMany |
|
||||
| radarr | `/mnt/Storage/plex` | `/media` | — | rw | ReadWriteMany |
|
||||
| qbittorrent | `/mnt/Storage/plex` | `/downloads` | `downloads` | rw | ReadWriteMany |
|
||||
| nextcloud | `/mnt/Storage/nextcloud` | `/mnt/legacy-nextcloud` | `nextcloud` | **ro** | ReadOnlyMany |
|
||||
|
||||
In-app paths that follow: Plex libraries at `/media/movies`, `/media/tv`, `/media/photos`; Sonarr
|
||||
Root Folder `/media/tv`; Radarr Root Folder `/media/movies`; both importing from
|
||||
`/media/downloads/complete/{tv-sonarr,radarr}`.
|
||||
|
||||
**All three media consumers use the identical mountPath on purpose.** When qbittorrent is eventually
|
||||
given the same export (it is not in this change), the arrs and the download client will already agree
|
||||
on absolute paths, which is the second precondition for hardlinked imports — otherwise every import
|
||||
needs a Remote Path Mapping and quietly falls back to copying when one is missing.
|
||||
**The three library consumers use the identical mountPath on purpose**, and qbittorrent has since
|
||||
been given the same export (narrowed by `subPath: downloads`, presented at `/downloads` to match its
|
||||
existing `Session\DefaultSavePath`). The arrs and the download client therefore agree on absolute
|
||||
paths, which is the second precondition for hardlinked imports — otherwise every import needs a
|
||||
Remote Path Mapping and quietly falls back to copying when one is missing.
|
||||
|
||||
**`/media` and not `/data`, and that is not cosmetic.** The arrs' Longhorn config PVC renders at the
|
||||
renderer default `/data/config`. A media mount at `/data` would be mounted **over** it and mask the
|
||||
@@ -147,62 +131,123 @@ Narrowing that would cost hardlinks, which is the worse trade.
|
||||
|
||||
---
|
||||
|
||||
## 5. The export change the operator must apply — STRICTLY ADDITIVE
|
||||
## 5. The exports, as configured on TrueNAS — and the settings that decide permissions
|
||||
|
||||
**Do not apply this from the repo.** It is an operator step on the storage host, recorded here so the
|
||||
exact value is reviewable. The existing consumers **must keep working**: 192.168.1.6, 10.123.1.10 and
|
||||
192.168.1.18 on the media tree, and 192.168.1.6 and 192.168.1.5 on the Nextcloud dataset, are the
|
||||
live non-k3s prod stack.
|
||||
**Do not apply this from the repo.** It is operator configuration on the NAS, recorded here so the
|
||||
exact values are reviewable. Everything below was **measured on 192.168.2.30 on 2026-10-07**, after
|
||||
the move.
|
||||
|
||||
Current values (`zfs get sharenfs Storage`):
|
||||
### The exports
|
||||
|
||||
```
|
||||
Storage/plex rw=@192.168.1.6:@10.123.1.10:@192.168.1.18,no_subtree_check,no_root_squash
|
||||
Storage/nextcloud rw=@192.168.1.6:@192.168.1.5,no_subtree_check,no_root_squash
|
||||
/mnt/Storage/plex 192.168.2.10 192.168.2.11 192.168.2.12
|
||||
(sync,wdelay,hide,no_subtree_check,sec=sys,rw,secure,root_squash,no_all_squash)
|
||||
/mnt/Storage/nextcloud 192.168.2.10 192.168.2.11 192.168.2.12
|
||||
(sync,wdelay,hide,no_subtree_check,sec=sys,ro,secure,root_squash,no_all_squash)
|
||||
```
|
||||
|
||||
### `Storage/plex` — append the six k3s node addresses to the existing `rw=` list
|
||||
Per-node addresses rather than `@192.168.2.0/24`, following the precedent of the old host. A `/24`
|
||||
would work and would survive adding a node; it also grants the whole subnet.
|
||||
|
||||
**All three nodes must appear on BOTH shares.** Any media pod can be scheduled on any node, so a
|
||||
missing node is a latent failure, not an immediate one: it surfaces the first time a pod reschedules
|
||||
there, as `access denied by server while mounting`, with the cause weeks stale. One was caught
|
||||
exactly this way during setup — the plex share listed `192.168.2.13`, an address no node holds, and
|
||||
so was missing `192.168.2.12` (k3s-prod-3). Nothing was failing at the time.
|
||||
|
||||
### The option flags ARE the permission contract
|
||||
|
||||
| flag | why it matters here |
|
||||
|---|---|
|
||||
| **`no_all_squash`** | The one the whole model rests on. Client uids pass through untouched, so the arrs' `PUID=1000` arrives as the owner of a `0755 1000:3000` tree. In the UI this is **Mapall User/Group left EMPTY** — setting either squashes every client to one identity and erases the reader/writer distinction entirely. |
|
||||
| **`sec=sys`** | AUTH_SYS. Numeric uid/gid, not Kerberos principals. §6 is only true because of this. |
|
||||
| **`root_squash`** | Maproot left empty. The Proxmox export carried `no_root_squash`; **nothing here needs it** — every write to the tree happens after the LSIO drop to uid 1000, and the images only chown their Longhorn `/config`. This is a deliberate tightening over the old export, not parity. |
|
||||
| **`rw` / `ro`** | The archive's read-only posture is **server-enforced**, as it was before. The media tree cannot get the same treatment — see below. |
|
||||
| **`secure`** | Reserved source ports required; "Allow non-root mount" off. The kubelet mounts as root from a reserved port and does not need the exception. |
|
||||
|
||||
**Plex's read-only posture is still a CLIENT-side guarantee.** Plex is ro and the arrs are rw, but
|
||||
all four can land on any node, so the server sees the same three source addresses for all of them
|
||||
and cannot distinguish the reader from the writers. `spec.nfs.readOnly: true` on `plex-media` plus
|
||||
the volumeMount flag is the whole enforcement. Unchanged by the move; an honest limitation, not an
|
||||
oversight. If it ever needs to be server-enforced, the lever is a dedicated node pool for Plex.
|
||||
|
||||
### Service settings that are NOT obvious from the export list
|
||||
|
||||
- **Enabled Protocols must include NFSv4** (above).
|
||||
- **Require Kerberos for NFSv4: off.** The numeric AUTH_SYS pass-through is the model.
|
||||
- **Manage Groups Server-side: off.** This is `rpc.mountd --manage-gids`: the server *discards the
|
||||
client's supplied group list* and looks that uid's groups up in TrueNAS's own user database
|
||||
instead. With no local uid 1000 defined, the arrs' `PGID=3000` would simply vanish. Writes would
|
||||
survive anyway — uid 1000 **owns** the tree, so the owner bits grant access without the group —
|
||||
but it is a free way to lose group semantics.
|
||||
- **NFSv4 DNS Domain: blank.** It only feeds `idmapd.conf`, which numeric mapping bypasses.
|
||||
- **Bind IP 192.168.2.30** means loopback does not answer: check with `showmount -e 192.168.2.30`,
|
||||
not `localhost`.
|
||||
|
||||
There is **no** "NFSv3 ownership model for NFSv4" control on current TrueNAS — it was an older
|
||||
TrueNAS/CORE option, and no replacement is needed: on a Linux client against a Linux server over
|
||||
AUTH_SYS, both the `nfs` and `nfsd` modules default to `nfs4_disable_idmapping=Y`, so NFSv4 carries
|
||||
raw numeric ids rather than `user@domain` principals. §9's `ls -n` probe is how you confirm it.
|
||||
|
||||
### The ZFS `sharenfs` properties must be CLEARED, not edited
|
||||
|
||||
The pools arrived with the old host's exports still set as dataset properties:
|
||||
|
||||
```
|
||||
Storage/plex rw=@192.168.1.6:@10.123.1.10:@192.168.1.18:@192.168.2.10:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash
|
||||
Storage/nextcloud rw=@192.168.1.6:@192.168.1.5,ro=@192.168.2.10:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash
|
||||
Storage/docker-volumes rw=@192.168.1.6:@192.168.1.5:@192.168.1.10:@192.168.1.18,no_subtree_check,no_root_squash
|
||||
```
|
||||
|
||||
TrueNAS generates `/etc/exports` from its own middleware database and will fight a property it did
|
||||
not set — the failure mode is two differently-configured exports of one path, with no indication
|
||||
which is in force. Cleared 2026-10-07:
|
||||
|
||||
```sh
|
||||
zfs set sharenfs='rw=@192.168.1.6:@10.123.1.10:@192.168.1.18:@192.168.1.40:@192.168.1.41:@192.168.1.42:@192.168.2.10:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash' Storage/plex
|
||||
zfs inherit sharenfs Storage/plex
|
||||
zfs inherit sharenfs Storage/nextcloud
|
||||
zfs inherit sharenfs Storage/docker-volumes
|
||||
```
|
||||
|
||||
Every pre-existing host is preserved verbatim and no option changes; only clients are added.
|
||||
`Storage` is `off`, so each resolves to `off`.
|
||||
|
||||
### `Storage/nextcloud` — add the k3s nodes to a **`ro=`** list, not to `rw=`
|
||||
### Do NOT touch the dataset's permissions or ACLs
|
||||
|
||||
```sh
|
||||
zfs set sharenfs='rw=@192.168.1.6:@192.168.1.5,ro=@192.168.1.40:@192.168.1.41:@192.168.1.42:@192.168.2.10:@192.168.2.11:@192.168.2.12,no_subtree_check,no_root_squash' Storage/nextcloud
|
||||
```
|
||||
The tree's ownership **is** the access story (§6), and TrueNAS offers two ways to destroy it
|
||||
irreversibly across 8.33 T.
|
||||
|
||||
This is the one place read-only can be enforced **server-side**, and it is worth taking: only
|
||||
Nextcloud consumes this dataset from k3s, so the export itself can refuse writes. Even a
|
||||
misconfigured client cannot then damage the archive.
|
||||
`Storage/plex` carries `acltype=nfsv4` (SCALE's built-in default, `SOURCE: default`) and
|
||||
`aclinherit=passthrough` (inherited). Measured 2026-10-07: the directories carry **trivial ACLs
|
||||
only** — `nfs4xdr_getfacl` shows just `owner@`/`group@`/`everyone@` matching the mode — so `0755
|
||||
1000:3000` governs exactly as it did under Proxmox.
|
||||
|
||||
**The media tree cannot get the same treatment.** Plex is read-only and the arrs are read-write, but
|
||||
all three may be scheduled on any node, so the server sees the same source addresses for all of
|
||||
them and cannot distinguish the reader from the writers. **Plex's read-only posture is therefore a
|
||||
client-side guarantee** (`spec.nfs.readOnly: true` on `plex-media`, plus the volumeMount flag) — an
|
||||
honest limitation, not an oversight. If it ever needs to be server-enforced, the lever is a
|
||||
dedicated node pool for Plex, which is a much larger change.
|
||||
- **Stay out of the dataset's Edit Permissions / Edit ACL screen.** On an `acltype=nfsv4` dataset
|
||||
that is a full ACL editor, and its presets write *real* NFSv4 ACLs, recursively if permitted.
|
||||
There is no undo.
|
||||
- `aclinherit=passthrough` means a single ACL written at `/mnt/Storage/plex` would be **inherited by
|
||||
everything created under it afterwards** — the blast radius is the whole future library, not one
|
||||
directory.
|
||||
- Note GNU `ls`'s `+` marker is **not** a reliable ACL indicator here: it looks for a POSIX ACL
|
||||
xattr, and this dataset stores NFSv4 ACLs elsewhere. Use `nfs4xdr_getfacl`.
|
||||
|
||||
Per-node addresses rather than `@192.168.2.0/24` follows the precedent already on this host
|
||||
(`/etc/exports` lists the dev nodes individually, as `/32`). A `/24` would work and would survive
|
||||
adding a node; it also grants the whole subnet. Per-node is the tighter default.
|
||||
`xattr=on` rather than `sa` is a performance detail, not a permissions one, and changing it would
|
||||
not affect existing files.
|
||||
|
||||
### Verifying, without changing anything
|
||||
|
||||
```sh
|
||||
exportfs -v # the new client lines appear, existing ones unchanged
|
||||
zfs get sharenfs Storage/plex Storage/nextcloud
|
||||
cat /proc/fs/nfsd/versions # +4.1 must be present
|
||||
exportfs -v # the two shares, three nodes each, flags as tabulated above
|
||||
showmount -e 192.168.2.30 # NOT localhost -- the service is bound to one address
|
||||
zfs get -r -o name,property,value,source sharenfs Storage # every row off or -
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. uid / gid — the failure this design exists to avoid
|
||||
|
||||
The export uses `sec=sys` and `no_root_squash`, so **the client's numeric uid is passed through
|
||||
verbatim** and the server's on-disk ownership is the entire access story. The media tree is
|
||||
The export uses `sec=sys` and `no_all_squash`, so **the client's numeric uid is passed through
|
||||
verbatim** and the server's on-disk ownership is the entire access story. (It no longer carries
|
||||
`no_root_squash`, which the Proxmox export did — see §5. Nothing here needs it, and the uid that
|
||||
matters is 1000, not 0.) The media tree is
|
||||
`0755 1000:3000` throughout (some directories `1000:1000`), so **only uid 1000 can write it**.
|
||||
|
||||
**`fsGroup` cannot fix this, and reaching for it is how this gets misdiagnosed.** The in-tree NFS
|
||||
@@ -233,24 +278,27 @@ Nextcloud needs no uid work at all, which is one more reason its mount is read-o
|
||||
|
||||
---
|
||||
|
||||
## 7. The prod placeholder does NOT fail at apply time — read this before merging
|
||||
## 7. Why this knob is unvalidated at create AND immutable at update
|
||||
|
||||
**The `TBD-PROD-BULK-NFS-SERVER` placeholder is gone** (2026-10-07) — prod now carries a real
|
||||
address, because the NAS is on prod's own subnet and there is no longer a decision to defer. The
|
||||
hazard that made it load-bearing is worth keeping on the page, because it is the half that explains
|
||||
why §10 exists.
|
||||
|
||||
Every other `TBD-PROD-*` in `clusters/prod/kustomization.yaml` is rejected loudly by CRD validation
|
||||
(Cilium, LB-IPAM, CNPG, Velero) because it is not a valid IP or URL. **This one is not.**
|
||||
(Cilium, LB-IPAM, CNPG, Velero) because it is not a valid IP or URL. **This one was not.**
|
||||
|
||||
A PersistentVolume's `spec.nfs.server` is an **unvalidated string**. The API server accepts
|
||||
`TBD-PROD-BULK-NFS-SERVER`, the PV is created, and the PVC **binds** to it — binding is pure
|
||||
control-plane and never touches the server. The failure appears only when a pod mounts:
|
||||
A PersistentVolume's `spec.nfs.server` is an **unvalidated string**. The API server accepted
|
||||
`TBD-PROD-BULK-NFS-SERVER`, the PV was created, and the PVC **bound** to it — binding is pure
|
||||
control-plane and never touches the server. The failure appeared only when a pod mounted:
|
||||
|
||||
```
|
||||
MountVolume.SetUp failed ... Failed to resolve server TBD-PROD-BULK-NFS-SERVER
|
||||
```
|
||||
|
||||
so **plex, sonarr, radarr and nextcloud would sit in ContainerCreating on prod.**
|
||||
|
||||
**Consequently: set `BULK_NFS_SERVER` in `clusters/prod/kustomization.yaml` BEFORE prod reconciles a
|
||||
revision that carries these bulk volumes.** Dev renders a working value and is where these manifests
|
||||
are meant to be proven first.
|
||||
**The same field is immutable on UPDATE** (§10), which is the other half of the same awkwardness: a
|
||||
wrong value is accepted silently at create, and then cannot be corrected in place. Together they are
|
||||
why repointing this knob is a delete-and-recreate rather than a reconcile.
|
||||
|
||||
### The static guard this convention leans on is missing from `main` — but the gate now refuses the value anyway
|
||||
|
||||
@@ -287,10 +335,9 @@ Without the arm, HALF A is what holds the line, by design.
|
||||
EPHEMERAL layer"). Plex was fixed; sonarr, radarr, qbittorrent and jackett were not. Fixing it
|
||||
means those apps start from an empty config volume, so it belongs in its own change with the
|
||||
operator present. **This change does not make it worse** — that is why the media mount is `/media`.
|
||||
2. **qbittorrent has no bulk mount.** Today the arrs' import source is written by the **legacy
|
||||
non-k3s** download client, which is coherent while the legacy stack stays live. Giving the k3s
|
||||
qbittorrent the same export at the same `/media` path is the natural follow-up, and the paths were
|
||||
chosen so it needs no Remote Path Mapping.
|
||||
2. **RESOLVED — qbittorrent now has the bulk mount** (`subPath: downloads` at `/downloads`), so the
|
||||
import source is written by the k3s download client rather than the legacy non-k3s one. See
|
||||
`catalog/qbittorrent.yaml`.
|
||||
3. **`/mnt/Storage/plex/downloads/downloads` is a symlink to `/shared/downloads`**, which is outside
|
||||
the export. Over NFS it is a dangling symlink on every client. Harmless, but expect Sonarr to log
|
||||
it while scanning `downloads/`.
|
||||
@@ -300,10 +347,10 @@ Without the arm, HALF A is what holds the line, by design.
|
||||
5. **`nconnect` was not set.** It would likely help throughput on the media tree, but it is a tuning
|
||||
change that should be measured, not guessed, and it changes client behaviour under server
|
||||
failure.
|
||||
6. **Nothing was verified against a live cluster.** No `kubectl` context is usable from this
|
||||
workstation (browser-OIDC only), and this change is manifest-authoring under an explicit
|
||||
do-not-apply constraint. Every claim about the *host* is measured; every claim about *binding and
|
||||
mounting* is reasoned from the manifests.
|
||||
6. **Every claim about the NAS in §5 is measured (2026-10-07); the post-move MOUNT is not.** The PVs
|
||||
still have to be recreated (§10) before any pod mounts 192.168.2.30, so the §9 probes are the
|
||||
outstanding verification, not a formality. The pre-move equivalents did pass against the old host
|
||||
on 2026-09-12.
|
||||
7. **The bulk-tier requirement ID is deliberately not typed on any non-`.md` line of this change.**
|
||||
`ci/touched-requirements.sh` attributes requirements from R-IDs appearing in added lines, and the
|
||||
in-cluster conformance test for the bulk tier is still a gap, so typing it would select a
|
||||
@@ -312,36 +359,95 @@ Without the arm, HALF A is what holds the line, by design.
|
||||
|
||||
---
|
||||
|
||||
## 9. Validating on dev, once the export is extended
|
||||
## 9. Validating, after the PVs are recreated
|
||||
|
||||
Dev can prove all of this before prod's network path exists. Read-only checks first:
|
||||
These run against **prod**; dev is decommissioned. They are the outstanding verification for the
|
||||
move — nothing below has been run against 192.168.2.30. Read-only checks first:
|
||||
|
||||
```sh
|
||||
infra/devcontainer/dev.sh run kubectl --context k3s-dev get pv
|
||||
infra/devcontainer/dev.sh run kubectl --context k3s-dev get pvc -A | grep -E 'media|legacy-archive'
|
||||
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n sonarr get pod -o wide
|
||||
./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get pv -o custom-columns=\
|
||||
NAME:.metadata.name,SERVER:.spec.nfs.server,PATH:.spec.nfs.path,STATUS:.status.phase'
|
||||
```
|
||||
|
||||
Expect **four** PVs — `plex-media`, `sonarr-media`, `radarr-media`, `nextcloud-legacy-archive` (three
|
||||
of them naming the *same* export, one per namespace, which is the cross-namespace model) — each
|
||||
`Bound` to the same-named PVC, with `RECLAIM POLICY Retain` and an empty `STORAGECLASS`.
|
||||
Expect **five** NFS PVs — `plex-media`, `sonarr-media`, `radarr-media`, `qbittorrent-downloads`,
|
||||
`nextcloud-legacy-archive` (four of them naming the *same* export, one per namespace, which is the
|
||||
cross-namespace model) — each `Bound`, `RECLAIM POLICY Retain`, empty `STORAGECLASS`, and
|
||||
**`SERVER 192.168.2.30`**. A PV still showing `192.168.2.102` means the recreate in §10 did not
|
||||
happen; Flux will NOT have updated it in place.
|
||||
|
||||
Then the two things that actually matter, from inside a pod:
|
||||
Then the four things that actually matter, from inside a pod:
|
||||
|
||||
```sh
|
||||
# (a) the writer can write — this is the uid check, and it is the one that fails in practice
|
||||
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n sonarr exec deploy/sonarr -- \
|
||||
sh -c 'id; touch /media/tv/.k3s-write-probe && rm /media/tv/.k3s-write-probe && echo WRITE-OK'
|
||||
# (a) numeric ids survived the wire -- the idmapping check, new since the TrueNAS move
|
||||
kubectl --context k3s-prod -n sonarr exec deploy/sonarr -c sonarr -- sh -c 'ls -n /media | head -3'
|
||||
# expect uid 1000 / gid 3000 on the entries. 65534 / nobody means ids are being mapped, not
|
||||
# passed through, and every import will fail EACCES while the pod still reports Ready.
|
||||
|
||||
# (b) hardlinks work BETWEEN the library and the download path — the root-mount check
|
||||
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n sonarr exec deploy/sonarr -- \
|
||||
# (b) the writer can write -- the uid check, and the one that fails in practice
|
||||
kubectl --context k3s-prod -n sonarr exec deploy/sonarr -c sonarr -- \
|
||||
sh -c 'touch /media/tv/.k3s-write-probe && rm /media/tv/.k3s-write-probe && echo WRITE-OK'
|
||||
|
||||
# (c) hardlinks work BETWEEN the library and the download path -- the root-mount check
|
||||
kubectl --context k3s-prod -n sonarr exec deploy/sonarr -c sonarr -- \
|
||||
sh -c 'set -e; d=/media/downloads/complete/tv-sonarr/.probe; t=/media/tv/.probe-link;
|
||||
echo x > "$d"; ln "$d" "$t"; ls -i "$d" "$t"; rm -f "$d" "$t"; echo HARDLINK-OK'
|
||||
|
||||
# (c) the reader really is read-only
|
||||
infra/devcontainer/dev.sh run kubectl --context k3s-dev -n plex exec deploy/plex -- \
|
||||
sh -c 'touch /media/.probe 2>&1 | head -1' # expect "Read-only file system"
|
||||
# (d) the reader really is read-only
|
||||
kubectl --context k3s-prod -n plex exec deploy/plex -- sh -c 'touch /media/.probe 2>&1 | head -1'
|
||||
# expect "Read-only file system"
|
||||
```
|
||||
|
||||
`(b)` printing two identical inode numbers is the proof that §4's argument holds in practice. If it
|
||||
`(c)` printing two identical inode numbers is the proof that §4's argument holds in practice. If it
|
||||
prints `EXDEV`, something has split the single mount back into two.
|
||||
|
||||
Reading the failures together is what localises the fault: **(b) EACCES with (a) showing `nobody`**
|
||||
is idmapping (§5 service settings). **(b) EACCES with (a) showing `1000`** is on-disk ownership —
|
||||
stop and check `ls -ldn` on the NAS before changing anything else.
|
||||
|
||||
---
|
||||
|
||||
## 10. Repointing BULK_NFS_SERVER means DELETING AND RECREATING THE PVs
|
||||
|
||||
**A bound PersistentVolume's NFS source is immutable.** Measured by server-side dry-run on
|
||||
2026-10-07:
|
||||
|
||||
```
|
||||
$ kubectl patch pv sonarr-media --type=json --dry-run=server \
|
||||
-p '[{"op":"replace","path":"/spec/nfs/server","value":"192.168.2.30"}]'
|
||||
The PersistentVolume "sonarr-media" is invalid: spec.persistentvolumesource: Forbidden:
|
||||
spec.persistentvolumesource is immutable after creation
|
||||
```
|
||||
|
||||
Note the pairing with §7: `spec.nfs.server` is **unvalidated at create** (any string is accepted and
|
||||
the PVC binds) and **immutable at update**. So editing `BULK_NFS_SERVER` alone does not fail loudly
|
||||
and does not work either — Flux's apply errors on the five PVs and the pods keep their previous,
|
||||
now-dead mount.
|
||||
|
||||
This is safe to do because `persistentVolumeReclaimPolicy: Retain` means deleting a PV never touches
|
||||
the data, and `catalog/render.py` emits a **pre-set `claimRef`** on each one, so a recreated PV
|
||||
rebinds its own PVC with no manual step.
|
||||
|
||||
**Operator sequence** (mutating `kubectl` is an operator action, not an agent action):
|
||||
|
||||
```sh
|
||||
# 1. Stop the consumers. This also releases the hung hard-mounts against the old server:
|
||||
# the pods report Ready, but any real I/O on /media blocks indefinitely once 2049 stops
|
||||
# answering, because the PVs mount `hard`.
|
||||
for ns in plex sonarr radarr qbittorrent nextcloud; do
|
||||
kubectl --context k3s-prod -n $ns scale deploy --all --replicas=0
|
||||
done
|
||||
|
||||
# 2. Delete the five PVs. Retain + no live claim means nothing is reclaimed or erased.
|
||||
kubectl --context k3s-prod delete pv plex-media sonarr-media radarr-media \
|
||||
qbittorrent-downloads nextcloud-legacy-archive
|
||||
|
||||
# 3. Let Flux reconcile the revision carrying BULK_NFS_SERVER=192.168.2.30, which recreates them.
|
||||
# Confirm .status.lastAppliedRevision matches the GitRepository artifact before continuing.
|
||||
|
||||
# 4. Bring the consumers back, then run every probe in §9.
|
||||
```
|
||||
|
||||
The PVCs pass through `Lost` between steps 2 and 3; that is expected and resolves when the PV with
|
||||
the matching `claimRef` reappears. If a PVC is still `Lost` after Flux reconciles, the PV name or
|
||||
its `claimRef` namespace does not match — do **not** delete the PVC to "fix" it.
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# Plan: an in-cluster AI Program Manager that turns Gitea issues into pull requests
|
||||
|
||||
**Status:** authored, gate-checked offline, **nothing deployed**. The image has never been
|
||||
built or pushed; no part of this has run in the cluster.
|
||||
**Owner:** Program Manager. **Serves:** R-SEC-02 (least privilege), R-GITOPS-02 (declared
|
||||
delivery). **Implementation:** [`platform/pm-agent/`](../../platform/pm-agent/),
|
||||
[`infra/agent/`](../../infra/agent/),
|
||||
[`platform/kyverno/admission/pm-agent-job-shape.yaml`](../../platform/kyverno/admission/pm-agent-job-shape.yaml).
|
||||
|
||||
## What was asked for
|
||||
|
||||
A service running in the k3s cluster that hosts an AI agent which reads issues reported in
|
||||
the Gitea project and spins up other agents to fix the codebase, following the required PR
|
||||
workflow. It uses an Anthropic commercial subscription. It runs as the Program Manager
|
||||
persona specified in the repository. It reacts to issue creation and to comments within
|
||||
issues, and the agents it spins up are assigned the ticket, with all context and
|
||||
correspondence flowing through the ticket and the PR process.
|
||||
|
||||
## The shape, in one paragraph
|
||||
|
||||
A single-replica **dispatcher** (`pm-dispatch`) polls the Gitea issue list. For any issue
|
||||
whose newest activity was not its own, it hands the whole thread to the model under the
|
||||
**Program Manager persona** — with no tools — and gets back one decision:
|
||||
`implement` / `revise` / `comment` / `decline`. It validates that decision, creates **one
|
||||
worker Job per assignment** from a committed template, and posts the PM's reply on the
|
||||
issue. Each **worker** (`pm-worker`) clones the repository, checks out the branch the PM
|
||||
named, re-reads the live issue thread, runs a full Claude Code session against `CLAUDE.md`,
|
||||
runs `./ci/gate.sh`, and only then commits, pushes, opens the pull request and reports back
|
||||
on the issue. Both halves are one image; neither takes a command-line argument.
|
||||
|
||||
---
|
||||
|
||||
## Decisions, and what was rejected
|
||||
|
||||
### 1. Poll the Gitea API; do not receive webhooks
|
||||
|
||||
Webhooks were the first design. Rejected for three reasons, in increasing order of weight:
|
||||
|
||||
- **Gitea's own SSRF guard.** Webhook targets are filtered by `[webhook]
|
||||
ALLOWED_HOST_LIST`, whose default posture excludes private and loopback addresses — and
|
||||
every in-cluster target is one. Receiving webhooks means widening a security setting on
|
||||
the git host so it can call into the cluster. *(Stated as the reason for rejection, not as
|
||||
a measured fact about this Gitea: nothing here queried that setting. Anyone reviving
|
||||
webhooks should measure it first.)*
|
||||
- **A missed delivery is gone.** A webhook that arrives during a pod restart or a Flux roll
|
||||
is lost, and the issue then sits unanswered forever with nothing in any log explaining
|
||||
why. A poll cannot miss an event because it does not consume events — it reads current
|
||||
state.
|
||||
- **It would need inbound reach.** A receiver means a Service, an ingress allow from the
|
||||
`gitea` namespace, and a shared secret to verify signatures. Polling needs none: this
|
||||
namespace takes **no ingress at all**, which is a materially smaller surface for a
|
||||
workload whose purpose is running model-authored code.
|
||||
|
||||
Cost: up to `PM_POLL_INTERVAL_SECONDS` (60s) of latency. For a home platform whose
|
||||
alternative is "a human gets to it this evening", that is not worth a security exception.
|
||||
|
||||
### 2. The issue thread is the state. There is no database
|
||||
|
||||
Rejected: a PVC holding a per-issue cursor, or a ConfigMap of last-seen comment IDs.
|
||||
|
||||
The trigger is *act iff the newest thing in this thread was not written by me*. Because the
|
||||
PM always answers in the thread, acting makes the thread quiet again until somebody else
|
||||
speaks. That gives three properties a cursor file does not have: it survives restart,
|
||||
reschedule and rebuild with **no recovery path to get wrong**; it is **readable** by the
|
||||
operator in the place they are already looking; and it creates **no R-STATE-03 obligation**,
|
||||
because there is no irreplaceable state to back up.
|
||||
|
||||
One consequence is load-bearing and easy to get backwards: **the Job is created before the
|
||||
comment is posted.** If the Job exists and the comment fails, the next cycle re-triggers,
|
||||
finds the Job active, and does not duplicate it — and the worker posts its own comment when
|
||||
it finishes. The other order would leave the PM's own comment as the newest in the thread
|
||||
with no work running, so the trigger would never fire again and the issue would be silently
|
||||
abandoned holding a promise.
|
||||
|
||||
A second consequence: exactly one replica, `strategy: Recreate`. Two dispatchers would both
|
||||
see the same untouched thread in the same window and both act on it — two decisions, two
|
||||
comments, two branches. `Recreate` extends that to rollouts, where the default
|
||||
`RollingUpdate` would briefly run two.
|
||||
|
||||
### 3. Two roles, and the PM holds no tools
|
||||
|
||||
Rejected: one agent that triages *and* implements.
|
||||
|
||||
The PM runs `claude --print --tools ""` against a JSON schema. It cannot run a command, read
|
||||
a file or reach the network; it returns one JSON object. The dispatcher then validates every
|
||||
field — the action against a fixed set, the branch against `CLAUDE.md` §4's pattern, `revise`
|
||||
against the branch that actually carries that issue's work, the brief against being empty —
|
||||
and refuses rather than repairing (a silently rewritten branch name is a branch the PM's own
|
||||
comment then misnames).
|
||||
|
||||
That gives three independent layers between a hostile issue comment and the cluster: the
|
||||
model holds no tools, the dispatcher accepts only a validated vocabulary, and admission
|
||||
constrains the one object it may create. Any one failing leaves two. It matters because the
|
||||
input is text written by whoever can reach the repository, and an issue saying "ignore your
|
||||
instructions and push to main" has to be ordinary data. **Nothing in this path executes
|
||||
text.**
|
||||
|
||||
### 4. Follow-ups re-read the thread; sessions are not resumed
|
||||
|
||||
Rejected: persisting each assignment's session and resuming it with `--resume` when a new
|
||||
comment arrives.
|
||||
|
||||
A `revise` worker checks out the existing branch, reads what is already on it
|
||||
(`git log`/`git diff` against the base) and re-reads the **live** thread, which may have
|
||||
moved on since any brief was written. That is stateless and idempotent: no transcript to
|
||||
store, nothing to go stale, and the context is always current. The cost is re-reading, which
|
||||
is cheap compared with a resumed session whose stored context disagrees with the thread.
|
||||
|
||||
### 5. The worker runs the real gate, in the real toolbox
|
||||
|
||||
`CLAUDE.md` §3 is explicit that only the pinned toolbox result counts — gating without
|
||||
`promtool` and the rest produces a false rc 3. So the `agent-runner` image is built **FROM**
|
||||
the dev toolbox rather than assembling a subset of the toolchain. The cost is size (~2 GiB
|
||||
for an image that needs neither ansible nor OpenTofu); the alternative costs drift, and the
|
||||
first time the two toolchains differed a worker's "gate green" would stop meaning the gate's
|
||||
green while still reading as a pass.
|
||||
|
||||
Two stages cannot run in-cluster, and the worker's acceptance rule names them exactly:
|
||||
|
||||
- `spec-immutability` — **the sibling spec repo has no git remote.** It exists only on the
|
||||
operator's workstation (measured: `git remote -v` in `../home-network-k3s` prints
|
||||
nothing), so no in-cluster process can check it out. `CLAUDE.md` §3 already names this skip
|
||||
as expected and tolerated. The worker also cannot *modify* the spec, because it is not
|
||||
there.
|
||||
- `gitea-settings` — needs the admin-scoped settings token, which a worker must not hold.
|
||||
|
||||
A worker accepts **rc 0**, or **rc 3 whose "Stages that did not run" line names nothing but
|
||||
those two**. Any other skip is a refusal. The commit happens **before** the gate, because
|
||||
`ci/touched-requirements.sh` derives its subset from `git diff BASE...HEAD` and the lint/L0
|
||||
scans enumerate `git ls-files` — gating a dirty tree examines almost none of the change and
|
||||
reports a cheerful PASS over an empty subset, which is the exact false green that script's
|
||||
header records. The commit is local to an ephemeral container and is discarded with the pod
|
||||
if the gate refuses it.
|
||||
|
||||
### 6. Subscription auth, and the two variables that must stay unset
|
||||
|
||||
The service authenticates with an Anthropic **subscription** (Pro/Max/Team/Enterprise) via
|
||||
`CLAUDE_CODE_OAUTH_TOKEN`, minted by `claude setup-token` — a **one-year** OAuth token that
|
||||
the command prints and saves nowhere.
|
||||
|
||||
Three facts that shape the implementation:
|
||||
|
||||
- **Credential precedence.** `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` both rank
|
||||
**above** `CLAUDE_CODE_OAUTH_TOKEN`. A stray or empty value in either silently takes over,
|
||||
and the failure looks like a model outage rather than a credential mix-up. The image sets
|
||||
neither; `pm-worker` unsets both before every session; the admission policy refuses a Job
|
||||
that tries to set them.
|
||||
- **Bare mode does not read it.** `--bare` ignores `CLAUDE_CODE_OAUTH_TOKEN`, so neither
|
||||
entrypoint may use that flag however much faster it starts.
|
||||
- **No expiry warning exists.** Claude Code's "login expires in 3 days" notice fires only
|
||||
for an interactive login, never for this variable. When it lapses every session fails at
|
||||
once. That is why the runbook carries the rotation procedure and asks for the mint date to
|
||||
be recorded.
|
||||
|
||||
Usage counts against the subscription seat's own limits, shared with every other use of that
|
||||
seat — which is the real reason `PM_MAX_ACTIVE_WORKERS` defaults to 2 rather than a RAM
|
||||
calculation alone.
|
||||
|
||||
### 7. The admission policy is the boundary; RBAC is only its necessary half
|
||||
|
||||
Kubernetes places **no `use` check** on a Pod's `serviceAccountName`, so "may create Jobs in
|
||||
this namespace" is "may run arbitrary code as the most privileged identity in it". The
|
||||
dispatcher creates Jobs. Answered twice over:
|
||||
|
||||
- **Nothing to escalate to.** `pm-agent-worker` is bound to no Role, and the Job template
|
||||
mounts no token (`automountServiceAccountToken: false`). The egress policy does not include
|
||||
the API server either, so `kubectl` — present because the gate's toolchain needs it — is
|
||||
inert.
|
||||
- **The shape is constrained at the API server.** `pm-agent-job-shape` is `Enforce` with
|
||||
`failurePolicy: Fail` (a boundary an overlay can downgrade to Audit is not a boundary), and
|
||||
it deliberately starts with all six rules the sibling policy needed four revisions to
|
||||
reach: the committed shape, exactly one container with no args, only `workspace`/`tmp`
|
||||
**emptyDir** volumes, no init or ephemeral containers, only the committed env **names**, no
|
||||
`envFrom`.
|
||||
|
||||
`platform/kyverno/tests/pm-agent/` proves each refusal offline — 28 assertions, all passing
|
||||
under the pinned CLI — including the two that a careless policy admits: a second container
|
||||
while `containers[0]` is perfectly compliant (a Kyverno array pattern is satisfied by one
|
||||
matching element), and a volume **named** `workspace` whose source is a `hostPath`.
|
||||
|
||||
### 8. Not a catalog ServiceDefinition
|
||||
|
||||
`schemas/servicedefinition.schema.json` has `additionalProperties: false` and no way to
|
||||
express a ServiceAccount, a Role, a `command`, a Job template or an admission policy. This
|
||||
service is mostly those things. It is therefore hand-authored under `platform/` with its own
|
||||
non-gating Flux Kustomization — the `crafty`/`headlamp`/`backup` pattern — rather than being
|
||||
forced through the renderer. The one catalog change it needs is an `inClusterClients` entry
|
||||
on `gitea`, so that Gitea's own default-deny admits the agent's namespace on tcp/3000; the
|
||||
golden was re-rendered with the canonical command.
|
||||
|
||||
---
|
||||
|
||||
## Threat model
|
||||
|
||||
The input is text that anyone who can reach the repository can write. The thing acting on it
|
||||
holds a git write token and can author code. So:
|
||||
|
||||
| concern | what answers it |
|
||||
|---|---|
|
||||
| a prompt-injected issue tells the PM to push to `main` | the PM holds no tools; `pm-worker` refuses `PM_BRANCH=main` before anything runs; branch protection rejects the push regardless |
|
||||
| …tells it to merge, or to post `/approve-override` | the service makes no merge call and no review call; the bot account is a non-admin collaborator, so the override would be rejected by `ci/approval-policy.sh` on live permission |
|
||||
| …tells it to exfiltrate a secret | egress is this Gitea and tcp/443 only; the worker's clone/push host is validated against an allowlist baked into the image; `CLAUDE.md` §6's no-plaintext-secrets rules are in both the persona and the worker rules |
|
||||
| …tells the dispatcher to create a privileged Job | the dispatcher only ever renders the committed template; admission re-checks it fail-closed |
|
||||
| the model returns a malformed or hostile decision | validated field by field, and refused rather than repaired |
|
||||
| a worker loops or wedges | `--max-turns`, `--max-budget-usd`, `activeDeadlineSeconds: 5400`, `backoffLimit: 0` |
|
||||
| a backlog stampede the moment credentials work | `PM_MAX_ACTIVE_WORKERS=2`, `PM_MAX_NEW_WORKERS_PER_CYCLE=1`, `PM_ISSUE_COOLDOWN_SECONDS=900`, and `PM_REQUIRE_LABEL` for a staged rollout |
|
||||
| an unreviewed change reaches `main` | it cannot: the gate is a required status check, `block_admin_merge_override: true`, and a sensitive-path PR needs a non-author review the bot cannot supply |
|
||||
| **the worker's own credentials** | **not defended.** The session shares the container and uid with the wrapper and can read its environment. Bounded by scope (one repository, write, no admin) and by egress, not by isolation |
|
||||
| **`world:443` is wider than one API host** | **not defended.** Narrowing needs `toFQDNs`, which needs Cilium's DNS proxy verified first. Tracked below |
|
||||
| **a `write` collaborator can merge** | **not defended by the credential.** No Gitea scope separates push-and-open-PR from merge. Bounded by the code making no merge call |
|
||||
|
||||
## Verified, and not
|
||||
|
||||
**Verified offline, with numbers:** `kyverno test platform/kyverno/tests/pm-agent` → 28/28
|
||||
under the pinned CLI (the first run was 27/28 and caught a real defect: the volume rule
|
||||
denied a valid `emptyDir: {}`); `check-jsonschema` on the changed ServiceDefinition; the
|
||||
canonical render reproduces `apps/wave-0/gitea.yaml` with the new policy in it; `bash -n`
|
||||
and `ast.parse` on the two entrypoints; `PM_SELFTEST=1 pm-dispatch` → 26 assertions, 0
|
||||
failures; `infra/agent/probe-validation.sh` → **17 refusals, 0 failures** against
|
||||
`pm-worker`'s input guards, including the three that matter most (a redirected git host, a
|
||||
reachable-but-wrong one, and `main` as the branch). **`./ci/gate.sh` → exit 3 (UNDETERMINED), with
|
||||
`gitea-settings` the one stage that did not run** — no `SETTINGS_API_TOKEN` on this
|
||||
workstation — and every other stage green, `spec-immutability` included. rc 3 is not a pass.
|
||||
The gate also refused an earlier revision of this change twice, both times correctly; see
|
||||
the handoff entry.
|
||||
|
||||
**Not verified — nothing has run.** The image has never been built (so the node/npm layer,
|
||||
the Claude Code install and the in-image verification are unexercised), never pushed, never
|
||||
pulled. No pod has started. No issue has been triaged, no Job created, no branch pushed, no
|
||||
pull request opened. The admission policy has not been tested against a live API server, the
|
||||
network policies have not been exercised, and neither credential exists. Every statement
|
||||
about runtime behaviour in this document is a design claim, not a measurement.
|
||||
|
||||
## Follow-ups, in the order they are worth doing
|
||||
|
||||
1. **Make the registry pullable, THEN build, push, pin.** Measured after the merge, and
|
||||
neither half is declared anywhere in this repository
|
||||
(`decisions/2026-10-05-pm-agent-registry-pull-prereq.md`):
|
||||
(i) the nodes resolved the registry hostname to the WAN IP and failed TLS against the
|
||||
router's `CN=%D` placeholder certificate — **diagnosed and fixed** by pinning
|
||||
`gitea.{{ k3s_base_domain }}` to the Gateway VIP in `node_baseline_hosts_pins`, the same
|
||||
pin the Keycloak issuer already carries, applied by
|
||||
`ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags hosts`;
|
||||
(ii) the pull is still unauthenticated and no `imagePullSecret` exists in the tree — with
|
||||
the credential decision now being local Gitea accounts with PATs, that means a
|
||||
`dockerconfigjson` for an account whose token carries `read:package`. Only then does the
|
||||
digest matter. **Superseded 2026-10-29:** the build-and-push is no longer an operator step.
|
||||
`.gitea/workflows/images.yaml` builds and verifies the image on rootless BuildKit
|
||||
(`platform/buildkit/`), publishes it on a push to `main`, and Renovate opens the one
|
||||
PR that replaces both placeholder digests (`decisions/2026-10-29-image-pipeline.md`). The
|
||||
same two blockers sit in front of `conformance-runner`, which shares the registry path and
|
||||
has never been pulled.
|
||||
2. **Create the credentials** per `pm-agent-credentials.sops.yaml.example`, and **verify the
|
||||
bot's permission is `write`, not `admin`** — that measurement is the control that keeps
|
||||
the agent out of its own approval gate.
|
||||
3. **First run with `PM_DRY_RUN=true` and `PM_REQUIRE_LABEL=agent`**, on one issue written
|
||||
for the purpose. Read what the PM says before letting it write anything.
|
||||
4. **Tighten the admission image pin** from the `@sha256:*` wildcard to the literal digest.
|
||||
One line, reviewed.
|
||||
5. **Narrow `world:443` to `toFQDNs: api.anthropic.com`** — after verifying Cilium's DNS
|
||||
proxy is enabled and that the model still answers through it.
|
||||
6. **Observability.** There is no metrics endpoint and no alert. The namespace's default-deny
|
||||
would drop a scrape from `monitoring`, so adding metrics means adding the port *and* its
|
||||
additive ingress allow — the lesson of
|
||||
`decisions/2026-10-04-oauth2-sidecar-metrics-scrape-netpol.md`. A useful first alert is
|
||||
"the dispatcher has not completed a cycle in 15 minutes", and note that a bare `up{}`
|
||||
makes an alert self-silencing: use `up == 1`.
|
||||
7. **A conformance test for the real boundary.** The L0 Kyverno suite proves the policy
|
||||
refuses; nothing proves the API server does. That is an L2 test, and it needs a cluster.
|
||||
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user