Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09265e0381 | ||
|
|
e65a76dc51 | ||
|
|
bc9c93d2ab | ||
|
|
6548f9a89c | ||
|
|
bfa39bc5d0 | ||
|
|
3ca4ad6011 | ||
|
|
708a05dfc1 | ||
|
|
161430d915 | ||
|
|
e7c4a95c2d | ||
|
|
0a0527e7db | ||
|
|
dc2d1ce17e | ||
|
|
6a8953e288 | ||
|
|
9739e9be91 | ||
|
|
d1a6628a97 | ||
|
|
f178d85c71 | ||
|
|
f21e89d9d2 | ||
|
|
2d7d302c7e | ||
|
|
c8c1d2935d | ||
|
|
9b8979a427 | ||
|
|
05baae90ec | ||
|
|
8e913fdd7b | ||
|
|
b19c5db9ae | ||
|
|
6e4d311545 | ||
|
|
4809199da6 | ||
|
|
0a733294ff | ||
|
|
cf33338448 | ||
|
|
47f02c6e06 | ||
|
|
49d82491dd | ||
|
|
5ea9c93f58 | ||
|
|
3a88abb958 | ||
|
|
086e9091c9 | ||
|
|
9f7052ab2b | ||
|
|
3c8f809aaf | ||
|
|
11b21f7347 | ||
|
|
cb69a53825 | ||
|
|
f1daa8cae0 | ||
|
|
380ea98a38 | ||
|
|
acae2ad36c | ||
|
|
fdc3265585 | ||
|
|
4cb5e7a8d4 | ||
|
|
57d4df8c4f | ||
|
|
14494e1947 | ||
|
|
e9a4b669d4 | ||
|
|
aa0a50a143 | ||
|
|
22174186ff | ||
|
|
23aa2854f1 | ||
|
|
63f98aab26 | ||
|
|
62968c9ee9 | ||
|
|
645994e3a3 | ||
|
|
ac9f20034c | ||
|
|
4c4f5355d9 | ||
|
|
63002d19c8 | ||
|
|
6b4da27f84 | ||
|
|
666a60776a | ||
|
|
fdc4a7d9f4 | ||
|
|
a344b046c9 | ||
|
|
8a2be824f6 | ||
|
|
3484955903 | ||
|
|
b8602c939c | ||
|
|
dce0e8ffe9 |
No files matched your search
@@ -1496,3 +1496,354 @@ thing that ever closes a requirement.
|
|||||||
chart default rather than set explicitly — see the follow-up in `platform/longhorn/`.
|
chart default rather than set explicitly — see the follow-up in `platform/longhorn/`.
|
||||||
- L2/L3 conformance still SKIPs in CI (`docs/plans/l2-l3-conformance-ci-wiring.md`); it needs
|
- L2/L3 conformance still SKIPs in CI (`docs/plans/l2-l3-conformance-ci-wiring.md`); it needs
|
||||||
the trigger-credential decision — a scoped in-cluster SA, never a broad kubeconfig in CI.
|
the trigger-credential decision — a scoped in-cluster SA, never a broad kubeconfig in CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-01 — INCIDENT: merging PR #4 briefly broke Cilium on 2/3 dev nodes. **Root cause: a substitution race that fails OPEN.**
|
||||||
|
|
||||||
|
Merging the R-ENV-02 tokenization (PR #4, `dce0e8f`) triggered a Cilium Helm upgrade that put
|
||||||
|
`cilium` agents into `Init:Error` on `k3s-dev-1` and `k3s-dev-2`. It **self-healed in ~6 minutes**
|
||||||
|
and the cluster is fully green (12/12 Kustomizations Ready, 0 HelmReleases unready, 0 pods down).
|
||||||
|
Recording it because the mechanism is systemic, will recur, and is far more dangerous on a cold
|
||||||
|
prod bootstrap than it was here.
|
||||||
|
|
||||||
|
### What happened
|
||||||
|
|
||||||
|
The HelmRelease config digest tells the whole story:
|
||||||
|
|
||||||
|
```
|
||||||
|
v8 05:49:39 digest e83bf695… <- before
|
||||||
|
v9 05:50:11 digest eebd6c88… <- the bad render
|
||||||
|
v10 05:55:23 digest e83bf695… <- identical to v8 again
|
||||||
|
```
|
||||||
|
|
||||||
|
The config changed and then changed **back to byte-identical**. Nothing in git differed
|
||||||
|
semantically — `git diff` over `platform/cilium/helmrelease.yaml` shows only comments plus
|
||||||
|
`k8sServiceHost: "192.168.1.50"` → `k8sServiceHost: "${K3S_API_HOST}"`, and the dev overlay sets
|
||||||
|
`K3S_API_HOST=192.168.1.50`. The pre-merge byte-identity verification was **correct**; the steady
|
||||||
|
state is identical. The damage happened in a transient intermediate state.
|
||||||
|
|
||||||
|
### Root cause — the var and its consumer are reconciled by DIFFERENT Kustomizations
|
||||||
|
|
||||||
|
- `cluster-vars` (which *defines* `K3S_API_HOST`) is owned by the **`flux-system`** Kustomization.
|
||||||
|
- `platform/cilium/helmrelease.yaml` (which *references* it) is owned by the **`platform`**
|
||||||
|
Kustomization, via `postBuild.substituteFrom: cluster-vars`.
|
||||||
|
- There is **no dependency ordering between defining a variable and using it.**
|
||||||
|
|
||||||
|
So there is a window where `platform` renders the NEW manifest against the OLD `cluster-vars`,
|
||||||
|
which has no `K3S_API_HOST` key yet. **Flux's post-build substitution is non-strict by default: an
|
||||||
|
undefined variable becomes the EMPTY STRING, silently.** That produced `k8sServiceHost: ""`, Helm
|
||||||
|
upgraded Cilium with it, and the agents fell back to the in-cluster ClusterIP `10.43.0.1:443` —
|
||||||
|
which only the Cilium agent itself can translate. The agent was the thing restarting, so its own
|
||||||
|
`config` init container could not reach the API server:
|
||||||
|
|
||||||
|
```
|
||||||
|
level=error msg="Unable to contact k8s api-server" ipAddr=https://10.43.0.1:443
|
||||||
|
error="dial tcp 10.43.0.1:443: i/o timeout"
|
||||||
|
level=fatal msg="Build config failed"
|
||||||
|
```
|
||||||
|
|
||||||
|
Once `flux-system` applied the updated `cluster-vars`, the next `platform` reconcile rendered the
|
||||||
|
correct value, digest returned to `e83bf695…`, and all three agents came up.
|
||||||
|
|
||||||
|
`optional: false` on the `substituteFrom` entry does **not** protect against this — it only requires
|
||||||
|
the ConfigMap to *exist*, not that every referenced key is defined.
|
||||||
|
|
||||||
|
### Why this matters much more for prod
|
||||||
|
|
||||||
|
On dev, one Cilium agent (`k3s-dev-3`) stayed up throughout, the API server was reachable, and the
|
||||||
|
race closed on its own. **On a cold prod bootstrap every one of these knobs is new simultaneously**,
|
||||||
|
there is no already-running CNI to fall back on, and an empty `k8sServiceHost` means Cilium may
|
||||||
|
never come up at all — with no healthy peer to recover from. This is a plausible
|
||||||
|
bring-up-blocker for `clusters/prod`.
|
||||||
|
|
||||||
|
### The fix — make substitution FAIL CLOSED
|
||||||
|
|
||||||
|
`kustomize-controller` currently runs with **no** `--feature-gates` argument, i.e. non-strict
|
||||||
|
substitution. Flux supports `StrictPostBuildSubstitutions`, which makes an **undefined variable fail
|
||||||
|
the Kustomization** instead of silently emptying it. That is exactly this project's stated doctrine
|
||||||
|
("if unsure, deny/close"; "never fake green") applied to the substitution layer, and it converts
|
||||||
|
this entire bug class from *silently corrupt a critical value* into *a loud, blocking reconcile
|
||||||
|
failure*.
|
||||||
|
|
||||||
|
**Not enabled unilaterally** — turning it on cluster-wide would immediately fail any Kustomization
|
||||||
|
that references a variable not defined in `cluster-vars`, including legitimate shell `${…}` in
|
||||||
|
embedded scripts. The safe sequence, before flipping it:
|
||||||
|
1. Enumerate every `${VAR}` in every substituted path and prove each is either defined in
|
||||||
|
`cluster-vars` or correctly escaped as `$${VAR}`.
|
||||||
|
2. Then enable the gate, and let CI + a dev reconcile prove it.
|
||||||
|
|
||||||
|
Interim discipline, independent of the gate: **add a `cluster-vars` knob in one commit and
|
||||||
|
reference it in a later one**, so the definition is always reconciled before its first use.
|
||||||
|
|
||||||
|
### Operational note worth keeping
|
||||||
|
|
||||||
|
A Cilium rolling upgrade on this cluster is not risk-free even when the values are right: the
|
||||||
|
`config` init container resolves the API server through the ClusterIP unless `k8sServiceHost` is
|
||||||
|
set, and the agent that translates that ClusterIP is the one being replaced. Keep
|
||||||
|
`k8sServiceHost`/`k8sServicePort` correct at all times, and roll Cilium one node at a time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-01 — RESEARCH: Flux substitution + HelmRelease best practice. **Measured semantics + ranked plan.**
|
||||||
|
|
||||||
|
Three parallel research passes (substitution/ordering, HelmRelease/CNI, and a full repo audit),
|
||||||
|
prompted by the incident above. Two of my own earlier conclusions were WRONG and are corrected
|
||||||
|
here. Everything below marked *measured* was run against flux 2.4.0 / the live cluster; everything
|
||||||
|
else is cited inference.
|
||||||
|
|
||||||
|
### THE SUBSTITUTION SEMANTICS TABLE (measured — trust this over prose elsewhere)
|
||||||
|
|
||||||
|
| form | behaviour |
|
||||||
|
|---|---|
|
||||||
|
| `${DEFINED}` | substituted |
|
||||||
|
| `${UNDEFINED}` | **silently empty** (non-strict) · fails loudly (strict) |
|
||||||
|
| `${UNDEFINED:-d}` | **default applied at reconcile time — `--strict` does NOT catch it** |
|
||||||
|
| `${VAR:?msg}` | renders **the message as the value** — a non-empty WRONG value. ANTI-PATTERN. |
|
||||||
|
| `${VAR:=192.168.1.50}` | silently ships the dev value to prod. ANTI-PATTERN. |
|
||||||
|
| `$${VAR}` / `$${VAR:-d}` / `$${ARR[0]}` | literal — **escaping is TOTAL** |
|
||||||
|
| `$VAR` / `$(cmd)` | untouched, no escaping needed |
|
||||||
|
| `${...}` / `${a b}` / `${ARR[0]}` unescaped | **hard parse error — fails the whole Kustomization** |
|
||||||
|
|
||||||
|
Flux exempts default-providing operators (`-`, `:-`, `+`, `:+`, `=`, `:=`, `?`, `:?`) from strict
|
||||||
|
mode by design ("they intentionally handle the unset case"). **That is why strict mode would never
|
||||||
|
have caught the gitea/nextcloud dead-guard bug**, and why a targeted check was required.
|
||||||
|
|
||||||
|
**Doctrine corrected:** `clusters/base/apps-vms.yaml` claimed an indexed expansion is fatal
|
||||||
|
"escaped or not". Measured: escaping is total; only the UNESCAPED form is fatal. Fixed in place.
|
||||||
|
|
||||||
|
**Comment nuance:** `kustomize build` strips *structural* YAML comments before envsubst sees them,
|
||||||
|
so those are usually harmless. But `#` lines INSIDE a block scalar are data and DO reach envsubst,
|
||||||
|
and a `$`-carrying body comment was observed surviving into built `apps/wave-1`. Do not rely on
|
||||||
|
stripping — escape in comments too. (An earlier claim in this log that a YAML comment "nearly broke
|
||||||
|
every merge" was overstated for the structural case; the hazard is real for block-scalar bodies.)
|
||||||
|
|
||||||
|
### CORRECTION 1 (mine) — `StrictPostBuildSubstitutions` alone is INSUFFICIENT
|
||||||
|
|
||||||
|
It fixes nothing about ordering; it converts "render a corrupt value and ship it" into "fail loudly
|
||||||
|
and keep last-good". **The actual ordering fix is one line**, verified against
|
||||||
|
`kustomization_controller.go :: checkDependencies` at our exact version (v1.4.0): `dependsOn` is
|
||||||
|
**revision-gated**, not merely Ready-gated — when the dependency shares a GitRepository, the
|
||||||
|
dependent cannot reconcile revision N until the dependency has APPLIED revision N.
|
||||||
|
|
||||||
|
`platform` is the only Kustomization in the graph with no `dependsOn`; every other consumer chains
|
||||||
|
transitively through it. So:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# clusters/base/platform.yaml
|
||||||
|
spec:
|
||||||
|
dependsOn:
|
||||||
|
- name: flux-system # cluster-vars must be applied at THIS revision first
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cost, recorded honestly:** the root becomes a gate — a malformed manifest anywhere under
|
||||||
|
`clusters/dev` now stops the whole graph where today it would not. Fail-closed, consistent with
|
||||||
|
doctrine, but a real blast-radius change. No deadlock risk: the root runs `wait: false`.
|
||||||
|
|
||||||
|
Rejected alternative: moving `cluster-vars` into the consuming Kustomization is **strictly worse** —
|
||||||
|
`SubstituteVariables` reads the ConfigMap from the API at build time, *before* the Kustomization
|
||||||
|
applies its own resources, so it would always read the PREVIOUS value. That converts an occasional
|
||||||
|
race into a permanent one-reconcile lag.
|
||||||
|
|
||||||
|
### CORRECTION 2 (an agent's) — the safety machinery DID fire
|
||||||
|
|
||||||
|
A research pass concluded that Helm considered the bad release healthy (because
|
||||||
|
`updateStrategy.rollingUpdate.maxUnavailable: 2` on 3 nodes means `expectedReady = 1`), and
|
||||||
|
therefore remediation never engaged. **Measured on the live cluster: that is wrong.** The Helm
|
||||||
|
release secrets show **v9 = `failed`**; Helm's wait DID fail and the release was remediated, which
|
||||||
|
is why it self-healed. `maxUnavailable: 2 of desired 3` IS confirmed live — so the real harm was
|
||||||
|
blast radius (2 of 3 agents down at once), not a missed detection.
|
||||||
|
|
||||||
|
### Verified, and worth knowing
|
||||||
|
|
||||||
|
- **Flux 2.4.0 is OUT OF SUPPORT** — the project supports the last three minors; 2.9.x is current.
|
||||||
|
- **Strict substitution became DEFAULT-ON** in kustomize-controller v1.9.0. So upgrading Flux flips
|
||||||
|
this behaviour whether or not we opt in — the variable audit is a prerequisite for the upgrade
|
||||||
|
regardless of today's decision.
|
||||||
|
- **Cilium's `values.schema.json` permits `k8sServiceHost: ""`** — no `minLength`, no `pattern`.
|
||||||
|
Helm was never going to reject the bad render.
|
||||||
|
- **Cilium docs make `k8sServiceHost` REQUIRED under kube-proxy replacement**, precisely because the
|
||||||
|
environment exports the API ClusterIP and there is no kube-proxy to provision it.
|
||||||
|
- **The Cilium chart ships zero test templates**, so `spec.test` is useless for it. A post-upgrade
|
||||||
|
check must live outside Helm.
|
||||||
|
- `valuesFrom` fails HARD on a missing key (verified in `chartutil/values.go`) unlike postBuild's
|
||||||
|
silent empty — but only on a MISSING key, not an empty value, and it cannot cross namespaces
|
||||||
|
(`cluster-vars` is in `flux-system`; the Cilium HelmRelease is in `kube-system`).
|
||||||
|
- **Do NOT enable `driftDetection.mode: enabled` on a CNI.** It compares the cluster against the
|
||||||
|
Helm storage manifest — during the incident that manifest WAS the bad render, so drift correction
|
||||||
|
would have actively re-asserted the broken value.
|
||||||
|
|
||||||
|
### RANKED PLAN
|
||||||
|
|
||||||
|
| P | Action | State |
|
||||||
|
|---|---|---|
|
||||||
|
| **P0** | `platform.dependsOn: [flux-system]` — closes the race completely | **PENDING — operator call** (blast-radius change) |
|
||||||
|
| **P0** | Escape the dead OIDC guards; renderer now refuses the class | ✅ DONE |
|
||||||
|
| **P1** | Substitution-safety L0 gate, unconditional in `ci/l0.sh` | ✅ DONE — 102 assertions |
|
||||||
|
| **P1** | Enable `StrictPostBuildSubstitutions=true` | **READY — proven safe**, all 11 paths exit 0 under `flux envsubst --strict`. Sequence AFTER P0. |
|
||||||
|
| **P2** | `updateStrategy.rollingUpdate.maxUnavailable: 1` on Cilium, as a new **[parity]** field in BOTH the HelmRelease and the Ansible j2 | PENDING — bounds blast radius to one node |
|
||||||
|
| **P3** | Split Cilium into its own Kustomization (`wait: true`, `interval: 1h`, `timeout: 10m`); `platform` dependsOn it | PENDING — gives `flux suspend kustomization cilium` as an emergency valve |
|
||||||
|
| **P4** | Upgrade Flux 2.4.0 → 2.9.x | PENDING — out of support; brings strict-by-default |
|
||||||
|
| **P5** | Cilium MINOR bumps become manual (Renovate PR → human runs the documented pre-flight → merge). Never automerge a CNI minor. | PENDING — process |
|
||||||
|
| — | Record `${VAR:?}` / `${VAR:=}` as anti-patterns | ✅ DONE (table above) |
|
||||||
|
| — | *Not recommended*: drift detection on Cilium; `spec.test` on Cilium; `k8sServiceHost: auto` | — |
|
||||||
|
|
||||||
|
### Follow-ups surfaced, not yet actioned
|
||||||
|
- 6 dead `cluster-vars` knobs; `BASE_DOMAIN` and `CLUSTER_ENV` look like genuine rot.
|
||||||
|
- `platform/{cilium,longhorn,velero,oauth2-proxy}/values.yaml` reference mirrors carry decorative
|
||||||
|
`${...}` that can silently drift from the real HelmRelease.
|
||||||
|
- `platform` Kustomization `timeout: 5m` vs Longhorn HelmRelease `timeout: 15m` — the tier can time
|
||||||
|
out while a legitimate Longhorn upgrade is still running.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-09-01 — P0/P1/P2 LANDED + a second incident: two bugs stacked, the outer one hid the inner
|
||||||
|
|
||||||
|
All three approved changes are merged (PR #5) and reconciled on dev, plus a hotfix (PR #6).
|
||||||
|
|
||||||
|
### Landed and verified on the live cluster
|
||||||
|
|
||||||
|
| Change | Verified |
|
||||||
|
|---|---|
|
||||||
|
| **P0** `platform.dependsOn: [flux-system]` | Flux on `63002d1`; all 12 Kustomizations Ready; steady-state graph unchanged |
|
||||||
|
| **P1** strict substitution | Committed to `bootstrap/flux/components/`. **NOT YET ROLLED OUT** — see below |
|
||||||
|
| **P2** Cilium `maxUnavailable: 1` | Applied. **Pod ages unchanged (15h/39h), restart counts unchanged** — the clean proof nothing rolled, exactly as predicted |
|
||||||
|
| OIDC guard fix | Deployed guards now read `${..._OIDC_CLIENT_SECRET:-}` instead of `""`; both providers registered |
|
||||||
|
| Prod overlay | SOPS decryption (same age key as dev) + source on `main` |
|
||||||
|
|
||||||
|
### THE SECOND INCIDENT — nextcloud CrashLoopBackOff
|
||||||
|
|
||||||
|
Immediately after the PR #5 reconcile, `nextcloud` went `CrashLoopBackOff` (7 restarts,
|
||||||
|
`FailedPostStartHook`) and `apps-wave-1`/`apps-wave-2` stalled. Cause:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[ "$p" -ge 15 ] && nextcloud_oidc_fail "could not promote ... after retries" # LAST line of the hook
|
||||||
|
```
|
||||||
|
|
||||||
|
On the **success** path (`p < 15`) the test returns 1, and as the script's last command that became
|
||||||
|
the hook's exit status. **A non-zero postStart exit makes the kubelet kill the container.**
|
||||||
|
|
||||||
|
**The previous commit did not introduce this — it UNCOVERED it.** The bug was unreachable for as
|
||||||
|
long as the OIDC guard above it was dead code that `exit 0`'d first. Fixing the guard made the block
|
||||||
|
execute for the first time in these apps' deployed lifetime, and the latent defect fired instantly.
|
||||||
|
**Two bugs stacked, and the outer one hid the inner one.** Worth remembering as a pattern: fixing
|
||||||
|
dead code is not a safe no-op — it is the first execution of never-tested code, and should be
|
||||||
|
treated with the caution of new code, not of a one-character escape.
|
||||||
|
|
||||||
|
Fixed by converting to a plain `if` and adding an explicit `exit 0`. Gitea's hook already returned 0
|
||||||
|
on both paths, but only by accident of which statement happened to be last — made explicit there too
|
||||||
|
so the class cannot return. Recovery confirmed: both pods `1/1 Running`, 0 restarts, all
|
||||||
|
Kustomizations Ready, both OIDC providers still registered.
|
||||||
|
|
||||||
|
### STILL TO DO — strict substitution is committed but NOT ACTIVE
|
||||||
|
|
||||||
|
`StrictPostBuildSubstitutions` does **not** arrive on a git push. This repo does not use
|
||||||
|
`flux bootstrap`; the controllers were installed imperatively by `flux install` and carry no Flux
|
||||||
|
ownership labels, so they are outside Flux's inventory and nothing under `clusters/**` can reach a
|
||||||
|
controller flag. An operator must run `bootstrap/flux/bootstrap.sh` step 1 (idempotent; the
|
||||||
|
standalone command is in `bootstrap/flux/README.md`), which restarts kustomize-controller for
|
||||||
|
~10-20s. Flux is level-triggered, so an interrupted reconcile is simply retried.
|
||||||
|
|
||||||
|
**Ordering is satisfied:** P0 is merged and reconciled, so the gate can now be rolled out safely.
|
||||||
|
With the gate on but the ordering edge absent, the same race would turn a transient empty render
|
||||||
|
into a transient hard Kustomization failure.
|
||||||
|
|
||||||
|
### Prod consequences recorded this session
|
||||||
|
|
||||||
|
- **Every merge to `main` will reconcile BOTH clusters** once prod is live — no promotion step, no
|
||||||
|
soak window. A deliberate consequence of dev being temporary, but it removes the "prove it on dev
|
||||||
|
first" safety net that the last two days have leaned on heavily, twice.
|
||||||
|
- **Gitea migration is the FIRST task of the dev decommission, not the last.** Prod's GitOps source
|
||||||
|
currently points at Gitea running on the dev cluster.
|
||||||
|
- **Prod's source URL cannot work on a cold prod cluster** — split-horizon answers the whole zone
|
||||||
|
with `${GATEWAY_VIP}`, so prod would resolve `gitea.home.keithjackson.dev` to its own gateway.
|
||||||
|
Bring-up needs a temporary source and a cutover, as `docs/05` describes for dev's own Phase-6.
|
||||||
|
|
||||||
|
## 2026-09-07 — LIVE BUG: prod's OIDC clients were pointed at DEV's Keycloak. Secrets are a substitution blind spot.
|
||||||
|
|
||||||
|
The operator could not log in to Headlamp on prod:
|
||||||
|
|
||||||
|
```
|
||||||
|
Get "https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration":
|
||||||
|
dial tcp 192.168.1.53:443: i/o timeout
|
||||||
|
```
|
||||||
|
|
||||||
|
`keycloak.home.keithjackson.dev` is **dev's** Keycloak. It resolves to `192.168.1.53`, on the dev
|
||||||
|
LAN behind pfSense; prod lives on `192.168.2.0/24` (pfSense's WAN) and cannot route there, so the
|
||||||
|
discovery fetch simply hung until it timed out.
|
||||||
|
|
||||||
|
### Root cause — the class, not the instance
|
||||||
|
|
||||||
|
**Flux's `${BASE_DOMAIN}` postBuild substitution rewrites the RENDERED MANIFEST TEXT. It can never
|
||||||
|
reach inside a SOPS-encrypted Secret.** Environment-specific URLs had been stored *inside* two
|
||||||
|
encrypted Secrets purely because it was convenient to keep one blob per app:
|
||||||
|
|
||||||
|
| File | Buried, un-substitutable values |
|
||||||
|
|---|---|
|
||||||
|
| `platform/headlamp/headlamp-oidc-credentials.sops.yaml` | `OIDC_ISSUER_URL`, `OIDC_CALLBACK_URL` |
|
||||||
|
| `apps/wave-1/semaphore-bootstrap-secret.sops.yaml` (`oidc-providers-json`) | the realm issuer + the OIDC redirect URI |
|
||||||
|
|
||||||
|
Both were frozen at the apex `home.keithjackson.dev`. **Dev IS the apex, so dev was correct by
|
||||||
|
coincidence** — the values were never per-environment, they just happened to match one environment.
|
||||||
|
Prod was wrong from the first reconcile. Semaphore on prod was broken the same way and nobody had
|
||||||
|
noticed, because nobody had tried prod SSO yet.
|
||||||
|
|
||||||
|
This is the same failure shape as the 2026-09-01 nextcloud guard: **a value that silently resolves
|
||||||
|
to something plausible is worse than one that fails.** `StrictPostBuildSubstitutions` cannot catch
|
||||||
|
it either — there is no `${...}` left unresolved to complain about; the ciphertext never contained
|
||||||
|
one.
|
||||||
|
|
||||||
|
### The rule this establishes
|
||||||
|
|
||||||
|
> **Non-secret, environment-specific values MUST NOT live inside an encrypted Secret.** Issuer URLs,
|
||||||
|
> callback URLs, redirect URIs and hostnames are public. Encrypt the secret half only, and template
|
||||||
|
> the rest where `${BASE_DOMAIN}` can reach it.
|
||||||
|
|
||||||
|
### The fix
|
||||||
|
|
||||||
|
- **Headlamp** — `OIDC_ISSUER_URL` / `OIDC_CALLBACK_URL` moved to the HelmRelease's
|
||||||
|
`spec.values.env`, templated `${BASE_DOMAIN}`. The Secret keeps `OIDC_CLIENT_SECRET` +
|
||||||
|
the environment-invariant `OIDC_CLIENT_ID` / `OIDC_SCOPES`, still `envFrom`'d.
|
||||||
|
**Exactly one source per variable** — an explicit `env:` entry silently outranks the same name
|
||||||
|
arriving via `envFrom`, so the two sets deliberately do not overlap.
|
||||||
|
Side effect, intended: naming `OIDC_CALLBACK_URL` in `env:` makes chart 0.41.0 finally emit
|
||||||
|
`-oidc-callback-url=$(OIDC_CALLBACK_URL)`; it gates that arg on `env:`/`config.oidc`, which a
|
||||||
|
Secret key alone never tripped — so the Secret's callback URL had been dead weight all along.
|
||||||
|
- **Semaphore** — the `SEMAPHORE_OIDC_PROVIDERS` JSON is now a plaintext literal in
|
||||||
|
`catalog/semaphore.yaml` (re-rendered to the `apps/wave-1` golden). Only the client secret stays
|
||||||
|
encrypted, in its own key `oidc-client-secret`, and the **kubelet** splices it back via
|
||||||
|
dependent-env-var expansion: `$(SEMAPHORE_OIDC_CLIENT_SECRET)` resolves against the `secretKeyRef`
|
||||||
|
env var declared immediately above it. **Order is load-bearing** — the kubelet resolves `env` in
|
||||||
|
list order and leaves a forward reference as literal text. `flux envsubst` ignores `$(...)`, so
|
||||||
|
the two expansion layers do not collide (the `$${VAR}` escape in `apps/wave-1/nextcloud.yaml`
|
||||||
|
documents the one form that DOES need escaping).
|
||||||
|
|
||||||
|
### Proof
|
||||||
|
|
||||||
|
Rendered per overlay (`kubectl kustomize --load-restrictor LoadRestrictionsNone`, then
|
||||||
|
`flux envsubst --strict` with that overlay's `cluster-vars`), plus `helm template` of headlamp
|
||||||
|
0.41.0 against the substituted values:
|
||||||
|
|
||||||
|
| | dev | prod |
|
||||||
|
|---|---|---|
|
||||||
|
| Headlamp `OIDC_ISSUER_URL` | `https://keycloak.home.keithjackson.dev/realms/homelab` | `https://keycloak.prod.home.keithjackson.dev/realms/homelab` |
|
||||||
|
| Headlamp `OIDC_CALLBACK_URL` | `https://headlamp.home.keithjackson.dev/oidc-callback` | `https://headlamp.prod.home.keithjackson.dev/oidc-callback` |
|
||||||
|
| Semaphore `provider_url` | `https://keycloak.home.keithjackson.dev/realms/homelab` | `https://keycloak.prod.home.keithjackson.dev/realms/homelab` |
|
||||||
|
| Semaphore `redirect_url` | `https://semaphore.home.keithjackson.dev/api/...` | `https://semaphore.prod.home.keithjackson.dev/api/...` |
|
||||||
|
|
||||||
|
Dev's values are byte-identical to what it runs today — this change moves prod only.
|
||||||
|
|
||||||
|
### [parity] the apiserver invariant HOLDS
|
||||||
|
|
||||||
|
Prod's kube-apiserver `oidc-issuer-url` is an **install-time k3s flag** derived from
|
||||||
|
`k3s_base_domain` (`infra/ansible/inventory/group_vars/k3s_prod/vars.yml` = `prod.home.keithjackson.dev`),
|
||||||
|
not from `cluster-vars` — it cannot read the Flux variable. Headlamp forwards the user's `id_token`
|
||||||
|
to the apiserver, so the two must name the same issuer. After this change they do, in both
|
||||||
|
environments, because both derive from the same per-environment domain. Unchanged risk, now written
|
||||||
|
down in `platform/headlamp/README.md`: moving one without the other 401s every request.
|
||||||
|
|
||||||
|
### Also fixed for the next person
|
||||||
|
|
||||||
|
`headlamp-oidc-credentials.sops.yaml.example` used to *teach* the broken pattern — it listed
|
||||||
|
`OIDC_ISSUER_URL` and `OIDC_CALLBACK_URL` as keys to fill in. It now says, in the file, that adding
|
||||||
|
either key back re-breaks prod.
|
||||||
@@ -92,14 +92,17 @@ spec:
|
|||||||
# NOTE: apps-vms NOW carries postBuild.substituteFrom cluster-vars (needed for
|
# NOTE: apps-vms NOW carries postBuild.substituteFrom cluster-vars (needed for
|
||||||
# ${UNIFI_L4_VIP} in 50-service-l4.yaml), so Flux envsubst DOES run over this userData.
|
# ${UNIFI_L4_VIP} in 50-service-l4.yaml), so Flux envsubst DOES run over this userData.
|
||||||
# Every shell brace-expansion below is therefore escaped as $${...}, which envsubst emits
|
# Every shell brace-expansion below is therefore escaped as $${...}, which envsubst emits
|
||||||
# as a literal ${...} — verified with `flux envsubst`. Bare $VAR and $(...) are left alone
|
# as a literal $${...} — verified with `flux envsubst`. Bare $VAR and $(...) are left alone
|
||||||
# by envsubst and need no escaping. NEVER write an unescaped ${...} here, and never write
|
# by envsubst and need no escaping. NEVER write an unescaped $${...} here, and never write
|
||||||
# ${ARR[0]} even escaped-adjacent: envsubst hard-fails on `[` ("missing closing brace")
|
# an INDEXED expansion unescaped: envsubst hard-fails on `[` ("missing closing brace")
|
||||||
# and would break the whole apps-vms apply.
|
# and would break the whole apps-vms apply. CORRECTION (2026-09-01, measured against
|
||||||
|
# flux 2.4.0 `envsubst --strict`): escaping IS total — $${ARR[0]} emits the literal
|
||||||
|
# text safely. Only the UNESCAPED indexed form is fatal. The earlier "escaped or not"
|
||||||
|
# wording in clusters/base/apps-vms.yaml overstated it.
|
||||||
userData: |-
|
userData: |-
|
||||||
#cloud-config
|
#cloud-config
|
||||||
hostname: unifi
|
hostname: unifi
|
||||||
fqdn: unifi.home.keithjackson.dev
|
fqdn: unifi.${BASE_DOMAIN}
|
||||||
package_update: true
|
package_update: true
|
||||||
package_upgrade: false
|
package_upgrade: false
|
||||||
packages: [qemu-guest-agent, podman, uidmap, slirp4netns, ca-certificates, curl, jq, cloud-guest-utils]
|
packages: [qemu-guest-agent, podman, uidmap, slirp4netns, ca-certificates, curl, jq, cloud-guest-utils]
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
# VIP: ${UNIFI_L4_VIP} (cluster-vars, R-ENV-02; dev 192.168.1.54, confirmed free, inside
|
# VIP: ${UNIFI_L4_VIP} (cluster-vars, R-ENV-02; dev 192.168.1.54, confirmed free, inside
|
||||||
# default-pool and reserved in tofu — the Gateway already holds the pool's first IP, dev .53).
|
# default-pool and reserved in tofu — the Gateway already holds the pool's first IP, dev .53).
|
||||||
# Substituted by the `apps-vms` Flux Kustomization, which carries postBuild.substituteFrom for
|
# Substituted by the `apps-vms` Flux Kustomization, which carries postBuild.substituteFrom for
|
||||||
# exactly this token; every shell ${...} in 30-virtualmachine.yaml's cloud-init is escaped as $${...}
|
# exactly this token; every shell $${...} in 30-virtualmachine.yaml's cloud-init is escaped as $${...}
|
||||||
# accordingly. A new environment MUST set UNIFI_L4_VIP to a free address in ITS pool.
|
# accordingly. A new environment MUST set UNIFI_L4_VIP to a free address in ITS pool.
|
||||||
#
|
#
|
||||||
# REUSABLE PATTERN (the deferred `minecraft` :25565/L4 problem is the same shape): expose an
|
# REUSABLE PATTERN (the deferred `minecraft` :25565/L4 problem is the same shape): expose an
|
||||||
@@ -46,7 +46,7 @@ metadata:
|
|||||||
lbipam.cilium.io/ips: "${UNIFI_L4_VIP}"
|
lbipam.cilium.io/ips: "${UNIFI_L4_VIP}"
|
||||||
# external-dns (source: service) publishes A unifi.home.keithjackson.dev -> the pinned VIP.
|
# external-dns (source: service) publishes A unifi.home.keithjackson.dev -> the pinned VIP.
|
||||||
# This is the browser + device target; UI is https://unifi.home.keithjackson.dev:11443.
|
# This is the browser + device target; UI is https://unifi.home.keithjackson.dev:11443.
|
||||||
external-dns.alpha.kubernetes.io/hostname: "unifi.home.keithjackson.dev"
|
external-dns.alpha.kubernetes.io/hostname: "unifi.${BASE_DOMAIN}"
|
||||||
spec:
|
spec:
|
||||||
type: LoadBalancer
|
type: LoadBalancer
|
||||||
externalTrafficPolicy: Local
|
externalTrafficPolicy: Local
|
||||||
|
|||||||
@@ -90,9 +90,9 @@ spec:
|
|||||||
name: gitea-bootstrap
|
name: gitea-bootstrap
|
||||||
key: secret-key
|
key: secret-key
|
||||||
- name: GITEA__server__DOMAIN
|
- name: GITEA__server__DOMAIN
|
||||||
value: gitea.home.keithjackson.dev
|
value: gitea.${BASE_DOMAIN}
|
||||||
- name: GITEA__server__ROOT_URL
|
- name: GITEA__server__ROOT_URL
|
||||||
value: https://gitea.home.keithjackson.dev/
|
value: https://gitea.${BASE_DOMAIN}/
|
||||||
- name: GITEA__actions__ENABLED
|
- name: GITEA__actions__ENABLED
|
||||||
value: 'true'
|
value: 'true'
|
||||||
- name: GITEA__actions__DEFAULT_ACTIONS_URL
|
- name: GITEA__actions__DEFAULT_ACTIONS_URL
|
||||||
@@ -127,8 +127,8 @@ spec:
|
|||||||
command:
|
command:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
- "# Idempotent break-glass admin bootstrap. Runs alongside the gitea entrypoint\n# (which, with INSTALL_LOCK=true, headless-installs + migrates the DB on first\n# boot). Waits for the DB, then creates the admin only if it does not exist.\nset -u\nexport HOME=/data/git\ni=0\nuntil su-exec git gitea admin user list >/dev/null 2>&1; do\n i=$((i + 1))\n [ \"$i\" -ge 150 ] && { echo \"[gitea-bootstrap] DB not ready after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\nif su-exec git gitea admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx \"$GITEA_ADMIN_USERNAME\"; then\n echo \"[gitea-bootstrap] admin '$GITEA_ADMIN_USERNAME' already present\"\nelse\n n=0\n until su-exec git gitea admin user create \\\n --username \"$GITEA_ADMIN_USERNAME\" \\\n --password \"$GITEA_ADMIN_PASSWORD\" \\\n --email \"[email protected]\" \\\n --admin --must-change-password=false; do\n n=$((n + 1))\n [ \"$n\" -ge 10 ] && { echo \"[gitea-bootstrap] admin create failed after retries\" >&2; break; }\n sleep 3\n done\n echo \"[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'\"\nfi\n# --- Keycloak OIDC login source (idempotent add-or-update; R-IDP-02/06) ---\n# Registers the confidential `gitea` client as the OAuth2 login source \"keycloak\" against the\n# homelab realm's discovery doc. The `groups` claim maps platform-admins -> Gitea admin\n# (--admin-group). Missing secret => skip (break-glass admin still works), so an unfilled\n# secret never crashes the pod.\n#\n# HARDENED (was a silent first-boot RACE): this step used to fire before the app/network was\n# ready and swallow its failure (|| echo ... >&2, exit 0), so SSO silently never registered.\n# Now it (1) waits \u2014 bounded, ~90s \u2014 for the Keycloak discovery doc to serve HTTP 200,\n# (2) registers (update-in-place if present, else create), (3) VERIFIES the source actually\n# persisted and retries the whole cycle, and (4) on give-up logs LOUDLY to stderr AND a marker\n# file on the persistent /data volume WITHOUT crashlooping the pod (break-glass admin stays\n# usable). The verify-then-retry loop is the real guarantee against \"exit 0 but nothing stuck\".\nGITEA_DISCOVERY_URL=https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration\ngitea_oidc_fail() {\n echo \"[gitea-oidc] FAILED to register 'keycloak' login source: $1\" >&2\n { echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) [gitea-oidc] FAILED: $1\"; } >> /data/gitea-oidc-bootstrap.log 2>/dev/null || true\n}\nif [ -z \"${GITEA_OIDC_CLIENT_SECRET:-}\" ]; then\n echo \"[gitea-oidc] GITEA_OIDC_CLIENT_SECRET unset; skipping OIDC source (break-glass admin only)\"\nelse\n # (1) Wait up to ~90s for the Keycloak discovery doc to be reachable (HTTP 200).\n d=0; disc_ok=0\n while [ \"$d\" -lt 45 ]; do\n if [ \"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \"$GITEA_DISCOVERY_URL\" 2>/dev/null)\" = \"200\" ]; then\n disc_ok=1; break\n fi\n d=$((d + 1)); sleep 2\n done\n [ \"$disc_ok\" = 1 ] || gitea_oidc_fail \"Keycloak discovery endpoint not reachable (HTTP 200) within timeout\"\n # (2)+(3) Register (update-in-place or create) then VERIFY it persisted; retry the whole cycle.\n r=0; reg_ok=0\n while [ \"$r\" -lt 10 ]; do\n oidc_id=$(su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{print $1; exit}')\n if [ -n \"$oidc_id\" ]; then\n su-exec git gitea admin auth update-oauth --id \"$oidc_id\" \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url \"$GITEA_DISCOVERY_URL\" \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins >/dev/null 2>&1 || true\n else\n su-exec git gitea admin auth add-oauth \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url \"$GITEA_DISCOVERY_URL\" \\\n --scopes \"openid email profile\" --group-claim-name\
|
- "# Idempotent break-glass admin bootstrap. Runs alongside the gitea entrypoint\n# (which, with INSTALL_LOCK=true, headless-installs + migrates the DB on first\n# boot). Waits for the DB, then creates the admin only if it does not exist.\nset -u\nexport HOME=/data/git\ni=0\nuntil su-exec git gitea admin user list >/dev/null 2>&1; do\n i=$((i + 1))\n [ \"$i\" -ge 150 ] && { echo \"[gitea-bootstrap] DB not ready after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\nif su-exec git gitea admin user list --admin 2>/dev/null | awk 'NR>1{print $2}' | grep -qx \"$GITEA_ADMIN_USERNAME\"; then\n echo \"[gitea-bootstrap] admin '$GITEA_ADMIN_USERNAME' already present\"\nelse\n n=0\n until su-exec git gitea admin user create \\\n --username \"$GITEA_ADMIN_USERNAME\" \\\n --password \"$GITEA_ADMIN_PASSWORD\" \\\n --email \"[email protected]\" \\\n --admin --must-change-password=false; do\n n=$((n + 1))\n [ \"$n\" -ge 10 ] && { echo \"[gitea-bootstrap] admin create failed after retries\" >&2; break; }\n sleep 3\n done\n echo \"[gitea-bootstrap] created break-glass admin '$GITEA_ADMIN_USERNAME'\"\nfi\n# --- Keycloak OIDC login source (idempotent add-or-update; R-IDP-02/06) ---\n# Registers the confidential `gitea` client as the OAuth2 login source \"keycloak\" against the\n# homelab realm's discovery doc. The `groups` claim maps platform-admins -> Gitea admin\n# (--admin-group). Missing secret => skip (break-glass admin still works), so an unfilled\n# secret never crashes the pod.\n#\n# HARDENED (was a silent first-boot RACE): this step used to fire before the app/network was\n# ready and swallow its failure (|| echo ... >&2, exit 0), so SSO silently never registered.\n# Now it (1) waits \u2014 bounded, ~90s \u2014 for the Keycloak discovery doc to serve HTTP 200,\n# (2) registers (update-in-place if present, else create), (3) VERIFIES the source actually\n# persisted and retries the whole cycle, and (4) on give-up logs LOUDLY to stderr AND a marker\n# file on the persistent /data volume WITHOUT crashlooping the pod (break-glass admin stays\n# usable). The verify-then-retry loop is the real guarantee against \"exit 0 but nothing stuck\".\nGITEA_DISCOVERY_URL=https://keycloak.${BASE_DOMAIN}/realms/homelab/.well-known/openid-configuration\ngitea_oidc_fail() {\n echo \"[gitea-oidc] FAILED to register 'keycloak' login source: $1\" >&2\n { echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) [gitea-oidc] FAILED: $1\"; } >> /data/gitea-oidc-bootstrap.log 2>/dev/null || true\n}\n# NOTE the `$$` escape below \u2014 it is load-bearing. apps-wave-0 declares postBuild.\n# substituteFrom cluster-vars, so Flux envsubst rewrites this whole manifest at RECONCILE\n# time, before any container runs. Written unescaped, `$${GITEA_OIDC_CLIENT_SECRET:-}` is not\n# a cluster-var, so envsubst blanked it and the guard reconciled as `[ -z \"\" ]` \u2014 ALWAYS TRUE\n# \u2014 making every line below (discovery wait, add/update-oauth, verify-then-retry) DEAD CODE on\n# every boot since 8738871 (2026-08-27). SSO kept working only because the login source was\n# already in the DB; a PVC rebuild or fresh cluster would have come up with NO OIDC at all.\n# Flux STRICT substitution cannot catch this \u2014 it EXEMPTS default-operator forms by design.\n# `$${VAR}` is the escape: envsubst emits it back as a plain brace expansion for the shell.\n# Bare `$VAR` and `$(cmd)` are left alone and need NO escaping \u2014 which is why the\n# `$GITEA_OIDC_CLIENT_SECRET` uses below always worked and only the guards broke.\n# Contract: clusters/base/apps-vms.yaml. Enforced at render time by catalog/render.py.\nif [ -z \"$${GITEA_OIDC_CLIENT_SECRET:-}\" ]; then\n echo \"[gitea-oidc] GITEA_OIDC_CLIENT_SECRET unset; skipping OIDC source (break-glass admin only)\"\nelse\n # (1) Wait up to ~90s for the Keycloak discovery doc to be reachable (HTTP 200).\n d=0; disc_ok=0\n while [ \"$d\" -lt 45 ]; do\n if [ \"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \"$GITEA_DISCOVERY_URL\" 2>/dev/null)\" = \"200\" ]; then\n disc_ok=1;\
|
||||||
\ groups --admin-group platform-admins >/dev/null 2>&1 || true\n fi\n # VERIFY: the \"keycloak\" source must actually be listed now (guards the exit-0-but-nothing-persisted race).\n if su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{f=1} END{exit !f}'; then\n reg_ok=1; break\n fi\n r=$((r + 1)); sleep 3\n done\n if [ \"$reg_ok\" = 1 ]; then\n echo \"[gitea-oidc] login source 'keycloak' registered and verified\"\n else\n gitea_oidc_fail \"add/update-oauth ran but the 'keycloak' source did not persist after retries\"\n fi\nfi\n"
|
\ break\n fi\n d=$((d + 1)); sleep 2\n done\n [ \"$disc_ok\" = 1 ] || gitea_oidc_fail \"Keycloak discovery endpoint not reachable (HTTP 200) within timeout\"\n # (2)+(3) Register (update-in-place or create) then VERIFY it persisted; retry the whole cycle.\n r=0; reg_ok=0\n while [ \"$r\" -lt 10 ]; do\n oidc_id=$(su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{print $1; exit}')\n if [ -n \"$oidc_id\" ]; then\n su-exec git gitea admin auth update-oauth --id \"$oidc_id\" \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url \"$GITEA_DISCOVERY_URL\" \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins >/dev/null 2>&1 || true\n else\n su-exec git gitea admin auth add-oauth \\\n --name keycloak --provider openidConnect --key gitea \\\n --secret \"$GITEA_OIDC_CLIENT_SECRET\" \\\n --auto-discover-url \"$GITEA_DISCOVERY_URL\" \\\n --scopes \"openid email profile\" --group-claim-name groups --admin-group platform-admins >/dev/null 2>&1 || true\n fi\n # VERIFY: the \"keycloak\" source must actually be listed now (guards the exit-0-but-nothing-persisted race).\n if su-exec git gitea admin auth list 2>/dev/null | awk '$2==\"keycloak\"{f=1} END{exit !f}'; then\n reg_ok=1; break\n fi\n r=$((r + 1)); sleep 3\n done\n if [ \"$reg_ok\" = 1 ]; then\n echo \"[gitea-oidc] login source 'keycloak' registered and verified\"\n else\n gitea_oidc_fail \"add/update-oauth ran but the 'keycloak' source did not persist after retries\"\n fi\nfi\n# A postStart hook MUST exit 0 \u2014 a non-zero exit makes the kubelet KILL the container\n# (FailedPostStartHook -> CrashLoopBackOff). This hook already ends 0 on both paths, but\n# it is made EXPLICIT so the guarantee does not depend on whatever statement happens to\n# be last. nextcloud's equivalent hook did depend on that and crashlooped on 2026-09-01,\n# the moment its OIDC guard was fixed and the block actually ran for the first time.\nexit 0\n"
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
@@ -207,7 +207,7 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- gitea.home.keithjackson.dev
|
- gitea.${BASE_DOMAIN}
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -91,16 +91,16 @@ spec:
|
|||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --provider=oidc
|
- --provider=oidc
|
||||||
- --oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab
|
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
|
||||||
- --upstream=http://127.0.0.1:9117
|
- --upstream=http://127.0.0.1:9117
|
||||||
- --email-domain=*
|
- --email-domain=*
|
||||||
- --scope=openid email profile
|
- --scope=openid email profile
|
||||||
- --oidc-groups-claim=groups
|
- --oidc-groups-claim=groups
|
||||||
- --allowed-group=media-users
|
- --allowed-group=media-users
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://jackett.home.keithjackson.dev/oauth2/callback
|
- --redirect-url=https://jackett.${BASE_DOMAIN}/oauth2/callback
|
||||||
- --cookie-domain=.home.keithjackson.dev
|
- --cookie-domain=.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.home.keithjackson.dev
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --pass-access-token=true
|
- --pass-access-token=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
@@ -273,7 +273,7 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- jackett.home.keithjackson.dev
|
- jackett.${BASE_DOMAIN}
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ resources:
|
|||||||
- jackett-oauth2-proxy-credentials.sops.yaml
|
- jackett-oauth2-proxy-credentials.sops.yaml
|
||||||
- jackett.yaml
|
- jackett.yaml
|
||||||
- nextcloud-db-app.sops.yaml
|
- nextcloud-db-app.sops.yaml
|
||||||
|
- nextcloud-db-backup-credentials-prod.sops.yaml
|
||||||
- nextcloud-db-backup-credentials.sops.yaml
|
- nextcloud-db-backup-credentials.sops.yaml
|
||||||
- nextcloud-db.yaml
|
- nextcloud-db.yaml
|
||||||
- pihole-oauth2-proxy-credentials.sops.yaml
|
- pihole-oauth2-proxy-credentials.sops.yaml
|
||||||
@@ -19,5 +20,6 @@ resources:
|
|||||||
- qbittorrent-oauth2-proxy-credentials.sops.yaml
|
- qbittorrent-oauth2-proxy-credentials.sops.yaml
|
||||||
- qbittorrent.yaml
|
- qbittorrent.yaml
|
||||||
- semaphore-db-app.sops.yaml
|
- semaphore-db-app.sops.yaml
|
||||||
|
- semaphore-db-backup-credentials-prod.sops.yaml
|
||||||
- semaphore-db-backup-credentials.sops.yaml
|
- semaphore-db-backup-credentials.sops.yaml
|
||||||
- semaphore-db.yaml
|
- semaphore-db.yaml
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: nextcloud-db-backup-credentials-prod
|
||||||
|
namespace: nextcloud-db
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
ACCESS_KEY_ID: ENC[AES256_GCM,data:4lx6et4HcXDegulyIoa7jZR/lVgapGPEkukajfCKjvk=,iv:p6V7tizbikk55tOvpPOsPHTe/p52QLX5A5xrwuB14rA=,tag:oNJuipT/Lb6zynJ2K2Q71A==,type:str]
|
||||||
|
ACCESS_SECRET_KEY: ENC[AES256_GCM,data:V5bHPoPeBOVyLp1n1Jilyso4i5P2aPSH0hrryfweg2q/SctWZxRbLBQ/FLzOigbJsAhlwOGu069mQPma6xjfdw==,iv:8u4UOvI8dC/cm6mEWDlyN5E3SfRZmwKY9uZY/DjPEsQ=,tag:tw7tGs29TmUvMPBCtCszAA==,type:str]
|
||||||
|
sops:
|
||||||
|
kms: []
|
||||||
|
gcp_kms: []
|
||||||
|
azure_kv: []
|
||||||
|
hc_vault: []
|
||||||
|
age:
|
||||||
|
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
|
||||||
|
enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBiSUt5N2k2N2lDU2ZsMy9m
|
||||||
|
M1NFS0M2QjFsNFhnR0hCMzFyaTdiQWFZRFJZCmhBTWRXNmxLMGtsc3Rxd2FSMDZp
|
||||||
|
aktjcTE2c1hmU1EzM3RWNksyb3Q1YVUKLS0tIDBFS1I5aDBER2cvcWtLZ3BXTEp5
|
||||||
|
ZGI5ZmZIcStheUNQamxIVVVpc0ZMOHMKlE5aHewtHJpeU24F5sodc9CEm9KTjHQQ
|
||||||
|
8ThEQ/8vyazL9V4ojlICxSHU/SHkHpD1eBaTKcAQd/LZzujIcJ+QfA==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
lastmodified: "2026-09-07T06:36:33Z"
|
||||||
|
mac: ENC[AES256_GCM,data:8awz1Jh8t4zjDMyd93/v+6on9iiIaSi01293A7uYhxj9L1+g63zeO+DPmSn6+KbZvHdqU2A7up09H1NzfXAEdGuvVbB36cdGq0zKank3PUi8c7IT6qNuWydxRAcqLnbbD+c30Z6GB+gAuZHstNOe4Rgc4aJvMN6FuP6x6Z0870c=,iv:KGGKJq2QA5xztvoHs/SViXW72DYwT16i+W+dJBUeIJQ=,tag:3UpYqx2+RIK0QdMH/BSIpA==,type:str]
|
||||||
|
pgp: []
|
||||||
|
encrypted_regex: ^(data|stringData)$
|
||||||
|
version: 3.9.4
|
||||||
@@ -68,25 +68,38 @@ spec:
|
|||||||
memory: 256Mi
|
memory: 256Mi
|
||||||
limits:
|
limits:
|
||||||
memory: 512Mi
|
memory: 512Mi
|
||||||
# --- Off-cluster DR backup (R-STATE-03) — CNPG-native Barman → the on-Proxmox MinIO `cnpg` bucket ---
|
# --- Off-cluster DR backup (R-STATE-03) — CNPG-native Barman → object storage ---
|
||||||
# Continuous WAL archiving + periodic base backups = proper Postgres PITR (realises the SD's
|
# Continuous WAL archiving + periodic base backups = proper Postgres PITR (realises the SD's
|
||||||
# backup.policy daily-offcluster). destinationPath is the bucket ROOT; CNPG appends serverName
|
# backup.policy daily-offcluster). CNPG appends serverName (defaults to the Cluster name) to
|
||||||
# (defaults to the Cluster name), so objects land under s3://cnpg/nextcloud-db/. Path-style HTTP.
|
# destinationPath, so objects land under ${CNPG_BACKUP_PATH}/nextcloud-db/ — dev
|
||||||
# Credentials: the scoped key from the in-namespace SOPS Secret nextcloud-db-backup-credentials
|
# s3://cnpg/nextcloud-db/ on the on-Proxmox MinIO, prod
|
||||||
# (same-ns only), kept in sync with platform/backup/minio-credentials.sops.yaml.
|
# s3://k3s-prod-backup/barman/nextcloud-db/ on Cloudflare R2. Both endpointURL and
|
||||||
# endpointURL is ${S3_BACKUP_ENDPOINT} (cluster-vars, R-ENV-02; dev http://192.168.1.9:9000),
|
# destinationPath are cluster-vars knobs (R-ENV-02), substituted by `apps-wave-0`.
|
||||||
# substituted by the `apps-wave-0` Flux Kustomization. Prod supplies its own off-cluster target.
|
# Credentials: PER-ENVIRONMENT (2026-09-07). dev reads nextcloud-db-backup-credentials (the
|
||||||
|
# scoped MinIO key), prod reads nextcloud-db-backup-credentials-prod (the Cloudflare R2
|
||||||
|
# token). BOTH Secrets are applied to BOTH clusters by apps/wave-0/kustomization.yaml (a
|
||||||
|
# generated golden — render.py places the -prod sidecar in this DB's wave); the
|
||||||
|
# cluster-vars knob ${BACKUP_CREDENTIALS_SUFFIX} (dev "" | prod "-prod") chooses which
|
||||||
|
# one this Cluster reads. Both are in-namespace, which is all a pod can read. They were
|
||||||
|
# ONE shared Secret until the split, so giving prod its R2 token would have left dev
|
||||||
|
# signing MinIO requests with an R2 key id — 403 on every archive command, i.e. the
|
||||||
|
# disk-full clock below rather than a merely missing backup.
|
||||||
|
#
|
||||||
|
# archive_mode is ON, so a FAILING archive command is a disk-full clock, not a degraded backup:
|
||||||
|
# Postgres refuses to recycle a WAL segment it has not archived, and the pgdata PVC fills until
|
||||||
|
# the primary PANICs. Prod demonstrated this from bootstrap to 2026-09-07 with a placeholder
|
||||||
|
# endpoint. Treat ContinuousArchiving=False here as an availability incident.
|
||||||
backup:
|
backup:
|
||||||
retentionPolicy: "30d"
|
retentionPolicy: "30d"
|
||||||
barmanObjectStore:
|
barmanObjectStore:
|
||||||
destinationPath: s3://cnpg
|
destinationPath: ${CNPG_BACKUP_PATH}
|
||||||
endpointURL: ${S3_BACKUP_ENDPOINT}
|
endpointURL: ${S3_BACKUP_ENDPOINT}
|
||||||
s3Credentials:
|
s3Credentials:
|
||||||
accessKeyId:
|
accessKeyId:
|
||||||
name: nextcloud-db-backup-credentials
|
name: nextcloud-db-backup-credentials${BACKUP_CREDENTIALS_SUFFIX}
|
||||||
key: ACCESS_KEY_ID
|
key: ACCESS_KEY_ID
|
||||||
secretAccessKey:
|
secretAccessKey:
|
||||||
name: nextcloud-db-backup-credentials
|
name: nextcloud-db-backup-credentials${BACKUP_CREDENTIALS_SUFFIX}
|
||||||
key: ACCESS_SECRET_KEY
|
key: ACCESS_SECRET_KEY
|
||||||
wal:
|
wal:
|
||||||
compression: gzip
|
compression: gzip
|
||||||
|
|||||||
@@ -91,16 +91,16 @@ spec:
|
|||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --provider=oidc
|
- --provider=oidc
|
||||||
- --oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab
|
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
|
||||||
- --upstream=http://127.0.0.1:80
|
- --upstream=http://127.0.0.1:80
|
||||||
- --email-domain=*
|
- --email-domain=*
|
||||||
- --scope=openid email profile
|
- --scope=openid email profile
|
||||||
- --oidc-groups-claim=groups
|
- --oidc-groups-claim=groups
|
||||||
- --allowed-group=platform-admins
|
- --allowed-group=platform-admins
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://pihole.home.keithjackson.dev/oauth2/callback
|
- --redirect-url=https://pihole.${BASE_DOMAIN}/oauth2/callback
|
||||||
- --cookie-domain=.home.keithjackson.dev
|
- --cookie-domain=.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.home.keithjackson.dev
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --pass-access-token=true
|
- --pass-access-token=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
@@ -229,7 +229,7 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- pihole.home.keithjackson.dev
|
- pihole.${BASE_DOMAIN}
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- plex.home.keithjackson.dev
|
- plex.${BASE_DOMAIN}
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -91,16 +91,16 @@ spec:
|
|||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --provider=oidc
|
- --provider=oidc
|
||||||
- --oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab
|
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
|
||||||
- --upstream=http://127.0.0.1:8080
|
- --upstream=http://127.0.0.1:8080
|
||||||
- --email-domain=*
|
- --email-domain=*
|
||||||
- --scope=openid email profile
|
- --scope=openid email profile
|
||||||
- --oidc-groups-claim=groups
|
- --oidc-groups-claim=groups
|
||||||
- --allowed-group=media-users
|
- --allowed-group=media-users
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://qbittorrent.home.keithjackson.dev/oauth2/callback
|
- --redirect-url=https://qbittorrent.${BASE_DOMAIN}/oauth2/callback
|
||||||
- --cookie-domain=.home.keithjackson.dev
|
- --cookie-domain=.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.home.keithjackson.dev
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --pass-access-token=true
|
- --pass-access-token=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
@@ -273,7 +273,7 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- qbittorrent.home.keithjackson.dev
|
- qbittorrent.${BASE_DOMAIN}
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: semaphore-db-backup-credentials-prod
|
||||||
|
namespace: semaphore-db
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
ACCESS_KEY_ID: ENC[AES256_GCM,data:OhWEEYqrvmDB40NmiETgeUisBvaO9ocevCZAsurNijw=,iv:kEjOIg9NHbRYm+Ul1WrBAzb3gLbIv+ipiYCxFK0QoVc=,tag:KAB1JAIw0ATwbjDEKZVT9A==,type:str]
|
||||||
|
ACCESS_SECRET_KEY: ENC[AES256_GCM,data:C0PNKv07LCYpFONLqd55sz+ioyU/i2YJUkbZ8OX/dCErM/DilqchyUAT5EqnT/r3YJmdaA1pX85S3fxlVVHkbg==,iv:4ha28b+xIebvX2JPITmtTovIe4vdmj2XfI3cgkUHH4c=,tag:xbMyflcHojytPzgaoOYcXQ==,type:str]
|
||||||
|
sops:
|
||||||
|
kms: []
|
||||||
|
gcp_kms: []
|
||||||
|
azure_kv: []
|
||||||
|
hc_vault: []
|
||||||
|
age:
|
||||||
|
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
|
||||||
|
enc: |
|
||||||
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZVTJ3cXR6cks3Y0RNS2hx
|
||||||
|
K3pjZEduYUxmL0V3SmFUcU9XbGVHSVFITWh3Cjl1eHlTLytoOW5PaVNFVkJoeTBo
|
||||||
|
T1FnRHNDVzJ6dWpuR09WaElvbWF4YU0KLS0tIEJoQ1VsM1cxRlg0R1dtNGdOenNn
|
||||||
|
aVNjMjVBTytLdHlFdmZCS2tPelo1Y2MKYt1G5t+sDiHgcg+Q9ECBzJn2gS/paSEC
|
||||||
|
7aoX60SBrPgHvnGls5lzc3Tin9nH4jWYl6Nu8DAvIE0YPXDFr5a0UA==
|
||||||
|
-----END AGE ENCRYPTED FILE-----
|
||||||
|
lastmodified: "2026-09-07T06:36:33Z"
|
||||||
|
mac: ENC[AES256_GCM,data:mhVP7tjdZK2FR2imvpJbTyT7BigvcIKEpLSnBS1oSITUirB8xZC7aHBfyFuIoKOrpOJgEe40nHUdDYCHRJtb4dLeVja8+7meVaQWzxC8BWVHsksW+73hsqhjAzlHeobx6Irbk4PAMqIY0/jnf4MHryjnRn3dUuwu9U1CqL30JbE=,iv:57oCcp6FW9EAIVKnYGtkaTrnbijyv54T+vmZxMbMqnw=,tag:agrRP/JLypU60KOeN0s/5w==,type:str]
|
||||||
|
pgp: []
|
||||||
|
encrypted_regex: ^(data|stringData)$
|
||||||
|
version: 3.9.4
|
||||||
@@ -69,25 +69,38 @@ spec:
|
|||||||
memory: 256Mi
|
memory: 256Mi
|
||||||
limits:
|
limits:
|
||||||
memory: 512Mi
|
memory: 512Mi
|
||||||
# --- Off-cluster DR backup (R-STATE-03) — CNPG-native Barman → the on-Proxmox MinIO `cnpg` bucket ---
|
# --- Off-cluster DR backup (R-STATE-03) — CNPG-native Barman → object storage ---
|
||||||
# Continuous WAL archiving + periodic base backups = proper Postgres PITR (realises the SD's
|
# Continuous WAL archiving + periodic base backups = proper Postgres PITR (realises the SD's
|
||||||
# backup.policy daily-offcluster). destinationPath is the bucket ROOT; CNPG appends serverName
|
# backup.policy daily-offcluster). CNPG appends serverName (defaults to the Cluster name) to
|
||||||
# (defaults to the Cluster name), so objects land under s3://cnpg/semaphore-db/. Path-style HTTP.
|
# destinationPath, so objects land under ${CNPG_BACKUP_PATH}/semaphore-db/ — dev
|
||||||
# Credentials: the scoped key from the in-namespace SOPS Secret semaphore-db-backup-credentials
|
# s3://cnpg/semaphore-db/ on the on-Proxmox MinIO, prod
|
||||||
# (same-ns only), kept in sync with platform/backup/minio-credentials.sops.yaml.
|
# s3://k3s-prod-backup/barman/semaphore-db/ on Cloudflare R2. Both endpointURL and
|
||||||
# endpointURL is ${S3_BACKUP_ENDPOINT} (cluster-vars, R-ENV-02; dev http://192.168.1.9:9000),
|
# destinationPath are cluster-vars knobs (R-ENV-02), substituted by `apps-wave-0`.
|
||||||
# substituted by the `apps-wave-0` Flux Kustomization. Prod supplies its own off-cluster target.
|
# Credentials: PER-ENVIRONMENT (2026-09-07). dev reads semaphore-db-backup-credentials (the
|
||||||
|
# scoped MinIO key), prod reads semaphore-db-backup-credentials-prod (the Cloudflare R2
|
||||||
|
# token). BOTH Secrets are applied to BOTH clusters by apps/wave-0/kustomization.yaml (a
|
||||||
|
# generated golden — render.py places the -prod sidecar in this DB's wave); the
|
||||||
|
# cluster-vars knob ${BACKUP_CREDENTIALS_SUFFIX} (dev "" | prod "-prod") chooses which
|
||||||
|
# one this Cluster reads. Both are in-namespace, which is all a pod can read. They were
|
||||||
|
# ONE shared Secret until the split, so giving prod its R2 token would have left dev
|
||||||
|
# signing MinIO requests with an R2 key id — 403 on every archive command, i.e. the
|
||||||
|
# disk-full clock below rather than a merely missing backup.
|
||||||
|
#
|
||||||
|
# archive_mode is ON, so a FAILING archive command is a disk-full clock, not a degraded backup:
|
||||||
|
# Postgres refuses to recycle a WAL segment it has not archived, and the pgdata PVC fills until
|
||||||
|
# the primary PANICs. Prod demonstrated this from bootstrap to 2026-09-07 with a placeholder
|
||||||
|
# endpoint. Treat ContinuousArchiving=False here as an availability incident.
|
||||||
backup:
|
backup:
|
||||||
retentionPolicy: "30d"
|
retentionPolicy: "30d"
|
||||||
barmanObjectStore:
|
barmanObjectStore:
|
||||||
destinationPath: s3://cnpg
|
destinationPath: ${CNPG_BACKUP_PATH}
|
||||||
endpointURL: ${S3_BACKUP_ENDPOINT}
|
endpointURL: ${S3_BACKUP_ENDPOINT}
|
||||||
s3Credentials:
|
s3Credentials:
|
||||||
accessKeyId:
|
accessKeyId:
|
||||||
name: semaphore-db-backup-credentials
|
name: semaphore-db-backup-credentials${BACKUP_CREDENTIALS_SUFFIX}
|
||||||
key: ACCESS_KEY_ID
|
key: ACCESS_KEY_ID
|
||||||
secretAccessKey:
|
secretAccessKey:
|
||||||
name: semaphore-db-backup-credentials
|
name: semaphore-db-backup-credentials${BACKUP_CREDENTIALS_SUFFIX}
|
||||||
key: ACCESS_SECRET_KEY
|
key: ACCESS_SECRET_KEY
|
||||||
wal:
|
wal:
|
||||||
compression: gzip
|
compression: gzip
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ spec:
|
|||||||
exec /sbin/tini -- /opt/act/run.sh
|
exec /sbin/tini -- /opt/act/run.sh
|
||||||
env:
|
env:
|
||||||
- name: GITEA_INSTANCE_URL
|
- name: GITEA_INSTANCE_URL
|
||||||
value: https://gitea.home.keithjackson.dev
|
value: https://gitea.${BASE_DOMAIN}
|
||||||
- name: GITEA_RUNNER_REGISTRATION_TOKEN
|
- name: GITEA_RUNNER_REGISTRATION_TOKEN
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
|
|||||||
@@ -104,13 +104,13 @@ spec:
|
|||||||
name: nextcloud-bootstrap
|
name: nextcloud-bootstrap
|
||||||
key: admin-password
|
key: admin-password
|
||||||
- name: NEXTCLOUD_TRUSTED_DOMAINS
|
- name: NEXTCLOUD_TRUSTED_DOMAINS
|
||||||
value: nextcloud.home.keithjackson.dev
|
value: nextcloud.${BASE_DOMAIN}
|
||||||
- name: OVERWRITEHOST
|
- name: OVERWRITEHOST
|
||||||
value: nextcloud.home.keithjackson.dev
|
value: nextcloud.${BASE_DOMAIN}
|
||||||
- name: OVERWRITEPROTOCOL
|
- name: OVERWRITEPROTOCOL
|
||||||
value: https
|
value: https
|
||||||
- name: OVERWRITECLIURL
|
- name: OVERWRITECLIURL
|
||||||
value: https://nextcloud.home.keithjackson.dev
|
value: https://nextcloud.${BASE_DOMAIN}
|
||||||
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
|
- name: NEXTCLOUD_OIDC_CLIENT_SECRET
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -125,8 +125,9 @@ spec:
|
|||||||
command:
|
command:
|
||||||
- /bin/sh
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
- "# Idempotent Keycloak OIDC wiring (R-IDP-02/06). Runs alongside the nextcloud entrypoint\n# (which installs/upgrades on first boot). Installs+enables the user_oidc app and creates OR\n# updates the \"keycloak\" login provider (occ user_oidc:provider is create-or-update by\n# identifier). Auto-discovery pulls the realm's endpoints; the `groups` claim drives Nextcloud\n# group provisioning. occ runs as www-data (owns config.php); the client secret is passed via\n# --clientsecret-env so it never lands on a command line. Safe to re-run every start; a missing\n# secret or not-yet-installed instance is skipped so the pod never crashloops (the local\n# break-glass admin still works).\nset -u\n# OCC runs occ as www-data (owns config.php). NOTE: this wrapper word-splits its args via $*,\n# so it is used ONLY for single-token subcommands (status/app:list/app:install/...). The\n# provider call below carries a MULTI-WORD value (--scope 'openid email profile') and is run as\n# ONE su -c with the scope single-quoted, so the inner shell keeps it a single argument.\nOCC() { su -s /bin/sh www-data -c \"php /var/www/html/occ $*\"; }\nNEXTCLOUD_DISCOVERY_URL=https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration\nnextcloud_oidc_fail() {\n echo \"[nextcloud-oidc] FAILED to configure 'keycloak' provider: $1\" >&2\n { echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) [nextcloud-oidc] FAILED: $1\"; } >> /var/www/html/nextcloud-oidc-bootstrap.log 2>/dev/null || true\n}\n# (a) Wait for the instance to be installed AND out of maintenance mode. The entrypoint holds\n# maintenance mode during first-boot install/upgrade; occ writes made in that window do not\n# stick \u2014 a second race on top of the quoting bug fixed at (c).\ni=0\nuntil OCC status 2>/dev/null | grep -q \"installed: true\" && ! OCC status 2>/dev/null | grep -q \"maintenance: true\"; do\n i=$((i + 1))\n [ \"$i\" -ge 300 ] && { echo \"[nextcloud-oidc] occ not ready (installed/maintenance) after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\n# Let Nextcloud's OWN server-side HTTP client reach the private-network IdP. The Keycloak\n# FQDN resolves (split-horizon) to the PRIVATE Cilium gateway VIP (RFC1918), and Nextcloud's\n# SSRF guard otherwise REFUSES that request \u2014 OCP\\Http\\Client\\LocalServerException \"violates\n# local access rules\" \u2014 during user_oidc's discovery/JWKS/token fetch AT LOGIN, surfacing to\n# the user as \"Could not reach the provider\". (The (b) curl check below uses system libcurl,\n# NOT this guard, so it passes while the browser login still failed.) Idempotent; re-applied\n# every start so it survives pod rolls / PVC rebuilds. Confirmed root cause 2026-08-27.\nOCC config:system:set allow_local_remote_servers --value=true --type=boolean >/dev/null 2>&1 \\\n || echo \"[nextcloud-oidc] WARN: failed to set allow_local_remote_servers\" >&2\nif [ -z \"${NEXTCLOUD_OIDC_CLIENT_SECRET:-}\" ]; then\n echo \"[nextcloud-oidc] client secret unset; skipping OIDC (break-glass admin only)\"\n exit 0\nfi\nif ! OCC app:list 2>/dev/null | grep -q \"user_oidc\"; then\n OCC app:install user_oidc || echo \"[nextcloud-oidc] app:install user_oidc failed\" >&2\nfi\nOCC app:enable user_oidc >/dev/null 2>&1 || true\n# (b) Wait up to ~90s for the Keycloak discovery doc to be reachable (HTTP 200) before registering.\nd=0; disc_ok=0\nwhile [ \"$d\" -lt 45 ]; do\n if [ \"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \"$NEXTCLOUD_DISCOVERY_URL\" 2>/dev/null)\" = \"200\" ]; then\n disc_ok=1; break\n fi\n d=$((d + 1)); sleep 2\ndone\n[ \"$disc_ok\" = 1 ] || nextcloud_oidc_fail \"Keycloak discovery endpoint not reachable (HTTP 200) within timeout\"\n# (c) Configure (create-or-update by identifier) then VERIFY it persisted; retry the whole cycle.\n# HARDENED: run as ONE su -c so www-data's shell parses --scope='openid email profile' as a\n# single argument. The previous `OCC user_oidc:provider ... --scope=\"openid email profile\"`\n# form fed the value back through the wrapper's $*, which SPLIT it into extra\
|
- "# Idempotent Keycloak OIDC wiring (R-IDP-02/06). Runs alongside the nextcloud entrypoint\n# (which installs/upgrades on first boot). Installs+enables the user_oidc app and creates OR\n# updates the \"keycloak\" login provider (occ user_oidc:provider is create-or-update by\n# identifier). Auto-discovery pulls the realm's endpoints; the `groups` claim drives Nextcloud\n# group provisioning. occ runs as www-data (owns config.php); the client secret is passed via\n# --clientsecret-env so it never lands on a command line. Safe to re-run every start; a missing\n# secret or not-yet-installed instance is skipped so the pod never crashloops (the local\n# break-glass admin still works).\nset -u\n# OCC runs occ as www-data (owns config.php). NOTE: this wrapper word-splits its args via $*,\n# so it is used ONLY for single-token subcommands (status/app:list/app:install/...). The\n# provider call below carries a MULTI-WORD value (--scope 'openid email profile') and is run as\n# ONE su -c with the scope single-quoted, so the inner shell keeps it a single argument.\nOCC() { su -s /bin/sh www-data -c \"php /var/www/html/occ $*\"; }\nNEXTCLOUD_DISCOVERY_URL=https://keycloak.${BASE_DOMAIN}/realms/homelab/.well-known/openid-configuration\nnextcloud_oidc_fail() {\n echo \"[nextcloud-oidc] FAILED to configure 'keycloak' provider: $1\" >&2\n { echo \"$(date -u +%Y-%m-%dT%H:%M:%SZ) [nextcloud-oidc] FAILED: $1\"; } >> /var/www/html/nextcloud-oidc-bootstrap.log 2>/dev/null || true\n}\n# (a) Wait for the instance to be installed AND out of maintenance mode. The entrypoint holds\n# maintenance mode during first-boot install/upgrade; occ writes made in that window do not\n# stick \u2014 a second race on top of the quoting bug fixed at (c).\ni=0\nuntil OCC status 2>/dev/null | grep -q \"installed: true\" && ! OCC status 2>/dev/null | grep -q \"maintenance: true\"; do\n i=$((i + 1))\n [ \"$i\" -ge 300 ] && { echo \"[nextcloud-oidc] occ not ready (installed/maintenance) after wait; skipping\" >&2; exit 0; }\n sleep 2\ndone\n# Let Nextcloud's OWN server-side HTTP client reach the private-network IdP. The Keycloak\n# FQDN resolves (split-horizon) to the PRIVATE Cilium gateway VIP (RFC1918), and Nextcloud's\n# SSRF guard otherwise REFUSES that request \u2014 OCP\\Http\\Client\\LocalServerException \"violates\n# local access rules\" \u2014 during user_oidc's discovery/JWKS/token fetch AT LOGIN, surfacing to\n# the user as \"Could not reach the provider\". (The (b) curl check below uses system libcurl,\n# NOT this guard, so it passes while the browser login still failed.) Idempotent; re-applied\n# every start so it survives pod rolls / PVC rebuilds. Confirmed root cause 2026-08-27.\nOCC config:system:set allow_local_remote_servers --value=true --type=boolean >/dev/null 2>&1 \\\n || echo \"[nextcloud-oidc] WARN: failed to set allow_local_remote_servers\" >&2\n# NOTE the `$$` escape below \u2014 it is load-bearing. apps-wave-1 declares postBuild.\n# substituteFrom cluster-vars, so Flux envsubst rewrites this whole manifest at RECONCILE\n# time, before any container runs. Written unescaped, `$${NEXTCLOUD_OIDC_CLIENT_SECRET:-}` is\n# not a cluster-var, so envsubst blanked it and the guard reconciled as `[ -z \"\" ]` \u2014 ALWAYS\n# TRUE \u2014 so this hook `exit 0`d here on EVERY boot since 8738871 (2026-08-27), never\n# installing/enabling user_oidc nor registering the provider. SSO kept working only because\n# the provider was already in the DB; a PVC rebuild or fresh cluster would have come up with\n# NO OIDC at all. Flux STRICT substitution cannot catch this \u2014 it EXEMPTS default-operator\n# forms by design. `$${VAR}` is the escape: envsubst emits it back as a plain brace expansion\n# for the shell. Bare `$VAR` and `$(cmd)` are left alone and need NO escaping \u2014 which is why\n# `--clientsecret-env` and the bare uses below always worked and only the guard broke.\n# Contract: clusters/base/apps-vms.yaml. Enforced at render time by catalog/render.py.\nif [ -z \"$${NEXTCLOUD_OIDC_CLIENT_SECRET:-}\" ]; then\n echo \"[nextcloud-oidc]\
|
||||||
\ positionals ->\n# occ \"Too many arguments\" -> silent failure on EVERY boot. Secret via --clientsecret-env\n# (never on argv). The verify-then-retry loop guarantees the provider actually persisted.\nr=0; reg_ok=0\nwhile [ \"$r\" -lt 10 ]; do\n su -s /bin/sh www-data -c \"php /var/www/html/occ user_oidc:provider keycloak \\\n --clientid=nextcloud \\\n --clientsecret-env=NEXTCLOUD_OIDC_CLIENT_SECRET \\\n --discoveryuri=$NEXTCLOUD_DISCOVERY_URL \\\n --scope='openid email profile' \\\n --unique-uid=0 \\\n --mapping-uid=preferred_username \\\n --mapping-email=email \\\n --mapping-display-name=name \\\n --group-provisioning=1 \\\n --mapping-groups=groups\" >/dev/null 2>&1 || true\n if OCC user_oidc:provider 2>/dev/null | grep -q \"keycloak\"; then\n reg_ok=1; break\n fi\n r=$((r + 1)); sleep 3\ndone\nif [ \"$reg_ok\" = 1 ]; then\n echo \"[nextcloud-oidc] provider 'keycloak' configured and verified\"\nelse\n nextcloud_oidc_fail \"user_oidc:provider ran but the provider did not persist after retries\"\nfi\n# (d) Promote Keycloak 'platform-admins' members to Nextcloud admins (#5). user_oidc 8.11 has\n# NO native group->admin mapping (occ user_oidc:provider offers group PROVISIONING and a\n# login WHITELIST only), so mirror the provisioned 'platform-admins' membership into the\n# built-in 'admin' group (the sole source of admin rights in Nextcloud). Idempotent every\n# start: occ group:adduser is a no-op for an existing member. A user is promotable only\n# AFTER their first OIDC login provisions them into the group, so a brand-new admin gains\n# rights on their next login + the following pod start (or run `occ group:adduser admin\n# <uid>` once). The LOCAL break-glass admin is unaffected (docs/15). The group name is\n# passed to php as argv (never interpolated) so group:list JSON parses robustly.\nADMIN_SRC_GROUP=platform-admins\n# HARDENED (verify-and-retry, like step (c)): the previous single pass ran `occ group:adduser`\n# exactly once with its output swallowed (>/dev/null 2>&1) and NO retry, so an early-boot\n# transient failure (DB / maintenance race in the first seconds after container start) silently\n# dropped the promotion until the NEXT pod restart. This loop re-reads group:list and re-adds\n# any platform-admins member still missing from 'admin' until all are verified (bounded ~30s);\n# it is a no-op for members already in 'admin'. Group names are passed to php as argv (never\n# interpolated) so the JSON parses robustly.\nparse_group() { su -s /bin/sh www-data -c \"php /var/www/html/occ group:list --output=json\" 2>/dev/null | php -r '$d=json_decode(stream_get_contents(STDIN),true);$g=$argv[1];if(is_array($d)&&!empty($d[$g])&&is_array($d[$g])){foreach($d[$g] as $u){echo $u,\"\\n\";}}' -- \"$1\" 2>/dev/null; }\np=0\nwhile [ \"$p\" -lt 15 ]; do\n current_admins=$(parse_group admin)\n missing=0\n for uid in $(parse_group \"$ADMIN_SRC_GROUP\"); do\n if printf '%s\\n' \"$current_admins\" | grep -qxF -- \"$uid\"; then\n continue\n fi\n missing=1\n if OCC group:adduser admin \"$uid\" >/dev/null 2>&1; then\n echo \"[nextcloud-oidc] ensured '$uid' is a Nextcloud admin (member of $ADMIN_SRC_GROUP)\"\n fi\n done\n [ \"$missing\" = 0 ] && break\n p=$((p + 1)); sleep 2\ndone\n[ \"$p\" -ge 15 ] && nextcloud_oidc_fail \"could not promote all $ADMIN_SRC_GROUP members into 'admin' after retries\"\n"
|
\ client secret unset; skipping OIDC (break-glass admin only)\"\n exit 0\nfi\nif ! OCC app:list 2>/dev/null | grep -q \"user_oidc\"; then\n OCC app:install user_oidc || echo \"[nextcloud-oidc] app:install user_oidc failed\" >&2\nfi\nOCC app:enable user_oidc >/dev/null 2>&1 || true\n# (b) Wait up to ~90s for the Keycloak discovery doc to be reachable (HTTP 200) before registering.\nd=0; disc_ok=0\nwhile [ \"$d\" -lt 45 ]; do\n if [ \"$(curl -sS -o /dev/null -w '%{http_code}' --max-time 5 \"$NEXTCLOUD_DISCOVERY_URL\" 2>/dev/null)\" = \"200\" ]; then\n disc_ok=1; break\n fi\n d=$((d + 1)); sleep 2\ndone\n[ \"$disc_ok\" = 1 ] || nextcloud_oidc_fail \"Keycloak discovery endpoint not reachable (HTTP 200) within timeout\"\n# (c) Configure (create-or-update by identifier) then VERIFY it persisted; retry the whole cycle.\n# HARDENED: run as ONE su -c so www-data's shell parses --scope='openid email profile' as a\n# single argument. The previous `OCC user_oidc:provider ... --scope=\"openid email profile\"`\n# form fed the value back through the wrapper's $*, which SPLIT it into extra positionals ->\n# occ \"Too many arguments\" -> silent failure on EVERY boot. Secret via --clientsecret-env\n# (never on argv). The verify-then-retry loop guarantees the provider actually persisted.\nr=0; reg_ok=0\nwhile [ \"$r\" -lt 10 ]; do\n su -s /bin/sh www-data -c \"php /var/www/html/occ user_oidc:provider keycloak \\\n --clientid=nextcloud \\\n --clientsecret-env=NEXTCLOUD_OIDC_CLIENT_SECRET \\\n --discoveryuri=$NEXTCLOUD_DISCOVERY_URL \\\n --scope='openid email profile' \\\n --unique-uid=0 \\\n --mapping-uid=preferred_username \\\n --mapping-email=email \\\n --mapping-display-name=name \\\n --group-provisioning=1 \\\n --mapping-groups=groups\" >/dev/null 2>&1 || true\n if OCC user_oidc:provider 2>/dev/null | grep -q \"keycloak\"; then\n reg_ok=1; break\n fi\n r=$((r + 1)); sleep 3\ndone\nif [ \"$reg_ok\" = 1 ]; then\n echo \"[nextcloud-oidc] provider 'keycloak' configured and verified\"\nelse\n nextcloud_oidc_fail \"user_oidc:provider ran but the provider did not persist after retries\"\nfi\n# (d) Promote Keycloak 'platform-admins' members to Nextcloud admins (#5). user_oidc 8.11 has\n# NO native group->admin mapping (occ user_oidc:provider offers group PROVISIONING and a\n# login WHITELIST only), so mirror the provisioned 'platform-admins' membership into the\n# built-in 'admin' group (the sole source of admin rights in Nextcloud). Idempotent every\n# start: occ group:adduser is a no-op for an existing member. A user is promotable only\n# AFTER their first OIDC login provisions them into the group, so a brand-new admin gains\n# rights on their next login + the following pod start (or run `occ group:adduser admin\n# <uid>` once). The LOCAL break-glass admin is unaffected (docs/15). The group name is\n# passed to php as argv (never interpolated) so group:list JSON parses robustly.\nADMIN_SRC_GROUP=platform-admins\n# HARDENED (verify-and-retry, like step (c)): the previous single pass ran `occ group:adduser`\n# exactly once with its output swallowed (>/dev/null 2>&1) and NO retry, so an early-boot\n# transient failure (DB / maintenance race in the first seconds after container start) silently\n# dropped the promotion until the NEXT pod restart. This loop re-reads group:list and re-adds\n# any platform-admins member still missing from 'admin' until all are verified (bounded ~30s);\n# it is a no-op for members already in 'admin'. Group names are passed to php as argv (never\n# interpolated) so the JSON parses robustly.\nparse_group() { su -s /bin/sh www-data -c \"php /var/www/html/occ group:list --output=json\" 2>/dev/null | php -r '$d=json_decode(stream_get_contents(STDIN),true);$g=$argv[1];if(is_array($d)&&!empty($d[$g])&&is_array($d[$g])){foreach($d[$g] as $u){echo $u,\"\\n\";}}' -- \"$1\" 2>/dev/null; }\np=0\nwhile [ \"$p\" -lt 15 ]; do\n current_admins=$(parse_group admin)\n missing=0\n for uid in $(parse_group \"$ADMIN_SRC_GROUP\"\
|
||||||
|
); do\n if printf '%s\\n' \"$current_admins\" | grep -qxF -- \"$uid\"; then\n continue\n fi\n missing=1\n if OCC group:adduser admin \"$uid\" >/dev/null 2>&1; then\n echo \"[nextcloud-oidc] ensured '$uid' is a Nextcloud admin (member of $ADMIN_SRC_GROUP)\"\n fi\n done\n [ \"$missing\" = 0 ] && break\n p=$((p + 1)); sleep 2\ndone\nif [ \"$p\" -ge 15 ]; then\n nextcloud_oidc_fail \"could not promote all $ADMIN_SRC_GROUP members into 'admin' after retries\"\nfi\n# A postStart hook MUST exit 0. A non-zero exit makes the kubelet KILL the container\n# (FailedPostStartHook -> CrashLoopBackOff), which is exactly what happened on 2026-09-01:\n# the final statement used to be `[ \"$p\" -ge 15 ] && nextcloud_oidc_fail ...`, so on the\n# SUCCESS path the test returned 1 and became the script's exit status. That was latent for\n# as long as the OIDC guard above was dead code and exited 0 first; fixing the guard\n# exposed it immediately. Every failure path here logs loudly and writes a marker file and\n# deliberately does NOT crash the pod \u2014 the break-glass local admin must stay usable.\nexit 0\n"
|
||||||
volumes:
|
volumes:
|
||||||
- name: data
|
- name: data
|
||||||
persistentVolumeClaim:
|
persistentVolumeClaim:
|
||||||
@@ -227,7 +228,7 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- nextcloud.home.keithjackson.dev
|
- nextcloud.${BASE_DOMAIN}
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -91,16 +91,16 @@ spec:
|
|||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --provider=oidc
|
- --provider=oidc
|
||||||
- --oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab
|
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
|
||||||
- --upstream=http://127.0.0.1:7878
|
- --upstream=http://127.0.0.1:7878
|
||||||
- --email-domain=*
|
- --email-domain=*
|
||||||
- --scope=openid email profile
|
- --scope=openid email profile
|
||||||
- --oidc-groups-claim=groups
|
- --oidc-groups-claim=groups
|
||||||
- --allowed-group=media-users
|
- --allowed-group=media-users
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://radarr.home.keithjackson.dev/oauth2/callback
|
- --redirect-url=https://radarr.${BASE_DOMAIN}/oauth2/callback
|
||||||
- --cookie-domain=.home.keithjackson.dev
|
- --cookie-domain=.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.home.keithjackson.dev
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --pass-access-token=true
|
- --pass-access-token=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
@@ -295,7 +295,7 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- radarr.home.keithjackson.dev
|
- radarr.${BASE_DOMAIN}
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -5,6 +5,15 @@
|
|||||||
# credential from its own namespace (a Pod cannot read a Secret in another namespace).
|
# credential from its own namespace (a Pod cannot read a Secret in another namespace).
|
||||||
# access-key-encryption: FIXED base64 32-byte AES key Semaphore uses to encrypt stored SSH keys /
|
# access-key-encryption: FIXED base64 32-byte AES key Semaphore uses to encrypt stored SSH keys /
|
||||||
# credentials; MUST stay stable across restarts or stored secrets become undecryptable.
|
# credentials; MUST stay stable across restarts or stored secrets become undecryptable.
|
||||||
|
# oidc-client-secret: the Keycloak `semaphore` CONFIDENTIAL client secret, and ONLY that. It MIRRORS
|
||||||
|
# `semaphore-client-secret` in platform/keycloak/keycloak-realm-clients.sops.yaml (the realm import
|
||||||
|
# injects that copy into the client), same cross-namespace mirror pattern as db-password above.
|
||||||
|
# It replaced the former `oidc-providers-json` key (2026-09-07): that key held the WHOLE
|
||||||
|
# SEMAPHORE_OIDC_PROVIDERS blob, which buried the environment-specific realm-issuer and redirect
|
||||||
|
# URLs inside ciphertext where Flux's ${BASE_DOMAIN} postBuild substitution could not reach them,
|
||||||
|
# so prod ran with dev's URLs. Those URLs are PUBLIC and now live in plaintext in
|
||||||
|
# catalog/semaphore.yaml (rendered to apps/wave-1/semaphore.yaml); the kubelet splices THIS value
|
||||||
|
# back into the blob via $(SEMAPHORE_OIDC_CLIENT_SECRET). Only the secret half is encrypted.
|
||||||
# Recover: infra/devcontainer/dev.sh run sops -d apps/semaphore-bootstrap-secret.sops.yaml
|
# Recover: infra/devcontainer/dev.sh run sops -d apps/semaphore-bootstrap-secret.sops.yaml
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
@@ -15,11 +24,11 @@ metadata:
|
|||||||
app.kubernetes.io/name: semaphore
|
app.kubernetes.io/name: semaphore
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
admin-username: ENC[AES256_GCM,data:uD/vO9A9MjerFg==,iv:i/NAqVD6rWg9oWWSGBrRkxzwcPp2IW1HLIKY0pdcPPg=,tag:r27tKLAJ+ydE7Ty3ccMNUw==,type:str]
|
admin-username: ENC[AES256_GCM,data:ju3wVdjNzRUP/w==,iv:d/5ZxgQZ5bympu3gT2ck3DJy9iUPPBAtZYsJvD3To7I=,tag:0UtBaDVqNSK4RCJrQicchw==,type:str]
|
||||||
admin-password: ENC[AES256_GCM,data:544nWY1UmBnlsdPJAQZJMXRumu4W9X7UXZvZ6vHN/ArjRgLXKTArOe5JkT/nNrNw,iv:AC1DDbZj2HfpU19EjzA7T+WpIaR4nZc3PNN61y8bAzU=,tag:r28LFi5w6ZeCCaRaX2jRdg==,type:str]
|
admin-password: ENC[AES256_GCM,data:pxIwpgh3BoND2TI0mPTixOTJoictzT77dqtuRdMKgY/2WaW4wM1Ra6H6n/dxnyR2,iv:eYbFQGFYVJsnYGwn6cOI3BgAdzMcin9YggAhiuL+xqM=,tag:/sPWGv4gue0vjc2nMk7wWA==,type:str]
|
||||||
db-password: ENC[AES256_GCM,data:ySkdvaGRCW9fMQqChs5l8dY4zCG19i4HOEinRC48mgLuh9WOrjd16T6jwFgiwB55,iv:+1gztJ22HbwgQJiolZMMWOcKIsxrBJpnruJdtxdooOU=,tag:8CfJGwyPTjDGyr0CvalXMQ==,type:str]
|
db-password: ENC[AES256_GCM,data:hBFpWXdpk1R5a23Pntup03t56NBRPS+SZU8CrHVb/ypIaIpgf7kjYtVy6nhTF6u0,iv:eBzoQgH5ZhWWpfKZ6AQmwaSal5QaiuN9gfexQ7GsVFs=,tag:j2k8EwhKDlj/Ud6O39ioVQ==,type:str]
|
||||||
access-key-encryption: ENC[AES256_GCM,data:1YAMLo1nVkpJWZc+KBZ7B4ia+Y8P+xMyOhHMcEGGJOXd3o+WC5FqirLBVh4=,iv:j28ozeaDhGWDjoaxFOhn2t9YbYE7L3ah/Mfi+i2DWwY=,tag:czG9Wxc/7Nsuh1W5iATOww==,type:str]
|
access-key-encryption: ENC[AES256_GCM,data:SKtAV4HxJ9pNYDqUK/1uOdI1rtMji3pTfprI2rhQxFQyhc6dFlhqI+OUEcY=,iv:rKCP+FLidcOltOwmMMcO5M8tz4qiifIMI+W5soGO2M8=,tag:HaHzkX/JI+9wmcg2SD9GAQ==,type:str]
|
||||||
oidc-providers-json: ENC[AES256_GCM,data:90yt5mIVWLVI2KrlJWpxhL/BMDpEgRhv7+BL+mBRt79diRvU7pN9vtzhZprAKzcU1sEywlT4EwFlsvJpYyo5Qbkp+ZVknbyhAlGvHhJOBHukOPWgf4LignLerO2GzhXjOOzFqWfZ4EyUxNMWTbSe/xc0zIVHDDNQiYplGVRK+E/ukiI7sD/Tn2RrbrdvpC5GyK0NXWRsXn9ffhE3fCoiRHXagj9i/q+uUrQI5EKpPobrDUAGqvKoBXTl/wJ4phnWg9IqtCTE/c3yA7fLLz4j5dCtPZNSVPT4TKDx8Mdv8CfCgziVicjG2s+yHuWpsQn8lf6nvdJmpYPvIqTtLWdxFWuD8Vfo6Qbq2mqPbKR+KmDOGUnjl4gbZdL1SkE9JiqBUobz7UXeLXwYzxMIsinOfYZLcCk7alrD5Yndj9JDxDJZQ54sMMBfVbxDl94W1VHy03RxAR46KKAbPaxOP5wH4Q4z3SDceNwmuGCDRGChRXiHhN5X2X0GqYCbc8E6GdkIMcpmD80e26ydEmAjw7F89mCF4F/yMzJNiG82qaZIn8HeqgGA8urous9r9bYJNlitYTYQ,iv:jbfVdrrZ9fGiOxhQeoTJfFg1QwiOMkoQyaadiVfxX/w=,tag:JkepcRs34EuEgNb4buTTDQ==,type:str]
|
oidc-client-secret: ENC[AES256_GCM,data:uCo7TNzbM2QKw9ie+xzyUG9MFKco1OAlwr5qPn/AGpNvo1YyXSfbZyyM0CLJ1NS821hGg5Zr/m3R/xpy84s3FQ==,iv:O8a4haskufpmorPaxOkJ1Q99Y5GTlzmDFJAsHS+irLk=,tag:VojJpA9jNMbNQmIe1Qr44w==,type:str]
|
||||||
sops:
|
sops:
|
||||||
kms: []
|
kms: []
|
||||||
gcp_kms: []
|
gcp_kms: []
|
||||||
@@ -29,14 +38,14 @@ sops:
|
|||||||
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
|
- recipient: age175l8mdpdfu7hq0aml3exs3gykarwjmuvr23sq56s3wjejnyk44wsp4mfzg
|
||||||
enc: |
|
enc: |
|
||||||
-----BEGIN AGE ENCRYPTED FILE-----
|
-----BEGIN AGE ENCRYPTED FILE-----
|
||||||
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuRDlqRFJ0dk14UHluSGRP
|
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyTzF6NTBtcFVWK1Vtc0I2
|
||||||
S1pESy9PWlpMakU0aTkzNVhqUGdtbWNEdTJjCm5RNG14azJHc28rdEJ4L0RzR0xO
|
VGdySHBsRGgvc1h0VzgvQ01xYVBJV3g2Q1NRCjJyZHVYZGtuamo1MFJHWlBwWkxt
|
||||||
MGRocGZ1V1JZTENWQkN3VGZzamp2eTgKLS0tIGt6QkNNaW5hdTBJVHY0SVo5d3JX
|
cHA4VkY1TkRpN3M4UTdMOWZTbFVGemMKLS0tIGVNdDBrWVJveU56VjVBU2JWdHF4
|
||||||
elp0SWxHcjg0RWF3aW9mWTJHZ0lhcUEKc0rU5VilAVDleCwqq4SOF+Y9sniu1Pka
|
RGtaYStXYUpONTZjZCtmM0NyZ3Rka1UKIQpmQai8dz+uRAsXxyeuGaRlRwsFqIPQ
|
||||||
w/MGDA9THSB0b+YgWRWiX/KiimFqp0ZFODOJ9cHVwFGY71+VadPwOg==
|
9AfrZJcslYwlaK3ptm1oqaCdrlxcMar2nI7thSqzbLe+e1nzA2z7vQ==
|
||||||
-----END AGE ENCRYPTED FILE-----
|
-----END AGE ENCRYPTED FILE-----
|
||||||
lastmodified: "2026-08-27T22:12:32Z"
|
lastmodified: "2026-09-07T07:01:50Z"
|
||||||
mac: ENC[AES256_GCM,data:nHftJTGnTr0O5h8yGXwgz3eflggXJfIXM3m8n3bzDJE/P4nvSpZzFeMb+sc2on+AL+t+SeKrIl8blzm33XsQ1lkaY85Zm+bc5cnED1g04BMDVmmK3brh/8w50Bp2wPEVzb4I01MDzhhyIbOYg7pFvZAH3/VuqvM1t6XmfF1NrOA=,iv:4KBa9PH/dfdGwLC16RenZIVB6BtSoGo6x3qt3h1R43I=,tag:KULiA1klP/RZMNigkloGDA==,type:str]
|
mac: ENC[AES256_GCM,data:Xutm1rQt57J4RILElD8FkNOsQT1HK5jZlPtqqLyZ/VqOoDwo+IS1wp7hROprlUvQC0bUXy79tRRIl5Q0CCOD+fvAGIdmade+vB7qXlYN6Q1441dGhuSTPvckso6FaB2ddRjKhKGLc/Z4bJoz9VaRNNxJNpWXIrENTg+I+QhNhlo=,iv:klJOrEQ6QN5dTGkQfGnbMbgCWsgWwZPb5YXDMeWeE34=,tag:fuJr4t3Tt3dT9u3/0SFo8Q==,type:str]
|
||||||
pgp: []
|
pgp: []
|
||||||
encrypted_regex: ^(data|stringData)$
|
encrypted_regex: ^(data|stringData)$
|
||||||
version: 3.9.4
|
version: 3.9.4
|
||||||
@@ -110,7 +110,7 @@ spec:
|
|||||||
- name: SEMAPHORE_ADMIN_NAME
|
- name: SEMAPHORE_ADMIN_NAME
|
||||||
value: Semaphore Admin
|
value: Semaphore Admin
|
||||||
- name: SEMAPHORE_ADMIN_EMAIL
|
- name: SEMAPHORE_ADMIN_EMAIL
|
||||||
value: admin@home.keithjackson.dev
|
value: admin@${BASE_DOMAIN}
|
||||||
- name: SEMAPHORE_ACCESS_KEY_ENCRYPTION
|
- name: SEMAPHORE_ACCESS_KEY_ENCRYPTION
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
@@ -118,11 +118,13 @@ spec:
|
|||||||
key: access-key-encryption
|
key: access-key-encryption
|
||||||
- name: SEMAPHORE_PORT
|
- name: SEMAPHORE_PORT
|
||||||
value: :3000
|
value: :3000
|
||||||
- name: SEMAPHORE_OIDC_PROVIDERS
|
- name: SEMAPHORE_OIDC_CLIENT_SECRET
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: semaphore-bootstrap
|
name: semaphore-bootstrap
|
||||||
key: oidc-providers-json
|
key: oidc-client-secret
|
||||||
|
- name: SEMAPHORE_OIDC_PROVIDERS
|
||||||
|
value: '{"keycloak":{"display_name":"Keycloak","provider_url":"https://keycloak.${BASE_DOMAIN}/realms/homelab","client_id":"semaphore","client_secret":"$(SEMAPHORE_OIDC_CLIENT_SECRET)","redirect_url":"https://semaphore.${BASE_DOMAIN}/api/auth/oidc/keycloak/redirect","scopes":["openid","profile","email"],"username_claim":"preferred_username","email_claim":"email","name_claim":"name","order":1}}'
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /etc/semaphore
|
mountPath: /etc/semaphore
|
||||||
@@ -231,7 +233,7 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- semaphore.home.keithjackson.dev
|
- semaphore.${BASE_DOMAIN}
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -91,16 +91,16 @@ spec:
|
|||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --provider=oidc
|
- --provider=oidc
|
||||||
- --oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab
|
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
|
||||||
- --upstream=http://127.0.0.1:8989
|
- --upstream=http://127.0.0.1:8989
|
||||||
- --email-domain=*
|
- --email-domain=*
|
||||||
- --scope=openid email profile
|
- --scope=openid email profile
|
||||||
- --oidc-groups-claim=groups
|
- --oidc-groups-claim=groups
|
||||||
- --allowed-group=media-users
|
- --allowed-group=media-users
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://sonarr.home.keithjackson.dev/oauth2/callback
|
- --redirect-url=https://sonarr.${BASE_DOMAIN}/oauth2/callback
|
||||||
- --cookie-domain=.home.keithjackson.dev
|
- --cookie-domain=.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.home.keithjackson.dev
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --pass-access-token=true
|
- --pass-access-token=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
@@ -295,7 +295,7 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- sonarr.home.keithjackson.dev
|
- sonarr.${BASE_DOMAIN}
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -223,7 +223,7 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- seerr.home.keithjackson.dev
|
- seerr.${BASE_DOMAIN}
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -11,11 +11,69 @@ Spec: [ADR-0004 (Flux)](../../../home-network-k3s/decisions/adr-0004-gitops-flux
|
|||||||
## Files
|
## Files
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `bootstrap.sh` | **The single bootstrap entrypoint** the Infra worker's final Ansible play calls. `flux install` → verify `sops-age` Secret → `kubectl apply -k clusters/dev`. |
|
| `bootstrap.sh` | **The single bootstrap entrypoint** the Infra worker's final Ansible play calls. render+apply the Flux controllers (`components/`) → verify `sops-age` Secret → `kubectl apply -k clusters/dev`. |
|
||||||
|
| `components/kustomization.yaml` | **Customization of the Flux CONTROLLERS themselves.** Overlays `flux install --export` output; currently enables `StrictPostBuildSubstitutions` on kustomize-controller. See "Customizing the controllers" below. |
|
||||||
| `patches/platform-sops-decryption.yaml` | Strategic-merge patch enabling `decryption.provider: sops` + `secretRef: sops-age` on the `platform` Kustomization (realises the base TODO). |
|
| `patches/platform-sops-decryption.yaml` | Strategic-merge patch enabling `decryption.provider: sops` + `secretRef: sops-age` on the `platform` Kustomization (realises the base TODO). |
|
||||||
| `patches/apps-sops-decryption.yaml` | Same, for the app-wave Kustomizations. There is **no single `apps` Kustomization** any more — the apps tier is now `apps-wave-0/1/2` + `apps-vms`, and SOPS decryption is layered on **per wave** (each wave Kustomization is patched by name in `clusters/dev`). |
|
| `patches/apps-sops-decryption.yaml` | Same, for the app-wave Kustomizations. There is **no single `apps` Kustomization** any more — the apps tier is now `apps-wave-0/1/2` + `apps-vms`, and SOPS decryption is layered on **per wave** (each wave Kustomization is patched by name in `clusters/dev`). |
|
||||||
| `patches/flux-system-source-url.yaml` | Overrides the `flux-system` GitRepository `url` placeholder with the real bootstrap source (template — fill at bootstrap). |
|
| `patches/flux-system-source-url.yaml` | Overrides the `flux-system` GitRepository `url` placeholder with the real bootstrap source (template — fill at bootstrap). |
|
||||||
|
|
||||||
|
## Customizing the controllers (`components/`) — and how it reaches a RUNNING cluster
|
||||||
|
|
||||||
|
Because this repo does not use `flux bootstrap`, the Flux controller Deployments are **not in git
|
||||||
|
and not reconciled by Flux**. Verified on the live dev cluster (2026-09-01):
|
||||||
|
|
||||||
|
```
|
||||||
|
$ kubectl -n flux-system get deploy kustomize-controller -o jsonpath='{.metadata.labels}'
|
||||||
|
{"app.kubernetes.io/component":"kustomize-controller","app.kubernetes.io/instance":"flux-system",
|
||||||
|
"app.kubernetes.io/part-of":"flux","app.kubernetes.io/version":"v2.4.0","control-plane":"controller"}
|
||||||
|
# no kustomize.toolkit.fluxcd.io/name label; deployment.kubernetes.io/revision: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
No `kustomize.toolkit.fluxcd.io/*` label => no Flux Kustomization owns it. **Nothing under
|
||||||
|
`clusters/**` can change a controller flag.** So `bootstrap.sh` step 1 uses the mechanism Flux
|
||||||
|
documents for hand-installed clusters ([Customize Flux
|
||||||
|
manifests](https://fluxcd.io/flux/installation/#customize-flux-manifests)):
|
||||||
|
|
||||||
|
```
|
||||||
|
flux install --export … -> <tmp>/gotk-components.yaml # regenerated from the pinned flux CLI
|
||||||
|
components/kustomization.yaml -> <tmp>/kustomization.yaml # the only hand-authored part (in git)
|
||||||
|
kubectl kustomize <tmp> | kubectl apply -f -
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing is vendored into the repo, so the component manifests can never drift from `FLUX_VERSION`
|
||||||
|
(`infra/devcontainer/versions.env`). Every future controller flag (vertical scaling, tolerations,
|
||||||
|
more feature gates) is another entry in `components/kustomization.yaml`.
|
||||||
|
|
||||||
|
### Rolling this out to the ALREADY-RUNNING dev cluster (operator/PM step — NOT automatic)
|
||||||
|
|
||||||
|
> **A `git push` does not deliver this.** Merging the change reconciles `clusters/**` only; the
|
||||||
|
> controller Deployment is outside Flux's inventory. Someone must run step 1.
|
||||||
|
|
||||||
|
Re-running `bootstrap.sh` is safe and idempotent, but if you want only step 1:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# inside infra/devcontainer (pinned flux 2.4.0 / kubectl 1.36.4), from the repo root
|
||||||
|
d=$(mktemp -d)
|
||||||
|
flux install --export --namespace=flux-system \
|
||||||
|
--components-extra=image-reflector-controller,image-automation-controller > "$d/gotk-components.yaml"
|
||||||
|
cp bootstrap/flux/components/kustomization.yaml "$d/kustomization.yaml"
|
||||||
|
kubectl kustomize "$d" | kubectl apply -f - # add --server-side --force-conflicts on a field-manager conflict
|
||||||
|
kubectl -n flux-system rollout status deploy/kustomize-controller
|
||||||
|
```
|
||||||
|
|
||||||
|
**Expect kustomize-controller to restart** (new arg => new pod template => new ReplicaSet). Any
|
||||||
|
reconcile in flight is interrupted and retried by the new pod; Flux is level-triggered, so nothing
|
||||||
|
is lost. Confirm afterwards:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
kubectl -n flux-system get deploy kustomize-controller \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].args}' # must contain --feature-gates=StrictPostBuildSubstitutions=true
|
||||||
|
flux get kustomizations -A # all 11 must return to Ready
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rollback** is the same command with `FLUX_INSTALL_PLAIN=1 bash bootstrap/flux/bootstrap.sh`, or
|
||||||
|
simply `flux install --namespace=flux-system --components-extra=…` (drops the arg back off).
|
||||||
|
|
||||||
## Why `flux install` + apply, not `flux bootstrap git` (Phase 1)
|
## Why `flux install` + apply, not `flux bootstrap git` (Phase 1)
|
||||||
|
|
||||||
The reconciliation graph is **already authored** in `clusters/base` (read-only to this worker):
|
The reconciliation graph is **already authored** in `clusters/base` (read-only to this worker):
|
||||||
|
|||||||
+60
-11
@@ -1,6 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# bootstrap.sh — the SINGLE Flux + SOPS bootstrap entrypoint for the dev cluster.
|
# bootstrap.sh — the SINGLE Flux + SOPS bootstrap entrypoint. ENVIRONMENT-AGNOSTIC: which overlay
|
||||||
|
# it applies is FLUX_ENV_PATH (default clusters/dev). roles/flux_bootstrap exports that from
|
||||||
|
# `flux_bootstrap_env_path`, overridden to clusters/prod in group_vars/k3s_prod/vars.yml — so the
|
||||||
|
# same script bootstraps dev and prod with no fork (R-ENV-02).
|
||||||
#
|
#
|
||||||
# Phase 1, T-1.4 · ADR-0004 (Flux) · ADR-0007 (SOPS+age) · R-GITOPS-01 · R-SEC-01.
|
# Phase 1, T-1.4 · ADR-0004 (Flux) · ADR-0007 (SOPS+age) · R-GITOPS-01 · R-SEC-01.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -33,7 +36,7 @@
|
|||||||
# 2. sops-age Secret — the operator installs the age PRIVATE key out-of-band BEFORE reconcile so
|
# 2. sops-age Secret — the operator installs the age PRIVATE key out-of-band BEFORE reconcile so
|
||||||
# decryption works on first pass (../sops/age-key-secret.md). This script
|
# decryption works on first pass (../sops/age-key-secret.md). This script
|
||||||
# only VERIFIES it exists; it never handles key material.
|
# only VERIFIES it exists; it never handles key material.
|
||||||
# 3. `kubectl apply -k clusters/dev` — materialises the authored reconciliation graph:
|
# 3. `kubectl apply -k ${FLUX_ENV_PATH}` — materialises the authored reconciliation graph:
|
||||||
# GitRepository `flux-system` (source) -> Kustomization `platform` ->
|
# GitRepository `flux-system` (source) -> Kustomization `platform` ->
|
||||||
# `platform-config` -> the app-wave Kustomizations `apps-wave-0/1/2`
|
# `platform-config` -> the app-wave Kustomizations `apps-wave-0/1/2`
|
||||||
# (chained dependsOn) + `apps-vms`, with cluster-vars overlay
|
# (chained dependsOn) + `apps-vms`, with cluster-vars overlay
|
||||||
@@ -52,7 +55,10 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# --- config (env-overridable) ------------------------------------------------
|
# --- config (env-overridable) ------------------------------------------------
|
||||||
FLUX_ENV_PATH="${FLUX_ENV_PATH:-clusters/dev}" # dev is the ONLY Phase-1 build target
|
# Which environment overlay to apply. Dev is the DEFAULT (it was the only Phase-1 build target);
|
||||||
|
# prod passes clusters/prod via roles/flux_bootstrap. Changing the default would silently retarget
|
||||||
|
# every existing caller — override it, never edit it.
|
||||||
|
FLUX_ENV_PATH="${FLUX_ENV_PATH:-clusters/dev}"
|
||||||
FLUX_NAMESPACE="${FLUX_NAMESPACE:-flux-system}"
|
FLUX_NAMESPACE="${FLUX_NAMESPACE:-flux-system}"
|
||||||
SOPS_SECRET_NAME="${SOPS_SECRET_NAME:-sops-age}"
|
SOPS_SECRET_NAME="${SOPS_SECRET_NAME:-sops-age}"
|
||||||
# The two Phase-4 image-automation controllers are installed alongside the default set so their CRDs
|
# The two Phase-4 image-automation controllers are installed alongside the default set so their CRDs
|
||||||
@@ -62,23 +68,66 @@ FLUX_COMPONENTS_EXTRA="${FLUX_COMPONENTS_EXTRA:-image-reflector-controller,image
|
|||||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
repo_root="$(git -C "${script_dir}" rev-parse --show-toplevel 2>/dev/null || (cd "${script_dir}/../.." && pwd))"
|
repo_root="$(git -C "${script_dir}" rev-parse --show-toplevel 2>/dev/null || (cd "${script_dir}/../.." && pwd))"
|
||||||
|
|
||||||
echo "==> Flux + SOPS bootstrap (dev) — path=${FLUX_ENV_PATH}"
|
echo "==> Flux + SOPS bootstrap — path=${FLUX_ENV_PATH}"
|
||||||
|
|
||||||
# --- 0. tooling present (from infra/devcontainer; versions pinned there) ------
|
# --- 0. tooling present (from infra/devcontainer; versions pinned there) ------
|
||||||
for bin in flux kubectl; do
|
for bin in flux kubectl; do
|
||||||
command -v "$bin" >/dev/null 2>&1 || { echo "ERROR: '$bin' not found (use infra/devcontainer)." >&2; exit 1; }
|
command -v "$bin" >/dev/null 2>&1 || { echo "ERROR: '$bin' not found (use infra/devcontainer)." >&2; exit 1; }
|
||||||
done
|
done
|
||||||
|
|
||||||
# --- 1. install Flux controllers ---------------------------------------------
|
# --- 1. install Flux controllers (rendered through ./components) --------------
|
||||||
# Default controllers + the image-reflector/image-automation pair (Phase-4 hands-off updates,
|
# Default controllers + the image-reflector/image-automation pair (Phase-4 hands-off updates,
|
||||||
# ADR-0009). `--components-extra` regenerates flux-system with all controllers and registers the
|
# ADR-0009). `--components-extra` regenerates flux-system with all controllers and registers the
|
||||||
# image.toolkit.fluxcd.io CRDs before any reconcile — so platform/image-automation's CR applies cleanly.
|
# image.toolkit.fluxcd.io CRDs before any reconcile — so platform/image-automation's CR applies cleanly.
|
||||||
if [ -n "${FLUX_COMPONENTS_EXTRA}" ]; then
|
#
|
||||||
echo "==> [1/3] flux install (default controllers + extra: ${FLUX_COMPONENTS_EXTRA})"
|
# We do NOT run `flux install` directly any more. The controllers need CUSTOMIZING (currently:
|
||||||
flux install --namespace="${FLUX_NAMESPACE}" --components-extra="${FLUX_COMPONENTS_EXTRA}"
|
# --feature-gates=StrictPostBuildSubstitutions=true on kustomize-controller, so an undefined
|
||||||
|
# postBuild variable FAILS the Kustomization instead of silently rendering an empty string — see
|
||||||
|
# ./components/kustomization.yaml and the 2026-09-01 INCIDENT entry in DECISIONS-LOG.md).
|
||||||
|
#
|
||||||
|
# `flux install` has no flag for that, and because this repo does not use `flux bootstrap` the
|
||||||
|
# controllers are not in git for a Flux Kustomization to patch. So we use the mechanism Flux
|
||||||
|
# documents for hand-installed clusters ("Customize Flux manifests"):
|
||||||
|
#
|
||||||
|
# flux install --export > gotk-components.yaml # exact manifests `flux install` would apply
|
||||||
|
# kustomize overlay = ./components/kustomization.yaml (checked in — the ONLY hand-authored part)
|
||||||
|
# kubectl apply -k
|
||||||
|
#
|
||||||
|
# The export is regenerated from the PINNED flux CLI on every run (FLUX_VERSION,
|
||||||
|
# infra/devcontainer/versions.env), so nothing is vendored and nothing can drift. The render dir is
|
||||||
|
# a temp dir — the repo tree is never written to.
|
||||||
|
#
|
||||||
|
# ESCAPE HATCH: FLUX_INSTALL_PLAIN=1 falls back to a bare `flux install` (no customization, so NO
|
||||||
|
# strict substitution). Use only to unblock a bring-up if the render path misbehaves, and say so.
|
||||||
|
echo "==> [1/3] installing Flux controllers${FLUX_COMPONENTS_EXTRA:+ (+ extra: ${FLUX_COMPONENTS_EXTRA})}"
|
||||||
|
flux_install_args=(--namespace="${FLUX_NAMESPACE}")
|
||||||
|
[ -n "${FLUX_COMPONENTS_EXTRA}" ] && flux_install_args+=(--components-extra="${FLUX_COMPONENTS_EXTRA}")
|
||||||
|
|
||||||
|
if [ "${FLUX_INSTALL_PLAIN:-0}" = "1" ]; then
|
||||||
|
echo " WARNING: FLUX_INSTALL_PLAIN=1 — controller customization SKIPPED."
|
||||||
|
echo " StrictPostBuildSubstitutions will NOT be enabled; substitution fails OPEN."
|
||||||
|
flux install "${flux_install_args[@]}"
|
||||||
else
|
else
|
||||||
echo "==> [1/3] flux install (default controllers only)"
|
components_dir="${script_dir}/components"
|
||||||
flux install --namespace="${FLUX_NAMESPACE}"
|
[ -f "${components_dir}/kustomization.yaml" ] || {
|
||||||
|
echo "ERROR: ${components_dir}/kustomization.yaml missing — refusing to install uncustomized." >&2
|
||||||
|
echo " (Override with FLUX_INSTALL_PLAIN=1 only if you accept fail-OPEN substitution.)" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
render_dir="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${render_dir}"' EXIT
|
||||||
|
echo " rendering flux install --export through ${components_dir}"
|
||||||
|
flux install --export "${flux_install_args[@]}" > "${render_dir}/gotk-components.yaml"
|
||||||
|
cp "${components_dir}/kustomization.yaml" "${render_dir}/kustomization.yaml"
|
||||||
|
# Fail fast if the overlay cannot build (e.g. a patch target that no longer exists after a Flux
|
||||||
|
# version bump) — better a hard stop than silently installing without the feature gate.
|
||||||
|
kubectl kustomize "${render_dir}" > "${render_dir}/rendered.yaml"
|
||||||
|
grep -q -- '--feature-gates=StrictPostBuildSubstitutions=true' "${render_dir}/rendered.yaml" || {
|
||||||
|
echo "ERROR: the StrictPostBuildSubstitutions patch did not apply to the rendered manifests." >&2
|
||||||
|
echo " Check ./components/kustomization.yaml against this flux CLI version." >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
kubectl apply -f "${render_dir}/rendered.yaml"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 2. verify the out-of-band age key Secret exists -------------------------
|
# --- 2. verify the out-of-band age key Secret exists -------------------------
|
||||||
@@ -91,7 +140,7 @@ if ! kubectl -n "${FLUX_NAMESPACE}" get secret "${SOPS_SECRET_NAME}" >/dev/null
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- 3. apply the authored dev entrypoint ------------------------------------
|
# --- 3. apply the authored environment entrypoint ----------------------------
|
||||||
echo "==> [3/3] kubectl apply -k ${FLUX_ENV_PATH}"
|
echo "==> [3/3] kubectl apply -k ${FLUX_ENV_PATH}"
|
||||||
kubectl apply -k "${repo_root}/${FLUX_ENV_PATH}"
|
kubectl apply -k "${repo_root}/${FLUX_ENV_PATH}"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
---
|
||||||
|
# Controller-level customization of the Flux CONTROLLERS themselves (the `flux-system` Deployments).
|
||||||
|
#
|
||||||
|
# WHY THIS DIRECTORY EXISTS
|
||||||
|
# =========================
|
||||||
|
# This repo does NOT use `flux bootstrap` (see ../README.md "Why `flux install` + apply"): the
|
||||||
|
# reconciliation graph is authored by hand in clusters/base, and the controllers are installed
|
||||||
|
# imperatively by `flux install`. Consequence, verified on the live dev cluster 2026-09-01:
|
||||||
|
#
|
||||||
|
# kubectl -n flux-system get deploy kustomize-controller -o jsonpath='{.metadata.labels}'
|
||||||
|
# -> NO kustomize.toolkit.fluxcd.io/name label, deployment.kubernetes.io/revision: 1
|
||||||
|
#
|
||||||
|
# i.e. **the controllers are not managed by Flux and are not in git.** There was therefore no
|
||||||
|
# existing mechanism to customize them, and no amount of editing under clusters/** can reach them.
|
||||||
|
#
|
||||||
|
# This is the mechanism Flux documents for exactly that situation ("Customize Flux manifests" —
|
||||||
|
# https://fluxcd.io/flux/installation/#customize-flux-manifests): export the component manifests
|
||||||
|
# with `flux install --export`, overlay them with kustomize `patches:`, and apply the result.
|
||||||
|
# `../bootstrap.sh` step 1 does that render — it copies THIS file next to a freshly exported
|
||||||
|
# `gotk-components.yaml` in a temp dir and runs `kubectl apply -k`. Nothing is generated into the
|
||||||
|
# repo, so the manifests can never drift from the pinned FLUX_VERSION
|
||||||
|
# (infra/devcontainer/versions.env) the way a vendored copy would.
|
||||||
|
#
|
||||||
|
# Every future controller customization (vertical scaling, tolerations, extra feature gates,
|
||||||
|
# --concurrent) belongs HERE, as another entry under `patches:`.
|
||||||
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
|
kind: Kustomization
|
||||||
|
resources:
|
||||||
|
# Written by `bootstrap.sh` from `flux install --export ...` at render time. NOT checked in.
|
||||||
|
- gotk-components.yaml
|
||||||
|
patches:
|
||||||
|
# -------------------------------------------------------------------------------------------
|
||||||
|
# StrictPostBuildSubstitutions — make postBuild substitution FAIL CLOSED.
|
||||||
|
#
|
||||||
|
# Default behaviour is fail-OPEN: a `${VAR}` that is not defined in `cluster-vars` renders as the
|
||||||
|
# EMPTY STRING, silently. On 2026-09-01 that shipped `k8sServiceHost: ""` into the Cilium
|
||||||
|
# HelmRelease and put 2 of 3 agents into Init:Error (DECISIONS-LOG, INCIDENT entry). With this
|
||||||
|
# gate the undefined variable FAILS the Kustomization instead, and Flux keeps the last-good state.
|
||||||
|
#
|
||||||
|
# PROVEN SAFE before enabling: every one of the 11 substituted paths was rendered with
|
||||||
|
# `kubectl kustomize --load-restrictor LoadRestrictionsNone <path> | flux envsubst --strict` and
|
||||||
|
# exited 0, with a negative control reproducing the incident. The permanent guard that keeps it
|
||||||
|
# true is the L0 gate conformance/tests/l0-static/flux-postbuild-substitution/.
|
||||||
|
#
|
||||||
|
# NOTE the gate does NOT catch default-providing forms (`${VAR:-d}`, `${VAR:?msg}`, `${VAR:=v}`) —
|
||||||
|
# Flux exempts them by design. That class is what the L0 gate covers; the two are complementary.
|
||||||
|
#
|
||||||
|
# This also pre-pays for the Flux 2.4.0 -> 2.9.x upgrade: strict substitution became DEFAULT-ON in
|
||||||
|
# kustomize-controller v1.9.0, so the upgrade flips this behaviour whether or not we opt in.
|
||||||
|
#
|
||||||
|
# JSON-6902 `add ... /args/-` (append), NOT a strategic merge: a container's `args` is an atomic
|
||||||
|
# list, so a strategic-merge patch would REPLACE every arg `flux install` generates
|
||||||
|
# (--events-addr, --watch-all-namespaces, --log-level, --log-encoding, --enable-leader-election)
|
||||||
|
# and silently drop any the CLI adds in a future version.
|
||||||
|
- target:
|
||||||
|
group: apps
|
||||||
|
version: v1
|
||||||
|
kind: Deployment
|
||||||
|
name: kustomize-controller
|
||||||
|
namespace: flux-system
|
||||||
|
patch: |-
|
||||||
|
- op: add
|
||||||
|
path: /spec/template/spec/containers/0/args/-
|
||||||
|
value: --feature-gates=StrictPostBuildSubstitutions=true
|
||||||
@@ -1,11 +1,30 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# bootstrap/gitea/bootstrap-gitea.sh — run a SINGLE Gitea container on the HOST (outside the
|
# bootstrap/gitea/bootstrap-gitea.sh — run a SINGLE Gitea container on the HOST (outside the
|
||||||
# cluster) as the Phase-1 GitOps bootstrap source. While the cluster is built, Flux (in-cluster)
|
# cluster) as the GitOps bootstrap source. While a cluster is built, Flux (in-cluster) reconciles
|
||||||
# reconciles this repo from the host Gitea over the k3s-dev NAT gateway. This is the "local
|
# this repo from the host Gitea. This is the "local bootstrap source" that migrates to in-cluster
|
||||||
# bootstrap source" that migrates to in-cluster Gitea in Phase 6 (docs/05 "Source-of-truth",
|
# Gitea at cutover (docs/05 "Source-of-truth", CONTRIBUTING.md). Dev has already cut over; PROD is
|
||||||
# CONTRIBUTING.md).
|
# using it now.
|
||||||
#
|
#
|
||||||
# Run by the OPERATOR at bring-up — NOT during author-only, NOT by any agent.
|
# HOW THE CLUSTER REACHES THE HOST — read this before setting HOST_NAT_IP.
|
||||||
|
# It is an ADDRESS OF THIS HOST as seen from the cluster nodes, and it differs per environment:
|
||||||
|
#
|
||||||
|
# PROD (bare metal, current use): HOST_NAT_IP=192.168.2.132
|
||||||
|
# The prod nodes (192.168.2.10/.11/.12) and this workstation are on the SAME LAN. There is
|
||||||
|
# NO NAT hop of any kind, and NO firewall change was needed — verified 2026-09-04 from all
|
||||||
|
# three nodes. The firewalld note further down is a DEV-ERA libvirt concern; it does not
|
||||||
|
# apply to prod, so do not "fix" prod by opening zones.
|
||||||
|
#
|
||||||
|
# DEV (libvirt VMs, HISTORICAL): HOST_NAT_IP=10.73.0.1 was the k3s-dev libvirt NAT gateway.
|
||||||
|
# *** THAT NETWORK IS RETIRED. 10.73.0.1 is stale and must not be reused. *** Dev's own
|
||||||
|
# bootstrap Gitea was later reached on 192.168.2.103:3000 and is retained STOPPED as
|
||||||
|
# recovery (see the comment on the GitRepository patch in clusters/dev/kustomization.yaml).
|
||||||
|
#
|
||||||
|
# WHO RUNS THIS: the OPERATOR, or an agent the operator has EXPLICITLY DIRECTED to stand up a
|
||||||
|
# bootstrap source for a specific bring-up (as happened 2026-09-04 for prod). It is NOT part of
|
||||||
|
# author-only work and must never be fired off casually or "to see if it works": it starts a
|
||||||
|
# long-lived container, generates and writes admin credentials to $HOME, creates an org and repo,
|
||||||
|
# and PUSHES THE CURRENT HEAD to that repo's `main` — which is the revision a cluster pointed at
|
||||||
|
# it will then reconcile. Know which HEAD you are publishing before you run it.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
ENGINE="${ENGINE:-podman}"
|
ENGINE="${ENGINE:-podman}"
|
||||||
GITEA_IMAGE="${GITEA_IMAGE:-docker.io/gitea/gitea:1.22}" # pinned; bump if needed
|
GITEA_IMAGE="${GITEA_IMAGE:-docker.io/gitea/gitea:1.22}" # pinned; bump if needed
|
||||||
@@ -16,11 +35,24 @@ SSH_PORT="${GITEA_SSH_PORT:-2222}"
|
|||||||
# subuid that can't write a host-owned dir, so Gitea fails to create /data/gitea/conf/app.ini.
|
# subuid that can't write a host-owned dir, so Gitea fails to create /data/gitea/conf/app.ini.
|
||||||
DATA_VOLUME="${GITEA_DATA_VOLUME:-gitea-bootstrap-data}"
|
DATA_VOLUME="${GITEA_DATA_VOLUME:-gitea-bootstrap-data}"
|
||||||
DATA_DIR="podman volume ${DATA_VOLUME}" # display label only (data lives in the named volume)
|
DATA_DIR="podman volume ${DATA_VOLUME}" # display label only (data lives in the named volume)
|
||||||
# The cluster VMs reach the host on the k3s-dev NAT gateway (10.73.0.1). ROOT_URL must match so
|
# The address the CLUSTER uses to reach this host (see the header). ROOT_URL must match it so
|
||||||
# clone/redirect URLs are correct from inside the cluster.
|
# clone/redirect URLs are correct from inside the cluster.
|
||||||
# NOTE: firewalld's `libvirt` zone (owns virbrN) blocks VM->host:3000 by default. Open it once:
|
#
|
||||||
|
# The DEFAULT IS DELIBERATELY UNSET — there is no correct default any more. The old default
|
||||||
|
# (10.73.0.1) was the retired k3s-dev libvirt NAT gateway; leaving it in place would silently
|
||||||
|
# publish a ROOT_URL no cluster can resolve, and Flux would report what looks like a network fault.
|
||||||
|
# Pass it explicitly, e.g. for prod: HOST_NAT_IP=192.168.2.132 bash bootstrap-gitea.sh
|
||||||
|
#
|
||||||
|
# NOTE (DEV/libvirt ONLY, historical): firewalld's `libvirt` zone (owns virbrN) blocked
|
||||||
|
# VM->host:3000 by default and had to be opened once with
|
||||||
# sudo firewall-cmd --zone=libvirt --add-port=3000/tcp --permanent && sudo firewall-cmd --reload
|
# sudo firewall-cmd --zone=libvirt --add-port=3000/tcp --permanent && sudo firewall-cmd --reload
|
||||||
HOST_NAT_IP="${HOST_NAT_IP:-10.73.0.1}"
|
# PROD needs NO firewall change: same-LAN, no libvirt zone involved (verified 2026-09-04).
|
||||||
|
if [ -z "${HOST_NAT_IP:-}" ]; then
|
||||||
|
echo "ERROR: HOST_NAT_IP is not set — it is this host's address AS SEEN FROM THE CLUSTER." >&2
|
||||||
|
echo " prod: HOST_NAT_IP=192.168.2.132 (same LAN as the nodes, no NAT, no firewall change)" >&2
|
||||||
|
echo " There is no safe default: the old one (10.73.0.1) is the RETIRED libvirt NAT." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
ORG="${GITEA_ORG:-Home-Network}"
|
ORG="${GITEA_ORG:-Home-Network}"
|
||||||
REPO="${GITEA_REPO:-home-network-k3s-impl}"
|
REPO="${GITEA_REPO:-home-network-k3s-impl}"
|
||||||
|
|
||||||
@@ -75,8 +107,17 @@ cat <<EOF
|
|||||||
|
|
||||||
Gitea bootstrap source ready (${DATA_DIR}).
|
Gitea bootstrap source ready (${DATA_DIR}).
|
||||||
UI (host): ${BASE}/${ORG}/${REPO}
|
UI (host): ${BASE}/${ORG}/${REPO}
|
||||||
Flux GitRepository (cluster): http://${HOST_NAT_IP}:${HTTP_PORT}/${ORG}/${REPO}.git (clusters/dev)
|
Flux GitRepository (cluster): http://${HOST_NAT_IP}:${HTTP_PORT}/${ORG}/${REPO}.git
|
||||||
|
(patch this url into the overlay being bootstrapped:
|
||||||
|
clusters/prod today; clusters/dev has already cut over)
|
||||||
Admin creds (host, 0600): ${ADMIN_CRED_FILE}
|
Admin creds (host, 0600): ${ADMIN_CRED_FILE}
|
||||||
Prerequisite: firewalld must allow VM->host:${HTTP_PORT} —
|
Reachability: prod nodes reach ${HOST_NAT_IP}:${HTTP_PORT} directly on the LAN — no NAT, no
|
||||||
|
firewall change (verified 2026-09-04). For libvirt-based clusters ONLY, firewalld's libvirt zone
|
||||||
|
must allow VM->host:${HTTP_PORT} —
|
||||||
ansible-playbook infra/ansible/playbooks/host-prereqs.yml --ask-become-pass
|
ansible-playbook infra/ansible/playbooks/host-prereqs.yml --ask-become-pass
|
||||||
|
REMINDER: the push above published the CURRENT HEAD as this repo's \`main\`. Whatever cluster points
|
||||||
|
at this source will reconcile THAT revision.
|
||||||
|
TEMPORARY BY DESIGN: this container is outside every cluster and is a single point of failure for
|
||||||
|
any cluster reconciling from it. Cut over to in-cluster Gitea (docs/05); for prod the cutover
|
||||||
|
procedure is written on the GitRepository patch in clusters/prod/kustomization.yaml.
|
||||||
EOF
|
EOF
|
||||||
@@ -28,6 +28,16 @@ inherit every guarantee automatically.
|
|||||||
`auth.groups`; `native`/`none` are documented exceptions only.
|
`auth.groups`; `native`/`none` are documented exceptions only.
|
||||||
- Irreplaceable state (`class: longhorn` configs/DBs) must set `backup.policy` to an off-cluster
|
- Irreplaceable state (`class: longhorn` configs/DBs) must set `backup.policy` to an off-cluster
|
||||||
schedule — never `reacquirable` (R-STATE-03). Only bulk media may be `reacquirable`.
|
schedule — never `reacquirable` (R-STATE-03). Only bulk media may be `reacquirable`.
|
||||||
|
- **Shell braces must be escaped `$${...}`.** Anything rendered verbatim onto the workload — a
|
||||||
|
`lifecycle` postStart script, an `env` value — lands under `apps/wave-<N>/`, and every
|
||||||
|
`apps-wave-<N>` Flux Kustomization declares `postBuild.substituteFrom: cluster-vars`. Flux runs
|
||||||
|
envsubst over the WHOLE manifest at reconcile time, so an unescaped `${VAR:-}` meant for the
|
||||||
|
*shell* is replaced with the empty string before the container ever starts (this silently killed
|
||||||
|
gitea's and nextcloud's OIDC registration for days). Bare `$VAR` and `$(cmd)` are left alone and
|
||||||
|
need no escaping; a genuine environment knob is declared in `clusters/base/cluster-vars.yaml` and
|
||||||
|
written bare `${KNOB}`. Full contract: [`../clusters/base/apps-vms.yaml`](../clusters/base/apps-vms.yaml).
|
||||||
|
`render.py` REFUSES to emit a manifest that breaks this rule (Flux's *strict* substitution mode
|
||||||
|
cannot: it deliberately exempts the `${V:-}` / `${V:=x}` / `${V:?}` default-operator forms).
|
||||||
|
|
||||||
## Validate before commit — single command, no cluster (R-TEST-02)
|
## Validate before commit — single command, no cluster (R-TEST-02)
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+24
-6
@@ -24,9 +24,9 @@ env:
|
|||||||
name: gitea-bootstrap
|
name: gitea-bootstrap
|
||||||
key: secret-key
|
key: secret-key
|
||||||
- name: GITEA__server__DOMAIN # correct public clone/redirect URLs for the TLS-terminated host
|
- name: GITEA__server__DOMAIN # correct public clone/redirect URLs for the TLS-terminated host
|
||||||
value: gitea.home.keithjackson.dev
|
value: gitea.${BASE_DOMAIN}
|
||||||
- name: GITEA__server__ROOT_URL
|
- name: GITEA__server__ROOT_URL
|
||||||
value: https://gitea.home.keithjackson.dev/
|
value: https://gitea.${BASE_DOMAIN}/
|
||||||
- name: GITEA__actions__ENABLED # Phase 6 CI/CD: declared (not implicit upstream default) so the
|
- name: GITEA__actions__ENABLED # Phase 6 CI/CD: declared (not implicit upstream default) so the
|
||||||
value: "true" # in-cluster act_runner (ns gitea-actions) registers against it
|
value: "true" # in-cluster act_runner (ns gitea-actions) registers against it
|
||||||
- name: GITEA__actions__DEFAULT_ACTIONS_URL # resolve `uses: <org>/<action>@ref` from github.com (cluster
|
- name: GITEA__actions__DEFAULT_ACTIONS_URL # resolve `uses: <org>/<action>@ref` from github.com (cluster
|
||||||
@@ -104,12 +104,24 @@ lifecycle:
|
|||||||
# persisted and retries the whole cycle, and (4) on give-up logs LOUDLY to stderr AND a marker
|
# persisted and retries the whole cycle, and (4) on give-up logs LOUDLY to stderr AND a marker
|
||||||
# file on the persistent /data volume WITHOUT crashlooping the pod (break-glass admin stays
|
# file on the persistent /data volume WITHOUT crashlooping the pod (break-glass admin stays
|
||||||
# usable). The verify-then-retry loop is the real guarantee against "exit 0 but nothing stuck".
|
# usable). The verify-then-retry loop is the real guarantee against "exit 0 but nothing stuck".
|
||||||
GITEA_DISCOVERY_URL=https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration
|
GITEA_DISCOVERY_URL=https://keycloak.${BASE_DOMAIN}/realms/homelab/.well-known/openid-configuration
|
||||||
gitea_oidc_fail() {
|
gitea_oidc_fail() {
|
||||||
echo "[gitea-oidc] FAILED to register 'keycloak' login source: $1" >&2
|
echo "[gitea-oidc] FAILED to register 'keycloak' login source: $1" >&2
|
||||||
{ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [gitea-oidc] FAILED: $1"; } >> /data/gitea-oidc-bootstrap.log 2>/dev/null || true
|
{ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [gitea-oidc] FAILED: $1"; } >> /data/gitea-oidc-bootstrap.log 2>/dev/null || true
|
||||||
}
|
}
|
||||||
if [ -z "${GITEA_OIDC_CLIENT_SECRET:-}" ]; then
|
# NOTE the `$$` escape below — it is load-bearing. apps-wave-0 declares postBuild.
|
||||||
|
# substituteFrom cluster-vars, so Flux envsubst rewrites this whole manifest at RECONCILE
|
||||||
|
# time, before any container runs. Written unescaped, `$${GITEA_OIDC_CLIENT_SECRET:-}` is not
|
||||||
|
# a cluster-var, so envsubst blanked it and the guard reconciled as `[ -z "" ]` — ALWAYS TRUE
|
||||||
|
# — making every line below (discovery wait, add/update-oauth, verify-then-retry) DEAD CODE on
|
||||||
|
# every boot since 8738871 (2026-08-27). SSO kept working only because the login source was
|
||||||
|
# already in the DB; a PVC rebuild or fresh cluster would have come up with NO OIDC at all.
|
||||||
|
# Flux STRICT substitution cannot catch this — it EXEMPTS default-operator forms by design.
|
||||||
|
# `$${VAR}` is the escape: envsubst emits it back as a plain brace expansion for the shell.
|
||||||
|
# Bare `$VAR` and `$(cmd)` are left alone and need NO escaping — which is why the
|
||||||
|
# `$GITEA_OIDC_CLIENT_SECRET` uses below always worked and only the guards broke.
|
||||||
|
# Contract: clusters/base/apps-vms.yaml. Enforced at render time by catalog/render.py.
|
||||||
|
if [ -z "$${GITEA_OIDC_CLIENT_SECRET:-}" ]; then
|
||||||
echo "[gitea-oidc] GITEA_OIDC_CLIENT_SECRET unset; skipping OIDC source (break-glass admin only)"
|
echo "[gitea-oidc] GITEA_OIDC_CLIENT_SECRET unset; skipping OIDC source (break-glass admin only)"
|
||||||
else
|
else
|
||||||
# (1) Wait up to ~90s for the Keycloak discovery doc to be reachable (HTTP 200).
|
# (1) Wait up to ~90s for the Keycloak discovery doc to be reachable (HTTP 200).
|
||||||
@@ -150,7 +162,13 @@ lifecycle:
|
|||||||
gitea_oidc_fail "add/update-oauth ran but the 'keycloak' source did not persist after retries"
|
gitea_oidc_fail "add/update-oauth ran but the 'keycloak' source did not persist after retries"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
exposure: public # edge-forwarded git host at gitea.home.keithjackson.dev (docs/09; R-DNS/R-NET)
|
# A postStart hook MUST exit 0 — a non-zero exit makes the kubelet KILL the container
|
||||||
|
# (FailedPostStartHook -> CrashLoopBackOff). This hook already ends 0 on both paths, but
|
||||||
|
# it is made EXPLICIT so the guarantee does not depend on whatever statement happens to
|
||||||
|
# be last. nextcloud's equivalent hook did depend on that and crashlooped on 2026-09-01,
|
||||||
|
# the moment its OIDC guard was fixed and the block actually ran for the first time.
|
||||||
|
exit 0
|
||||||
|
exposure: public # edge-forwarded git host at gitea.${BASE_DOMAIN} (docs/09; R-DNS/R-NET)
|
||||||
egress: internet # normal WAN egress (docs/03; R-NET-05)
|
egress: internet # normal WAN egress (docs/03; R-NET-05)
|
||||||
storage:
|
storage:
|
||||||
- name: data
|
- name: data
|
||||||
@@ -160,7 +178,7 @@ storage:
|
|||||||
# the default /data/<name> would strand real state on the ephemeral rootfs (R-STATE-01)
|
# the default /data/<name> would strand real state on the ephemeral rootfs (R-STATE-01)
|
||||||
dependencies: [] # default-deny: reaches only DNS; git clients reach it north-south via the Gateway (R-NET-01/02)
|
dependencies: [] # default-deny: reaches only DNS; git clients reach it north-south via the Gateway (R-NET-01/02)
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: gitea.home.keithjackson.dev
|
- host: gitea.${BASE_DOMAIN}
|
||||||
port: 3000 # Gitea HTTP web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
port: 3000 # Gitea HTTP web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
||||||
auth:
|
auth:
|
||||||
mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC" set; R-IDP-02)
|
mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC" set; R-IDP-02)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ name: jackett
|
|||||||
kind: container
|
kind: container
|
||||||
source:
|
source:
|
||||||
image: lscr.io/linuxserver/jackett:latest
|
image: lscr.io/linuxserver/jackett:latest
|
||||||
exposure: internal # LAN-only web UI at jackett.home.keithjackson.dev (docs/13 row 4; R-DNS/R-NET)
|
exposure: internal # LAN-only web UI at jackett.${BASE_DOMAIN} (docs/13 row 4; R-DNS/R-NET)
|
||||||
egress: vpn # indexer scraping via node PIA tunnel + kill-switch (docs/03; ADR-0015; R-NET-05/06)
|
egress: vpn # indexer scraping via node PIA tunnel + kill-switch (docs/03; ADR-0015; R-NET-05/06)
|
||||||
storage:
|
storage:
|
||||||
- name: config
|
- name: config
|
||||||
@@ -25,7 +25,7 @@ storage:
|
|||||||
class: longhorn # indexer definitions/API keys on 3-replica Longhorn (docs/04; R-STATE-02)
|
class: longhorn # indexer definitions/API keys on 3-replica Longhorn (docs/04; R-STATE-02)
|
||||||
dependencies: [] # default-deny: reaches only DNS; arr reach it north of its own egress (docs/03; R-NET-01/02)
|
dependencies: [] # default-deny: reaches only DNS; arr reach it north of its own egress (docs/03; R-NET-01/02)
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: jackett.home.keithjackson.dev
|
- host: jackett.${BASE_DOMAIN}
|
||||||
port: 9117 # Jackett WebUI; protected at the Gateway by forward-auth (docs/09, docs/15)
|
port: 9117 # Jackett WebUI; protected at the Gateway by forward-auth (docs/09, docs/15)
|
||||||
auth:
|
auth:
|
||||||
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no usable native OIDC (docs/15; R-IDP-02)
|
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no usable native OIDC (docs/15; R-IDP-02)
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ dependencies:
|
|||||||
port: 5432
|
port: 5432
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: keycloak.home.keithjackson.dev
|
- host: keycloak.${BASE_DOMAIN}
|
||||||
port: 8080 # HTTP listener; TLS terminates at the Gateway (docs/08, docs/09)
|
port: 8080 # HTTP listener; TLS terminates at the Gateway (docs/08, docs/09)
|
||||||
auth:
|
auth:
|
||||||
mode: native # it IS the IdP; own admin login is local + MFA (docs/15; ADR-0014). Documented exception.
|
mode: native # it IS the IdP; own admin login is local + MFA (docs/15; ADR-0014). Documented exception.
|
||||||
|
|||||||
+29
-7
@@ -50,13 +50,13 @@ env:
|
|||||||
# the vhost answer for the public FQDN; OVERWRITE* make Nextcloud emit https:// self-URLs even
|
# the vhost answer for the public FQDN; OVERWRITE* make Nextcloud emit https:// self-URLs even
|
||||||
# though it speaks plain HTTP behind the proxy. ---
|
# though it speaks plain HTTP behind the proxy. ---
|
||||||
- name: NEXTCLOUD_TRUSTED_DOMAINS
|
- name: NEXTCLOUD_TRUSTED_DOMAINS
|
||||||
value: nextcloud.home.keithjackson.dev
|
value: nextcloud.${BASE_DOMAIN}
|
||||||
- name: OVERWRITEHOST
|
- name: OVERWRITEHOST
|
||||||
value: nextcloud.home.keithjackson.dev
|
value: nextcloud.${BASE_DOMAIN}
|
||||||
- name: OVERWRITEPROTOCOL
|
- name: OVERWRITEPROTOCOL
|
||||||
value: https
|
value: https
|
||||||
- name: OVERWRITECLIURL
|
- name: OVERWRITECLIURL
|
||||||
value: https://nextcloud.home.keithjackson.dev
|
value: https://nextcloud.${BASE_DOMAIN}
|
||||||
# --- Keycloak native OIDC SSO (auth.mode oidc; docs/15 "Native OIDC"; R-IDP-02/06). The confidential
|
# --- Keycloak native OIDC SSO (auth.mode oidc; docs/15 "Native OIDC"; R-IDP-02/06). The confidential
|
||||||
# `nextcloud` realm client's secret is mirrored into this namespace's nextcloud-bootstrap Secret
|
# `nextcloud` realm client's secret is mirrored into this namespace's nextcloud-bootstrap Secret
|
||||||
# (oidc-client-secret; same value SOPS-held in keycloak-realm-clients). The postStart hook reads it
|
# (oidc-client-secret; same value SOPS-held in keycloak-realm-clients). The postStart hook reads it
|
||||||
@@ -88,7 +88,7 @@ lifecycle:
|
|||||||
# provider call below carries a MULTI-WORD value (--scope 'openid email profile') and is run as
|
# provider call below carries a MULTI-WORD value (--scope 'openid email profile') and is run as
|
||||||
# ONE su -c with the scope single-quoted, so the inner shell keeps it a single argument.
|
# ONE su -c with the scope single-quoted, so the inner shell keeps it a single argument.
|
||||||
OCC() { su -s /bin/sh www-data -c "php /var/www/html/occ $*"; }
|
OCC() { su -s /bin/sh www-data -c "php /var/www/html/occ $*"; }
|
||||||
NEXTCLOUD_DISCOVERY_URL=https://keycloak.home.keithjackson.dev/realms/homelab/.well-known/openid-configuration
|
NEXTCLOUD_DISCOVERY_URL=https://keycloak.${BASE_DOMAIN}/realms/homelab/.well-known/openid-configuration
|
||||||
nextcloud_oidc_fail() {
|
nextcloud_oidc_fail() {
|
||||||
echo "[nextcloud-oidc] FAILED to configure 'keycloak' provider: $1" >&2
|
echo "[nextcloud-oidc] FAILED to configure 'keycloak' provider: $1" >&2
|
||||||
{ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [nextcloud-oidc] FAILED: $1"; } >> /var/www/html/nextcloud-oidc-bootstrap.log 2>/dev/null || true
|
{ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [nextcloud-oidc] FAILED: $1"; } >> /var/www/html/nextcloud-oidc-bootstrap.log 2>/dev/null || true
|
||||||
@@ -111,7 +111,19 @@ lifecycle:
|
|||||||
# every start so it survives pod rolls / PVC rebuilds. Confirmed root cause 2026-08-27.
|
# every start so it survives pod rolls / PVC rebuilds. Confirmed root cause 2026-08-27.
|
||||||
OCC config:system:set allow_local_remote_servers --value=true --type=boolean >/dev/null 2>&1 \
|
OCC config:system:set allow_local_remote_servers --value=true --type=boolean >/dev/null 2>&1 \
|
||||||
|| echo "[nextcloud-oidc] WARN: failed to set allow_local_remote_servers" >&2
|
|| echo "[nextcloud-oidc] WARN: failed to set allow_local_remote_servers" >&2
|
||||||
if [ -z "${NEXTCLOUD_OIDC_CLIENT_SECRET:-}" ]; then
|
# NOTE the `$$` escape below — it is load-bearing. apps-wave-1 declares postBuild.
|
||||||
|
# substituteFrom cluster-vars, so Flux envsubst rewrites this whole manifest at RECONCILE
|
||||||
|
# time, before any container runs. Written unescaped, `$${NEXTCLOUD_OIDC_CLIENT_SECRET:-}` is
|
||||||
|
# not a cluster-var, so envsubst blanked it and the guard reconciled as `[ -z "" ]` — ALWAYS
|
||||||
|
# TRUE — so this hook `exit 0`d here on EVERY boot since 8738871 (2026-08-27), never
|
||||||
|
# installing/enabling user_oidc nor registering the provider. SSO kept working only because
|
||||||
|
# the provider was already in the DB; a PVC rebuild or fresh cluster would have come up with
|
||||||
|
# NO OIDC at all. Flux STRICT substitution cannot catch this — it EXEMPTS default-operator
|
||||||
|
# forms by design. `$${VAR}` is the escape: envsubst emits it back as a plain brace expansion
|
||||||
|
# for the shell. Bare `$VAR` and `$(cmd)` are left alone and need NO escaping — which is why
|
||||||
|
# `--clientsecret-env` and the bare uses below always worked and only the guard broke.
|
||||||
|
# Contract: clusters/base/apps-vms.yaml. Enforced at render time by catalog/render.py.
|
||||||
|
if [ -z "$${NEXTCLOUD_OIDC_CLIENT_SECRET:-}" ]; then
|
||||||
echo "[nextcloud-oidc] client secret unset; skipping OIDC (break-glass admin only)"
|
echo "[nextcloud-oidc] client secret unset; skipping OIDC (break-glass admin only)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -191,7 +203,17 @@ lifecycle:
|
|||||||
[ "$missing" = 0 ] && break
|
[ "$missing" = 0 ] && break
|
||||||
p=$((p + 1)); sleep 2
|
p=$((p + 1)); sleep 2
|
||||||
done
|
done
|
||||||
[ "$p" -ge 15 ] && nextcloud_oidc_fail "could not promote all $ADMIN_SRC_GROUP members into 'admin' after retries"
|
if [ "$p" -ge 15 ]; then
|
||||||
|
nextcloud_oidc_fail "could not promote all $ADMIN_SRC_GROUP members into 'admin' after retries"
|
||||||
|
fi
|
||||||
|
# A postStart hook MUST exit 0. A non-zero exit makes the kubelet KILL the container
|
||||||
|
# (FailedPostStartHook -> CrashLoopBackOff), which is exactly what happened on 2026-09-01:
|
||||||
|
# the final statement used to be `[ "$p" -ge 15 ] && nextcloud_oidc_fail ...`, so on the
|
||||||
|
# SUCCESS path the test returned 1 and became the script's exit status. That was latent for
|
||||||
|
# as long as the OIDC guard above was dead code and exited 0 first; fixing the guard
|
||||||
|
# exposed it immediately. Every failure path here logs loudly and writes a marker file and
|
||||||
|
# deliberately does NOT crash the pod — the break-glass local admin must stay usable.
|
||||||
|
exit 0
|
||||||
exposure: public # edge port-forward + public DNS (docs/09; R-DNS/R-NET)
|
exposure: public # edge port-forward + public DNS (docs/09; R-DNS/R-NET)
|
||||||
egress: internet # normal WAN egress (docs/03; R-NET-05) — app store, integrity checks
|
egress: internet # normal WAN egress (docs/03; R-NET-05) — app store, integrity checks
|
||||||
storage:
|
storage:
|
||||||
@@ -206,7 +228,7 @@ dependencies:
|
|||||||
port: 5432 # Renders the egress half; the DB namespace mirrors keycloak-db (no
|
port: 5432 # Renders the egress half; the DB namespace mirrors keycloak-db (no
|
||||||
protocol: tcp # default-deny), so Cilium's default-allow accepts the reciprocal ingress.
|
protocol: tcp # default-deny), so Cilium's default-allow accepts the reciprocal ingress.
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: nextcloud.home.keithjackson.dev
|
- host: nextcloud.${BASE_DOMAIN}
|
||||||
port: 80 # apache HTTP; wildcard TLS terminates at the shared Gateway (docs/08/09)
|
port: 80 # apache HTTP; wildcard TLS terminates at the shared Gateway (docs/08/09)
|
||||||
auth:
|
auth:
|
||||||
mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC"; R-IDP-02)
|
mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC"; R-IDP-02)
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ storage:
|
|||||||
class: longhorn # replicated config state; DR-critical (docs/04 line 118; R-STATE-02/03)
|
class: longhorn # replicated config state; DR-critical (docs/04 line 118; R-STATE-02/03)
|
||||||
dependencies: [] # default-deny: reaches only DNS; it PROVIDES DNS (docs/03; R-NET-01/02)
|
dependencies: [] # default-deny: reaches only DNS; it PROVIDES DNS (docs/03; R-NET-01/02)
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: pihole.home.keithjackson.dev
|
- host: pihole.${BASE_DOMAIN}
|
||||||
port: 80 # admin web UI, protected at the Gateway by forward-auth (docs/09)
|
port: 80 # admin web UI, protected at the Gateway by forward-auth (docs/09)
|
||||||
auth:
|
auth:
|
||||||
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no native OIDC (docs/15; R-IDP-02)
|
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no native OIDC (docs/15; R-IDP-02)
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ storage:
|
|||||||
# so a placeholder/absent claim Secret would crashloop the pod. See operator hand-off.
|
# so a placeholder/absent claim Secret would crashloop the pod. See operator hand-off.
|
||||||
dependencies: [] # default-deny: reaches only DNS; clients/Seerr reach IT (docs/03; R-NET-01/02)
|
dependencies: [] # default-deny: reaches only DNS; clients/Seerr reach IT (docs/03; R-NET-01/02)
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: plex.home.keithjackson.dev
|
- host: plex.${BASE_DOMAIN}
|
||||||
port: 32400 # Plex web UI + API served on 32400 (wildcard TLS at the Gateway; docs/08/09)
|
port: 32400 # Plex web UI + API served on 32400 (wildcard TLS at the Gateway; docs/08/09)
|
||||||
auth:
|
auth:
|
||||||
mode: native # Plex account auth — documented exception, NOT Keycloak (docs/13/15; R-IDP-02)
|
mode: native # Plex account auth — documented exception, NOT Keycloak (docs/13/15; R-IDP-02)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ name: qbittorrent
|
|||||||
kind: container
|
kind: container
|
||||||
source:
|
source:
|
||||||
image: lscr.io/linuxserver/qbittorrent:5.1.2 # pinned stable tag (R-UPD-01); update.range governs advancement
|
image: lscr.io/linuxserver/qbittorrent:5.1.2 # pinned stable tag (R-UPD-01); update.range governs advancement
|
||||||
exposure: internal # LAN-only web UI at qbittorrent.home.keithjackson.dev (docs/13 row 13; R-DNS/R-NET)
|
exposure: internal # LAN-only web UI at qbittorrent.${BASE_DOMAIN} (docs/13 row 13; R-DNS/R-NET)
|
||||||
egress: vpn # ALL public egress via node PIA tunnel + kill-switch (docs/03; ADR-0015; R-NET-05/06)
|
egress: vpn # ALL public egress via node PIA tunnel + kill-switch (docs/03; ADR-0015; R-NET-05/06)
|
||||||
storage:
|
storage:
|
||||||
- name: config
|
- name: config
|
||||||
@@ -40,7 +40,7 @@ storage:
|
|||||||
class: longhorn # settings/categories/session on 3-replica Longhorn; DR-critical (docs/04; R-STATE-02)
|
class: longhorn # settings/categories/session on 3-replica Longhorn; DR-critical (docs/04; R-STATE-02)
|
||||||
dependencies: [] # default-deny: reaches only DNS; arr reach it north of its own egress (docs/03; R-NET-01/02)
|
dependencies: [] # default-deny: reaches only DNS; arr reach it north of its own egress (docs/03; R-NET-01/02)
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: qbittorrent.home.keithjackson.dev
|
- host: qbittorrent.${BASE_DOMAIN}
|
||||||
port: 8080 # qBittorrent WebUI; protected at the Gateway by forward-auth (docs/09, docs/15)
|
port: 8080 # qBittorrent WebUI; protected at the Gateway by forward-auth (docs/09, docs/15)
|
||||||
auth:
|
auth:
|
||||||
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no usable native OIDC (docs/15; R-IDP-02)
|
mode: forward-auth # oauth2-proxy -> Keycloak; bare UI, no usable native OIDC (docs/15; R-IDP-02)
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ name: radarr
|
|||||||
kind: container
|
kind: container
|
||||||
source:
|
source:
|
||||||
image: lscr.io/linuxserver/radarr:5.21.1 # pinned stable v5 tag (R-UPD-01); update.range governs advancement
|
image: lscr.io/linuxserver/radarr:5.21.1 # pinned stable v5 tag (R-UPD-01); update.range governs advancement
|
||||||
exposure: internal # LAN-only web UI at radarr.home.keithjackson.dev (docs/13 row 3; R-DNS/R-NET)
|
exposure: internal # LAN-only web UI at radarr.${BASE_DOMAIN} (docs/13 row 3; R-DNS/R-NET)
|
||||||
egress: vpn # indexer scrape + downloader handoff steered via the shared VPN egress gateway,
|
egress: vpn # indexer scrape + downloader handoff steered via the shared VPN egress gateway,
|
||||||
# kill-switch enforced OUTSIDE the workload (docs/03; ADR-0015; R-NET-05/06)
|
# kill-switch enforced OUTSIDE the workload (docs/03; ADR-0015; R-NET-05/06)
|
||||||
storage:
|
storage:
|
||||||
@@ -29,7 +29,7 @@ dependencies:
|
|||||||
port: 9117
|
port: 9117
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: radarr.home.keithjackson.dev
|
- host: radarr.${BASE_DOMAIN}
|
||||||
port: 7878 # Radarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
port: 7878 # Radarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
||||||
auth:
|
auth:
|
||||||
mode: forward-auth # bare UI, no usable OIDC: oauth2-proxy → Keycloak at the Gateway (docs/15; R-IDP-02)
|
mode: forward-auth # bare UI, no usable OIDC: oauth2-proxy → Keycloak at the Gateway (docs/15; R-IDP-02)
|
||||||
|
|||||||
+239
-7
@@ -29,6 +29,7 @@ conformance harness stays stdlib-only and only shells out to this script + diffs
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -43,7 +44,15 @@ except ImportError: # pragma: no cover - explicit, actionable failure
|
|||||||
|
|
||||||
# --- Label / naming conventions (match platform/kyverno/policies + platform/*/) --------
|
# --- Label / naming conventions (match platform/kyverno/policies + platform/*/) --------
|
||||||
LBL = "homelab.keithjackson.dev"
|
LBL = "homelab.keithjackson.dev"
|
||||||
ZONE = "home.keithjackson.dev"
|
# The platform hostname suffix, emitted as the Flux post-build TOKEN rather than a literal.
|
||||||
|
# Every rendered manifest lands under a Flux path whose Kustomization declares
|
||||||
|
# postBuild.substituteFrom cluster-vars, so `${BASE_DOMAIN}` is resolved per environment at
|
||||||
|
# reconcile time: dev renders home.keithjackson.dev, prod renders prod.home.keithjackson.dev.
|
||||||
|
# It is a DECLARED cluster-vars knob (clusters/base/cluster-vars.yaml), so it is written BARE —
|
||||||
|
# the escape contract below reserves `$${...}` for expansions meant for a container's shell.
|
||||||
|
# Hardcoding the literal here is what let prod be authored to serve dev's public hostnames,
|
||||||
|
# and prod's external-dns then deleted dev's records (infra/PROD-PROVISIONING.md §10).
|
||||||
|
ZONE = "${BASE_DOMAIN}"
|
||||||
GATEWAY_NAME = "shared"
|
GATEWAY_NAME = "shared"
|
||||||
GATEWAY_NS = "gateway"
|
GATEWAY_NS = "gateway"
|
||||||
KP_RELEASE = "kube-prometheus-stack" # ServiceMonitor/PrometheusRule discovery label
|
KP_RELEASE = "kube-prometheus-stack" # ServiceMonitor/PrometheusRule discovery label
|
||||||
@@ -936,6 +945,124 @@ def r_virtualmachine(s: dict) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Substitution safety (R-ENV-02 / R-GITOPS-02) — refuse to emit a manifest that
|
||||||
|
# Flux's envsubst would silently eat before the workload ever runs.
|
||||||
|
# =============================================================================
|
||||||
|
# Every manifest this renderer writes lands under apps/wave-<N>/, and EVERY apps-wave-<N>
|
||||||
|
# Flux Kustomization declares `postBuild.substituteFrom: cluster-vars` (see
|
||||||
|
# flux_wave_kustomization_text below). Flux therefore runs envsubst over the WHOLE rendered
|
||||||
|
# document — including any shell script carried verbatim in a `lifecycle` hook or an env
|
||||||
|
# value — at reconcile time, long before a container sees it. The escape contract is stated
|
||||||
|
# in clusters/base/apps-vms.yaml and proven with `flux envsubst`:
|
||||||
|
#
|
||||||
|
# `${VAR}` -> SUBSTITUTED (empty when unset) => a shell brace must be written `$${VAR}`,
|
||||||
|
# which envsubst emits back as `${VAR}`.
|
||||||
|
# `$VAR` -> left alone => bare shell vars need NO escaping.
|
||||||
|
# `$(cmd)` -> left alone => command substitution needs NO escaping.
|
||||||
|
# `${A[0]}` -> HARD ERROR ("missing closing brace"), failing the whole Kustomization.
|
||||||
|
#
|
||||||
|
# This bit us for real (fixed 2026-09-01): gitea/nextcloud guarded their whole Keycloak OIDC
|
||||||
|
# registration with `if [ -z "${..._OIDC_CLIENT_SECRET:-}" ]`. Neither name is a cluster-var,
|
||||||
|
# so envsubst rendered the guard as `if [ -z "" ]` — ALWAYS TRUE — and every boot skipped the
|
||||||
|
# registration entirely. Flux's STRICT substitution mode cannot catch this class: it
|
||||||
|
# deliberately EXEMPTS the default-operator forms (`${V:-}`, `${V:=x}`, `${V:?}`), which is
|
||||||
|
# exactly the form a defensive shell script uses. So the renderer checks it itself.
|
||||||
|
CLUSTER_VARS_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent / "clusters" / "base" / "cluster-vars.yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
# A run of `$` followed by `{...}`. envsubst consumes `$$` as a literal `$`, so a run of N
|
||||||
|
# dollars leaves the brace SUBSTITUTED when N is odd (`${X}`, `$$${X}`) and LITERAL when N is
|
||||||
|
# even (`$${X}` — the escape). Deliberately greedy about the body so malformed/indexed forms
|
||||||
|
# (`${A[0]}`) are caught too rather than skipped.
|
||||||
|
_BRACED_EXPANSION_RE = re.compile(r"(\$+)\{([^{}]*)\}")
|
||||||
|
_PLAIN_IDENT_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
|
||||||
|
|
||||||
|
_CLUSTER_VARS_CACHE: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_vars_keys(path: Path | None = None) -> set:
|
||||||
|
"""The declared cluster-vars keys — the ONLY names envsubst may legitimately expand.
|
||||||
|
|
||||||
|
Read from clusters/base/cluster-vars.yaml (the single knob surface, R-ENV-02) so the
|
||||||
|
renderer never carries a stale hardcoded copy. Unreadable/absent => empty set, i.e.
|
||||||
|
FAIL-CLOSED: with no known knobs, every braced expansion must be escaped for the shell.
|
||||||
|
"""
|
||||||
|
key = str(path or CLUSTER_VARS_PATH)
|
||||||
|
if key not in _CLUSTER_VARS_CACHE:
|
||||||
|
try:
|
||||||
|
doc = yaml.safe_load(Path(key).read_text())
|
||||||
|
data = doc.get("data") if isinstance(doc, dict) else None
|
||||||
|
_CLUSTER_VARS_CACHE[key] = set(data) if isinstance(data, dict) else set()
|
||||||
|
except Exception: # pragma: no cover - absent/malformed => fail closed
|
||||||
|
_CLUSTER_VARS_CACHE[key] = set()
|
||||||
|
return _CLUSTER_VARS_CACHE[key]
|
||||||
|
|
||||||
|
|
||||||
|
def substitution_violations(text: str, known: set | None = None) -> list:
|
||||||
|
"""Every unescaped `${...}` in `text` that is NOT a bare, declared cluster-vars key.
|
||||||
|
|
||||||
|
Returns `(line_number, token, reason)` triples in line order. An expansion is ALLOWED only
|
||||||
|
when it is exactly `${KNOWN_KEY}` — a deliberate R-ENV-02 knob. Anything else is a bug:
|
||||||
|
a shell expansion that envsubst will eat (`${V:-}`), a typo'd/undeclared knob that
|
||||||
|
envsubst will blank, or an indexed form that hard-fails the whole Kustomization.
|
||||||
|
"""
|
||||||
|
keys = cluster_vars_keys() if known is None else known
|
||||||
|
out = []
|
||||||
|
for lineno, line in enumerate(text.splitlines(), start=1):
|
||||||
|
for m in _BRACED_EXPANSION_RE.finditer(line):
|
||||||
|
if len(m.group(1)) % 2 == 0:
|
||||||
|
continue # even run of `$` => escaped; envsubst emits it back literally
|
||||||
|
expr = m.group(2)
|
||||||
|
if _PLAIN_IDENT_RE.match(expr):
|
||||||
|
if expr in keys:
|
||||||
|
continue # a declared cluster-vars knob, intentionally substituted
|
||||||
|
reason = (
|
||||||
|
f"'{expr}' is not a declared cluster-vars key — envsubst will replace "
|
||||||
|
"it with the EMPTY STRING"
|
||||||
|
)
|
||||||
|
elif "[" in expr:
|
||||||
|
reason = (
|
||||||
|
"indexed expansion — envsubst hard-fails with 'missing closing brace' "
|
||||||
|
"and the whole Kustomization stops reconciling"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
reason = (
|
||||||
|
"shell expansion (default-operator/parameter form) — envsubst eats it "
|
||||||
|
"at reconcile time and STRICT mode exempts this form, so nothing else "
|
||||||
|
"will catch it"
|
||||||
|
)
|
||||||
|
out.append((lineno, m.group(0), reason))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def assert_substitution_safe(text: str, origin: str = "<render>") -> None:
|
||||||
|
"""Refuse to emit a manifest carrying an unescaped braced expansion (see above).
|
||||||
|
|
||||||
|
Raises ValueError listing every offending token. The fix is almost always to escape the
|
||||||
|
shell brace as `$${...}` (envsubst emits a literal `${...}` for the shell), or — if the
|
||||||
|
value really is an environment knob — to declare it in clusters/base/cluster-vars.yaml
|
||||||
|
and write it as a bare `${KNOB}`.
|
||||||
|
"""
|
||||||
|
bad = substitution_violations(text)
|
||||||
|
if not bad:
|
||||||
|
return
|
||||||
|
lines = [
|
||||||
|
f"{origin}: refusing to emit — unescaped `${{...}}` in a manifest that Flux's "
|
||||||
|
"postBuild.substituteFrom (cluster-vars) will substitute at reconcile time:"
|
||||||
|
]
|
||||||
|
for lineno, token, reason in bad:
|
||||||
|
lines.append(f" line {lineno}: {token} -> {reason}")
|
||||||
|
lines.append(
|
||||||
|
" FIX: a SHELL expansion must be escaped `$${...}` (envsubst emits a literal "
|
||||||
|
"`${...}`); bare `$VAR` / `$(cmd)` need no escaping. A genuine environment knob "
|
||||||
|
"must be declared in clusters/base/cluster-vars.yaml and written bare `${KNOB}`. "
|
||||||
|
"Contract: clusters/base/apps-vms.yaml."
|
||||||
|
)
|
||||||
|
raise ValueError("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Top-level render + emit
|
# Top-level render + emit
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1026,8 +1153,14 @@ def render(sd: dict, image_automation: bool = True, inbound: list | None = None)
|
|||||||
return docs
|
return docs
|
||||||
|
|
||||||
|
|
||||||
def emit(docs: list) -> str:
|
def emit(docs: list, origin: str = "<render>") -> str:
|
||||||
"""Deterministic multi-document YAML text (golden-diffable)."""
|
"""Deterministic multi-document YAML text (golden-diffable).
|
||||||
|
|
||||||
|
Every emitted document is checked against the substitution contract before it is handed
|
||||||
|
back (assert_substitution_safe): the rendered manifest lands on a Flux path that runs
|
||||||
|
envsubst over it, so an unescaped `${...}` meant for a SHELL is silently blanked. Raises
|
||||||
|
ValueError rather than emitting such a manifest.
|
||||||
|
"""
|
||||||
parts = []
|
parts = []
|
||||||
for comment, resource in docs:
|
for comment, resource in docs:
|
||||||
body = yaml.dump(
|
body = yaml.dump(
|
||||||
@@ -1037,7 +1170,9 @@ def emit(docs: list) -> str:
|
|||||||
width=4096,
|
width=4096,
|
||||||
)
|
)
|
||||||
parts.append(f"---\n# {comment}\n{body}".rstrip() + "\n")
|
parts.append(f"---\n# {comment}\n{body}".rstrip() + "\n")
|
||||||
return "".join(parts)
|
text = "".join(parts)
|
||||||
|
assert_substitution_safe(text, origin)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def load_sd(path: Path) -> dict:
|
def load_sd(path: Path) -> dict:
|
||||||
@@ -1076,7 +1211,9 @@ def render_file(path: Path, image_automation: bool = True, catalog_dir: Path | N
|
|||||||
src_dir = catalog_dir if catalog_dir is not None else path.parent
|
src_dir = catalog_dir if catalog_dir is not None else path.parent
|
||||||
rev = build_reverse_map(_load_catalog(src_dir))
|
rev = build_reverse_map(_load_catalog(src_dir))
|
||||||
inbound = rev.get(sd["name"], [])
|
inbound = rev.get(sd["name"], [])
|
||||||
return emit(render(sd, image_automation=image_automation, inbound=inbound))
|
return emit(
|
||||||
|
render(sd, image_automation=image_automation, inbound=inbound), origin=str(path)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -1184,6 +1321,11 @@ def plan_apps_waves(sds: list, present_files: set) -> dict:
|
|||||||
- `<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)
|
||||||
- `<svc>-db-backup-credentials.sops.yaml` -> wave(svc-db) (CNPG Barman S3 backup secret)
|
- `<svc>-db-backup-credentials.sops.yaml` -> wave(svc-db) (CNPG Barman S3 backup secret)
|
||||||
|
- `<svc>-db-backup-credentials-prod.sops.yaml` -> wave(svc-db) (the PROD half of that
|
||||||
|
secret: the two environments carry DIFFERENT object-store credentials, selected in
|
||||||
|
the Cluster CR by `${BACKUP_CREDENTIALS_SUFFIX}`. Both objects ship to both clusters,
|
||||||
|
so both must be placed in the DB wave — the Secret has to exist before the Cluster
|
||||||
|
that names it.)
|
||||||
Non-catalog units (EXTRA_UNITS, e.g. gitea-actions) place by their declared anchor+offset.
|
Non-catalog units (EXTRA_UNITS, e.g. gitea-actions) place by their declared anchor+offset.
|
||||||
Only files that actually exist in `present_files` are placed, so the plan is robust to a
|
Only files that actually exist in `present_files` are placed, so the plan is robust to a
|
||||||
service having no bootstrap secret or no DB. Deterministic (sorted).
|
service having no bootstrap secret or no DB. Deterministic (sorted).
|
||||||
@@ -1211,6 +1353,9 @@ def plan_apps_waves(sds: list, present_files: set) -> dict:
|
|||||||
add(wdb, f"{db}.yaml")
|
add(wdb, f"{db}.yaml")
|
||||||
add(wdb, f"{db}-app.sops.yaml")
|
add(wdb, f"{db}-app.sops.yaml")
|
||||||
add(wdb, f"{db}-backup-credentials.sops.yaml") # CNPG Barman S3 creds (R-STATE-03)
|
add(wdb, f"{db}-backup-credentials.sops.yaml") # CNPG Barman S3 creds (R-STATE-03)
|
||||||
|
# PROD half of the same credential (2026-09-07 per-env split). Placed only when
|
||||||
|
# the file exists, so a DB that has not been split is unaffected.
|
||||||
|
add(wdb, f"{db}-backup-credentials-prod.sops.yaml")
|
||||||
|
|
||||||
for unit, cfg in EXTRA_UNITS.items():
|
for unit, cfg in EXTRA_UNITS.items():
|
||||||
anchor = cfg["anchor"]
|
anchor = cfg["anchor"]
|
||||||
@@ -1693,6 +1838,7 @@ def _selftest() -> int:
|
|||||||
present = {
|
present = {
|
||||||
"a.yaml", "svc.yaml", "svc-bootstrap-secret.sops.yaml",
|
"a.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",
|
||||||
"gitea.yaml",
|
"gitea.yaml",
|
||||||
"gitea-actions.yaml", "gitea-actions-runner-secret.sops.yaml",
|
"gitea-actions.yaml", "gitea-actions-runner-secret.sops.yaml",
|
||||||
}
|
}
|
||||||
@@ -1702,8 +1848,10 @@ def _selftest() -> int:
|
|||||||
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("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
|
||||||
"CNPG DB manifest + its owner secret + its Barman backup secret must be in the DB's (lower) wave")
|
and flat.get("svc-db-backup-credentials-prod.sops.yaml") == 0,
|
||||||
|
"CNPG DB manifest + its owner secret + BOTH halves of its per-env Barman backup secret "
|
||||||
|
"must be in the DB's (lower) wave")
|
||||||
check(flat.get("svc.yaml") == 1 and flat.get("svc-bootstrap-secret.sops.yaml") == 1,
|
check(flat.get("svc.yaml") == 1 and flat.get("svc-bootstrap-secret.sops.yaml") == 1,
|
||||||
"a DB-backed app + its bootstrap secret must be in the consumer wave, above the DB")
|
"a DB-backed app + its bootstrap secret must be in the consumer wave, above the DB")
|
||||||
check(flat.get("gitea.yaml") == 0, "gitea (leaf) must be wave 0")
|
check(flat.get("gitea.yaml") == 0, "gitea (leaf) must be wave 0")
|
||||||
@@ -1729,6 +1877,90 @@ def _selftest() -> int:
|
|||||||
check(kt["kind"] == "Kustomization" and kt["resources"] == ["svc.yaml", "sonarr.yaml"],
|
check(kt["kind"] == "Kustomization" and kt["resources"] == ["svc.yaml", "sonarr.yaml"],
|
||||||
"wave kustomization must list exactly the wave's resources")
|
"wave kustomization must list exactly the wave's resources")
|
||||||
|
|
||||||
|
# 8) substitution safety (R-ENV-02): the renderer must REFUSE to emit a manifest whose
|
||||||
|
# shell script carries an unescaped `${...}`. Every rendered manifest lands under a
|
||||||
|
# Flux path with postBuild.substituteFrom cluster-vars, so envsubst rewrites it before
|
||||||
|
# the container runs — this is the gitea/nextcloud OIDC-guard bug (`[ -z "${X:-}" ]`
|
||||||
|
# -> `[ -z "" ]`, always true, whole registration block skipped on every boot).
|
||||||
|
known = {"BASE_DOMAIN", "GATEWAY_VIP"}
|
||||||
|
# allowed: a bare, DECLARED cluster-vars knob is a deliberate R-ENV-02 substitution.
|
||||||
|
check(
|
||||||
|
substitution_violations("host: app.${BASE_DOMAIN}", known) == [],
|
||||||
|
"a bare declared cluster-vars key must be allowed (that is what substitution is FOR)",
|
||||||
|
)
|
||||||
|
# allowed: escaped shell braces, and the forms envsubst leaves alone.
|
||||||
|
check(
|
||||||
|
substitution_violations('if [ -z "$${X:-}" ]; then', known) == [],
|
||||||
|
"an ESCAPED `$${...}` must be allowed (envsubst emits it back as a literal `${...}`)",
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
substitution_violations('t="$(mktemp)"; echo "$HOME $1"', known) == [],
|
||||||
|
"bare `$VAR` / `$(cmd)` are left alone by envsubst and must NOT be flagged",
|
||||||
|
)
|
||||||
|
# `$$` is a literal `$` to envsubst, so an ODD run of dollars still leaves the brace live.
|
||||||
|
check(
|
||||||
|
len(substitution_violations("$$${X:-}", known)) == 1,
|
||||||
|
"an ODD run of `$` still substitutes ($$ -> literal $, then `${X:-}` is eaten)",
|
||||||
|
)
|
||||||
|
# flagged: the exact regression — a default-operator form STRICT mode deliberately exempts.
|
||||||
|
guard_bug = substitution_violations('if [ -z "${GITEA_OIDC_CLIENT_SECRET:-}" ]; then', known)
|
||||||
|
check(
|
||||||
|
len(guard_bug) == 1 and guard_bug[0][1] == "${GITEA_OIDC_CLIENT_SECRET:-}",
|
||||||
|
"an unescaped `${VAR:-}` shell guard must be flagged (strict mode exempts this form)",
|
||||||
|
)
|
||||||
|
# flagged: an undeclared bare knob (envsubst blanks it), and an indexed form (hard error).
|
||||||
|
check(
|
||||||
|
len(substitution_violations("value: ${NOT_A_KNOB}", known)) == 1,
|
||||||
|
"an undeclared bare `${VAR}` must be flagged (envsubst blanks it silently)",
|
||||||
|
)
|
||||||
|
idx = substitution_violations("echo ${PIPESTATUS[0]}", known)
|
||||||
|
check(
|
||||||
|
len(idx) == 1 and "missing closing brace" in idx[0][2],
|
||||||
|
"an indexed `${A[0]}` must be flagged (envsubst hard-fails the whole Kustomization)",
|
||||||
|
)
|
||||||
|
# the checker must be wired into emit(): rendering an SD whose lifecycle hook carries the
|
||||||
|
# bug RAISES rather than writing a manifest whose guard is dead on arrival.
|
||||||
|
bad_sd = {
|
||||||
|
"name": "subbad",
|
||||||
|
"source": {"image": "a:1"},
|
||||||
|
"lifecycle": {
|
||||||
|
"postStart": {
|
||||||
|
"exec": {
|
||||||
|
"command": [
|
||||||
|
"/bin/sh",
|
||||||
|
"-c",
|
||||||
|
'if [ -z "${SUBBAD_SECRET:-}" ]; then exit 0; fi',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
emit(render(bad_sd), origin="<selftest>")
|
||||||
|
check(False, "emit must REFUSE a manifest with an unescaped `${...}` shell expansion")
|
||||||
|
except ValueError as exc:
|
||||||
|
check(
|
||||||
|
"SUBBAD_SECRET" in str(exc) and "$${" in str(exc),
|
||||||
|
"the refusal must name the offending token AND state the `$${...}` fix",
|
||||||
|
)
|
||||||
|
# the ESCAPED form of the same hook renders normally (the fix must be emittable).
|
||||||
|
good_sd = dict(bad_sd, name="subgood")
|
||||||
|
good_sd["lifecycle"] = {
|
||||||
|
"postStart": {
|
||||||
|
"exec": {
|
||||||
|
"command": [
|
||||||
|
"/bin/sh",
|
||||||
|
"-c",
|
||||||
|
'if [ -z "$${SUBBAD_SECRET:-}" ]; then exit 0; fi',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(
|
||||||
|
"$${SUBBAD_SECRET:-}" in emit(render(good_sd), origin="<selftest>"),
|
||||||
|
"the escaped `$${...}` form must render (and reach the shell as `${...}`)",
|
||||||
|
)
|
||||||
|
|
||||||
if failures:
|
if failures:
|
||||||
for f in failures:
|
for f in failures:
|
||||||
sys.stderr.write(f"SELFTEST FAIL: {f}\n")
|
sys.stderr.write(f"SELFTEST FAIL: {f}\n")
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ dependencies:
|
|||||||
port: 32400
|
port: 32400
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: seerr.home.keithjackson.dev
|
- host: seerr.${BASE_DOMAIN}
|
||||||
port: 5055 # Overseerr web UI + API; wildcard TLS terminates at the shared Gateway (docs/08/09)
|
port: 5055 # Overseerr web UI + API; wildcard TLS terminates at the shared Gateway (docs/08/09)
|
||||||
auth:
|
auth:
|
||||||
mode: native # Plex account auth — documented exception, NOT Keycloak (docs/13/15; R-IDP-02)
|
mode: native # Plex account auth — documented exception, NOT Keycloak (docs/13/15; R-IDP-02)
|
||||||
|
|||||||
+33
-13
@@ -58,7 +58,7 @@ env:
|
|||||||
- name: SEMAPHORE_ADMIN_NAME
|
- name: SEMAPHORE_ADMIN_NAME
|
||||||
value: Semaphore Admin
|
value: Semaphore Admin
|
||||||
- name: SEMAPHORE_ADMIN_EMAIL
|
- name: SEMAPHORE_ADMIN_EMAIL
|
||||||
value: admin@home.keithjackson.dev
|
value: admin@${BASE_DOMAIN}
|
||||||
# --- Persistent access-key encryption key (see header): FIXED base64 32-byte key from SOPS so the
|
# --- Persistent access-key encryption key (see header): FIXED base64 32-byte key from SOPS so the
|
||||||
# SSH keys / credentials Semaphore stores in the DB stay decryptable across restarts. ---
|
# SSH keys / credentials Semaphore stores in the DB stay decryptable across restarts. ---
|
||||||
- name: SEMAPHORE_ACCESS_KEY_ENCRYPTION
|
- name: SEMAPHORE_ACCESS_KEY_ENCRYPTION
|
||||||
@@ -78,16 +78,34 @@ env:
|
|||||||
# (util ConfigType `oidc_providers`, env-tagged SEMAPHORE_OIDC_PROVIDERS). The value is a single
|
# (util ConfigType `oidc_providers`, env-tagged SEMAPHORE_OIDC_PROVIDERS). The value is a single
|
||||||
# JSON blob for the `keycloak` provider — provider_url is the homelab realm issuer (auto-discovery),
|
# JSON blob for the `keycloak` provider — provider_url is the homelab realm issuer (auto-discovery),
|
||||||
# client_id=semaphore, redirect_url=/api/auth/oidc/keycloak/redirect, claims map to the realm's
|
# client_id=semaphore, redirect_url=/api/auth/oidc/keycloak/redirect, claims map to the realm's
|
||||||
# token. It CONTAINS the confidential client secret, so the whole blob is held SOPS-encrypted in the
|
# token. The name is NOT a kubelet service-link var (those are SEMAPHORE_SERVICE_HOST /
|
||||||
# semaphore-bootstrap Secret (key oidc-providers-json; same secret value SOPS-held in
|
# SEMAPHORE_PORT_*), so no collision. The LOCAL SEMAPHORE_ADMIN above stays break-glass (docs/15).
|
||||||
# keycloak-realm-clients) and injected here by reference — never in cleartext. The name is NOT a
|
#
|
||||||
# kubelet service-link var (those are SEMAPHORE_SERVICE_HOST / SEMAPHORE_PORT_*), so no collision.
|
# PER-ENVIRONMENT URLS (2026-09-07 fix). The blob USED to live whole inside the SOPS-encrypted
|
||||||
# The LOCAL SEMAPHORE_ADMIN above stays as the break-glass path (docs/15). ---
|
# semaphore-bootstrap Secret (key `oidc-providers-json`) because it CONTAINS the confidential
|
||||||
- name: SEMAPHORE_OIDC_PROVIDERS
|
# client secret. That hid two environment-specific, NON-SECRET URLs — the realm issuer and the
|
||||||
|
# redirect URI — inside ciphertext, where Flux's ${BASE_DOMAIN} postBuild substitution CANNOT
|
||||||
|
# reach them. Both were frozen at the apex `home.keithjackson.dev`, so PROD (BASE_DOMAIN =
|
||||||
|
# prod.home.keithjackson.dev) pointed its OIDC at DEV's Keycloak — unroutable from prod's
|
||||||
|
# 192.168.2.0/24 (the identical root cause as the Headlamp login timeout; see
|
||||||
|
# platform/headlamp/README.md). Dev was only ever "right" because dev IS the apex.
|
||||||
|
#
|
||||||
|
# THE SPLIT: the URLs are public, so the blob is now a PLAINTEXT literal here and gets
|
||||||
|
# ${BASE_DOMAIN}-substituted per environment like every other hostname. Only the confidential
|
||||||
|
# client secret stays SOPS-encrypted, in its own key (`oidc-client-secret`), and is spliced back
|
||||||
|
# in by the KUBELET's dependent-env-var expansion: an env `value` may reference an EARLIER env
|
||||||
|
# var of the same container as $(NAME), and secretKeyRef-sourced vars are eligible. So
|
||||||
|
# SEMAPHORE_OIDC_CLIENT_SECRET MUST STAY IMMEDIATELY ABOVE — the kubelet resolves env in list
|
||||||
|
# order, and a reference to a not-yet-defined name is left as the LITERAL text $(...). It is
|
||||||
|
# fail-loud in the way that matters: if the Secret key is missing the Pod never starts
|
||||||
|
# (CreateContainerConfigError), rather than silently authenticating against nothing. ---
|
||||||
|
- name: SEMAPHORE_OIDC_CLIENT_SECRET
|
||||||
valueFrom:
|
valueFrom:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: semaphore-bootstrap
|
name: semaphore-bootstrap
|
||||||
key: oidc-providers-json
|
key: oidc-client-secret
|
||||||
|
- name: SEMAPHORE_OIDC_PROVIDERS
|
||||||
|
value: '{"keycloak":{"display_name":"Keycloak","provider_url":"https://keycloak.${BASE_DOMAIN}/realms/homelab","client_id":"semaphore","client_secret":"$(SEMAPHORE_OIDC_CLIENT_SECRET)","redirect_url":"https://semaphore.${BASE_DOMAIN}/api/auth/oidc/keycloak/redirect","scopes":["openid","profile","email"],"username_claim":"preferred_username","email_claim":"email","name_claim":"name","order":1}}'
|
||||||
# The semaphoreui/semaphore image runs as the non-root user UID 1001 / GID 0 (tini -> server-wrapper),
|
# The semaphoreui/semaphore image runs as the non-root user UID 1001 / GID 0 (tini -> server-wrapper),
|
||||||
# and on boot the wrapper writes its generated config.json into the config dir. A mounted Longhorn PVC
|
# and on boot the wrapper writes its generated config.json into the config dir. A mounted Longhorn PVC
|
||||||
# is root:root, so UID 1001 cannot write it -> the pod panics ("permission denied"). fsGroup=1001 makes
|
# is root:root, so UID 1001 cannot write it -> the pod panics ("permission denied"). fsGroup=1001 makes
|
||||||
@@ -110,17 +128,19 @@ dependencies:
|
|||||||
port: 5432 # Renders the egress half; the DB namespace mirrors keycloak-db (no
|
port: 5432 # Renders the egress half; the DB namespace mirrors keycloak-db (no
|
||||||
protocol: tcp # default-deny), so Cilium's default-allow accepts the reciprocal ingress.
|
protocol: tcp # default-deny), so Cilium's default-allow accepts the reciprocal ingress.
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: semaphore.home.keithjackson.dev
|
- host: semaphore.${BASE_DOMAIN}
|
||||||
port: 3000 # Semaphore HTTP web/API; wildcard TLS terminates at the shared Gateway (docs/08/09)
|
port: 3000 # Semaphore HTTP web/API; wildcard TLS terminates at the shared Gateway (docs/08/09)
|
||||||
auth:
|
auth:
|
||||||
mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC"; R-IDP-02)
|
mode: oidc # native Keycloak OIDC client (docs/13 SSO row, docs/15 "Native OIDC"; R-IDP-02)
|
||||||
groups:
|
groups:
|
||||||
- platform-admins # access scoped to an authorized group (R-IDP-06)
|
- platform-admins # access scoped to an authorized group (R-IDP-06)
|
||||||
# WIRED (native OIDC): the Keycloak `semaphore` confidential client (platform/keycloak/keycloak-realm-
|
# WIRED (native OIDC): the Keycloak `semaphore` confidential client (platform/keycloak/keycloak-realm-
|
||||||
# import.yaml + live kcadm) is consumed via the SEMAPHORE_OIDC_PROVIDERS env (JSON blob held encrypted
|
# import.yaml + live kcadm) is consumed via the SEMAPHORE_OIDC_PROVIDERS env — a PLAINTEXT JSON blob
|
||||||
# in the semaphore-bootstrap Secret, oidc-providers-json). The `groups` claim is exposed for
|
# (see `env` above) whose per-environment URLs are ${BASE_DOMAIN}-substituted, with the confidential
|
||||||
# authorization (R-IDP-06). Semaphore KEEPS its LOCAL admin as the break-glass path so an IdP outage
|
# client secret spliced in at runtime from the semaphore-bootstrap Secret key `oidc-client-secret`.
|
||||||
# never locks the operator out (docs/15 "Availability caveat"). No secret is embedded here.
|
# The `groups` claim is exposed for authorization (R-IDP-06). Semaphore KEEPS its LOCAL admin as the
|
||||||
|
# break-glass path so an IdP outage never locks the operator out (docs/15 "Availability caveat").
|
||||||
|
# No secret is embedded here.
|
||||||
update:
|
update:
|
||||||
channel: stable # R-UPD-01
|
channel: stable # R-UPD-01
|
||||||
range: ">=2.19.0 <3.0.0" # stay on the 2.x stable line
|
range: ">=2.19.0 <3.0.0" # stay on the 2.x stable line
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ name: sonarr
|
|||||||
kind: container
|
kind: container
|
||||||
source:
|
source:
|
||||||
image: lscr.io/linuxserver/sonarr:4.0.15 # pinned stable v4 tag (R-UPD-01); update.range governs advancement
|
image: lscr.io/linuxserver/sonarr:4.0.15 # pinned stable v4 tag (R-UPD-01); update.range governs advancement
|
||||||
exposure: internal # LAN-only web UI at sonarr.home.keithjackson.dev (docs/13 row 2; R-DNS/R-NET)
|
exposure: internal # LAN-only web UI at sonarr.${BASE_DOMAIN} (docs/13 row 2; R-DNS/R-NET)
|
||||||
egress: vpn # indexer scrape + downloader handoff steered via the shared VPN egress gateway,
|
egress: vpn # indexer scrape + downloader handoff steered via the shared VPN egress gateway,
|
||||||
# kill-switch enforced OUTSIDE the workload (docs/03; ADR-0015; R-NET-05/06)
|
# kill-switch enforced OUTSIDE the workload (docs/03; ADR-0015; R-NET-05/06)
|
||||||
storage:
|
storage:
|
||||||
@@ -29,7 +29,7 @@ dependencies:
|
|||||||
port: 9117
|
port: 9117
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
ingressRoutes:
|
ingressRoutes:
|
||||||
- host: sonarr.home.keithjackson.dev
|
- host: sonarr.${BASE_DOMAIN}
|
||||||
port: 8989 # Sonarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
port: 8989 # Sonarr web/API; TLS terminates at the shared Gateway (docs/08, docs/09)
|
||||||
auth:
|
auth:
|
||||||
mode: forward-auth # bare UI, no usable OIDC: oauth2-proxy → Keycloak at the Gateway (docs/15; R-IDP-02)
|
mode: forward-auth # bare UI, no usable OIDC: oauth2-proxy → Keycloak at the Gateway (docs/15; R-IDP-02)
|
||||||
|
|||||||
@@ -100,6 +100,81 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ---- Stage 3c: Flux post-build substitution safety (UNCONDITIONAL) ----------
|
||||||
|
# R-ENV-02's promote-once mechanism is `postBuild.substituteFrom cluster-vars`, and it
|
||||||
|
# FAILS OPEN: an undefined ${TOKEN} becomes the empty string and the Kustomization still
|
||||||
|
# reports Ready. That emptied Cilium's k8sServiceHost and broke the CNI on 2/3 dev nodes
|
||||||
|
# (DECISIONS-LOG 2026-09-01, "substitution race that fails OPEN"). The check asserts every
|
||||||
|
# ${...} in every substituted path is DEFINED in cluster-vars or deliberately ESCAPED
|
||||||
|
# `$${...}` — i.e. that enabling the StrictPostBuildSubstitutions feature gate cannot fail
|
||||||
|
# a legitimate manifest, and that no expansion silently empties today.
|
||||||
|
#
|
||||||
|
# WHY THIS IS ITS OWN STAGE and not left to Stage 3b: 3b scopes the conformance run to the
|
||||||
|
# TOUCHED requirements (R-TEST-03), and ci/touched-requirements.sh's coarse path map keys
|
||||||
|
# only clusters/* to R-ENV-02 — an edit to a SUBSTITUTED manifest under apps/ or platform/
|
||||||
|
# attributes no requirement, so the requirement-scoped selection would filter this check
|
||||||
|
# out of exactly the change that introduces the bug. The invariant is whole-repo (same
|
||||||
|
# reasoning as spec-immutability's own gate stage), so it runs on EVERY gate invocation.
|
||||||
|
# When 3b's touched set does include R-ENV-02/R-GITOPS-01 the check runs twice; it is a
|
||||||
|
# sub-second offline scan with no side effects, so the duplication is not worth avoiding.
|
||||||
|
SUBST_CHECK="${SUBST_CHECK:-${REPO_ROOT}/conformance/tests/l0-static/flux-postbuild-substitution/check.sh}"
|
||||||
|
log "Stage: Flux postBuild substitution safety (${SUBST_CHECK})"
|
||||||
|
if [ ! -f "$SUBST_CHECK" ]; then
|
||||||
|
skip "substitution check not present at '${SUBST_CHECK}' — UNWIRED, not green"
|
||||||
|
summary "- subst-safety: **SKIPPED** (check missing)"
|
||||||
|
if [ "$rc" -eq 0 ]; then rc=3; fi
|
||||||
|
else
|
||||||
|
subst_rc=0
|
||||||
|
bash "$SUBST_CHECK" || subst_rc=$?
|
||||||
|
case "$subst_rc" in
|
||||||
|
0) ok "Flux postBuild substitution safety passed"
|
||||||
|
summary "- subst-safety: **passed**" ;;
|
||||||
|
3) skip "substitution check SKIPPED (clusters/ absent) — not a pass"
|
||||||
|
summary "- subst-safety: **SKIPPED** (nothing to audit)"
|
||||||
|
if [ "$rc" -eq 0 ]; then rc=3; fi ;;
|
||||||
|
*) err "Flux postBuild substitution safety FAILED"
|
||||||
|
summary "- subst-safety: **FAILED**"; rc=1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Stage 3d: Keycloak client-secret declaration + reconciler (UNCONDITIONAL) ----
|
||||||
|
# The Keycloak realm import resolves each OIDC client's secret from an `(env:NAME)`
|
||||||
|
# placeholder. When the operator cannot resolve one it stores the LITERAL placeholder as
|
||||||
|
# the client secret AND THE IMPORT JOB STILL REPORTS SUCCESS, then rests at Done=True and
|
||||||
|
# never re-runs. On 2026-09-06 that had all 12 prod clients unusable ("Invalid client or
|
||||||
|
# Invalid client credentials") behind an entirely green cluster; dev had one. The check
|
||||||
|
# asserts the declaration is self-consistent (every placeholder defined, every Secret key
|
||||||
|
# present, names matching the reconciler's contract), that the Flux ordering is a REAL
|
||||||
|
# dependsOn edge rather than a comment, and — by extracting the shipped script from its
|
||||||
|
# ConfigMap and running its --selftest — that the reconciler is compare-then-write,
|
||||||
|
# idempotent, non-logging and blast-radius-bounded.
|
||||||
|
#
|
||||||
|
# WHY ITS OWN UNCONDITIONAL STAGE, same reasoning as 3c: Stage 3b scopes the conformance
|
||||||
|
# run to the TOUCHED requirements (R-TEST-03). This check's invariant spans platform/,
|
||||||
|
# platform-config/ and clusters/, so a change that breaks it need not attribute R-IDP-03/04
|
||||||
|
# and the requirement-scoped selection would filter it out of exactly the change that
|
||||||
|
# introduces the bug. It is a sub-second offline scan plus a loopback selftest, with no
|
||||||
|
# side effects, so running it twice when 3b also selects it is not worth avoiding.
|
||||||
|
KEYCLOAK_SECRET_CHECK="${KEYCLOAK_SECRET_CHECK:-${REPO_ROOT}/conformance/tests/l0-static/keycloak-client-secret-reconciler/check.sh}"
|
||||||
|
log "Stage: Keycloak client-secret reconciler safety (${KEYCLOAK_SECRET_CHECK})"
|
||||||
|
if [ ! -f "$KEYCLOAK_SECRET_CHECK" ]; then
|
||||||
|
skip "keycloak client-secret check not present at '${KEYCLOAK_SECRET_CHECK}' — UNWIRED, not green"
|
||||||
|
summary "- keycloak-client-secrets: **SKIPPED** (check missing)"
|
||||||
|
if [ "$rc" -eq 0 ]; then rc=3; fi
|
||||||
|
else
|
||||||
|
kc_rc=0
|
||||||
|
bash "$KEYCLOAK_SECRET_CHECK" || kc_rc=$?
|
||||||
|
case "$kc_rc" in
|
||||||
|
0) ok "Keycloak client-secret declaration + reconciler passed"
|
||||||
|
summary "- keycloak-client-secrets: **passed**" ;;
|
||||||
|
3) skip "keycloak client-secret check SKIPPED (platform/keycloak absent) — not a pass"
|
||||||
|
summary "- keycloak-client-secrets: **SKIPPED** (nothing to audit)"
|
||||||
|
if [ "$rc" -eq 0 ]; then rc=3; fi ;;
|
||||||
|
*) err "Keycloak client-secret declaration + reconciler FAILED"
|
||||||
|
summary "- keycloak-client-secrets: **FAILED**"; rc=1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
case "$rc" in
|
case "$rc" in
|
||||||
0) ok "L0 stage complete" ;;
|
0) ok "L0 stage complete" ;;
|
||||||
3) skip "L0 stage incomplete (unwired seam / missing tool) — not a pass" ;;
|
3) skip "L0 stage incomplete (unwired seam / missing tool) — not a pass" ;;
|
||||||
|
|||||||
@@ -180,9 +180,41 @@ path_requirements() {
|
|||||||
platform/externaldns/*) echo "R-DNS-02" ;;
|
platform/externaldns/*) echo "R-DNS-02" ;;
|
||||||
platform/velero/*) echo "R-STATE-03" ;;
|
platform/velero/*) echo "R-STATE-03" ;;
|
||||||
platform/monitoring/*) echo "R-OBS-01 R-OBS-02" ;;
|
platform/monitoring/*) echo "R-OBS-01 R-OBS-02" ;;
|
||||||
|
# platform/cnpg was UNMAPPED until 2026-09-06. It is the PostgreSQL DR path for keycloak-db /
|
||||||
|
# nextcloud-db / semaphore-db (barmanObjectStore WAL archiving + ScheduledBackup), so a change
|
||||||
|
# there is an R-IDP-05 / R-STATE-03 change; it now also carries the platform-health alert rules
|
||||||
|
# for that path (R-OBS-04). Without this line an edit under platform/cnpg/ contributed ZERO
|
||||||
|
# requirements unless it happened to retype an R-ID on a non-comment line, and escaped the gate.
|
||||||
|
platform/cnpg/*) echo "R-IDP-05 R-STATE-03 R-OBS-04" ;;
|
||||||
|
# vpn-egress was UNMAPPED until 2026-09-06. platform/vpn-egress/egress-steering.yaml IS the
|
||||||
|
# R-NET-04 datapath (the rule that selects between the two egress paths) and the R-NET-05/06/07
|
||||||
|
# steering policy — it even self-declares those IDs in its `homelab.keithjackson.dev/requirements`
|
||||||
|
# annotation. But the content scan only reads ADDED/REMOVED lines, so any edit that does not
|
||||||
|
# happen to retype an R-ID (a selector change, an excludedCIDRs change, a comment) contributed
|
||||||
|
# ZERO requirements and escaped the gate entirely. Mapped explicitly; the annotation still wins
|
||||||
|
# when a diff does carry the tags.
|
||||||
|
platform/vpn-egress/*) echo "R-NET-04 R-NET-05 R-NET-06 R-NET-07" ;;
|
||||||
|
# Keycloak is the whole SSO surface: the realm import (declarative identity config),
|
||||||
|
# the SOPS client secrets, and the reconciler that heals them. Added 2026-09-06 after
|
||||||
|
# the client-secret incident, in which editing platform/keycloak/ attributed NO
|
||||||
|
# requirement, so the L2 keycloak tests were filtered out of the merge gate's touched
|
||||||
|
# subset — the change that could break every OIDC login was gated by nothing.
|
||||||
|
# R-IDP-05 is in scope because platform/keycloak/ also carries the CNPG DB Cluster and
|
||||||
|
# its off-cluster backup credential, not only the realm.
|
||||||
|
platform/keycloak/*) echo "R-IDP-01 R-IDP-02 R-IDP-03 R-IDP-04 R-IDP-05 R-IDP-06" ;;
|
||||||
|
platform/oauth2-proxy/*) echo "R-IDP-02 R-IDP-06" ;;
|
||||||
platform/image-automation/*) echo "R-UPD-01 R-UPD-02" ;;
|
platform/image-automation/*) echo "R-UPD-01 R-UPD-02" ;;
|
||||||
conformance/*) echo "R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04" ;;
|
conformance/*) echo "R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04" ;;
|
||||||
clusters/*) echo "R-GITOPS-01 R-ENV-02" ;;
|
clusters/*) echo "R-GITOPS-01 R-ENV-02" ;;
|
||||||
|
# bootstrap/ was UNMAPPED until 2026-09-06, and the omission was load-bearing:
|
||||||
|
# bootstrap/standup.sh carries the placeholder guards that decide whether `velero` may be
|
||||||
|
# enabled against a real backup endpoint. A change touching standup.sh ALONGSIDE any
|
||||||
|
# already-mapped path scoped the L0 run to that other path's requirements and silently
|
||||||
|
# excluded conformance/tests/l0-static/placeholder-guard-coupling — the check that exists
|
||||||
|
# precisely to catch a guard/value mismatch. The 2026-09-05 guard bug (a prefix glob that
|
||||||
|
# stopped matching when the value it guards changed, in a different file) is exactly the
|
||||||
|
# class that omission would hide.
|
||||||
|
bootstrap/*) echo "R-STATE-03 R-GITOPS-01" ;;
|
||||||
.gitea/*|ci/*) echo "R-GITOPS-01 R-TEST-02 R-TEST-03" ;;
|
.gitea/*|ci/*) echo "R-GITOPS-01 R-TEST-02 R-TEST-03" ;;
|
||||||
*) : ;;
|
*) : ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
@@ -97,6 +97,27 @@ Kustomization: apps-wave-1 (./apps/wave-1, dependsOn apps-wave-0)
|
|||||||
Kustomization: apps-wave-N (./apps/wave-N, dependsOn apps-wave-N-1)
|
Kustomization: apps-wave-N (./apps/wave-N, dependsOn apps-wave-N-1)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Second-order CRDs need a THIRD pass (`platform-config-cdi`, added 2026-09-05)
|
||||||
|
|
||||||
|
The controllers/configs split (`platform` → `platform-config`) assumes a CRD is registered by the
|
||||||
|
controller **install**. CDI breaks that: `cdi-operator.yaml` registers only `cdis.cdi.kubevirt.io`,
|
||||||
|
and cdi-operator creates `storageprofiles` / `datavolumes` / `datasources` **only after it has
|
||||||
|
reconciled the `CDI` CR**. Since kustomize-controller server-side **dry-runs every object in a pass
|
||||||
|
before applying any of them**, having the `CDI` CR and the CDI `StorageProfile`s in one pass was a
|
||||||
|
**deadlock, not a race**: unknown kind → pass aborts → CR never applied → CRD never created →
|
||||||
|
`platform-config` can never converge → every tier below it blocked. Invisible on dev (its CDI CRDs
|
||||||
|
have existed for months); it cost 12h on prod's cold start.
|
||||||
|
|
||||||
|
So there is a third tier, `platform-config-cdi` (`./platform-config-cdi`, `dependsOn:
|
||||||
|
platform-config`), and a rule:
|
||||||
|
|
||||||
|
> A CR belongs in `platform-config-cdi`, not `platform-config`, when the CRD it needs is created by a
|
||||||
|
> **controller at runtime** rather than by a manifest in git.
|
||||||
|
|
||||||
|
`apps-vms` `dependsOn` it as well as `platform-config`, because CDI reads
|
||||||
|
`StorageProfile/longhorn-vm` at DataVolume **provisioning** time — a VM disk provisioned before that
|
||||||
|
profile exists is stamped RWO/Filesystem and is permanently not live-migratable.
|
||||||
|
|
||||||
Wave 0 `dependsOn` `platform-config` (transitively `platform`) so CNI/storage/TLS/DNS/gateway
|
Wave 0 `dependsOn` `platform-config` (transitively `platform`) so CNI/storage/TLS/DNS/gateway
|
||||||
exist before any workload reconciles. The app tier is split into dependency-ordered WAVES
|
exist before any workload reconciles. The app tier is split into dependency-ordered WAVES
|
||||||
(`catalog/render.py --emit-waves`): each `apps-wave-<N>` health-gates (wait:true) ONLY its own
|
(`catalog/render.py --emit-waves`): each `apps-wave-<N>` health-gates (wait:true) ONLY its own
|
||||||
|
|||||||
@@ -4,8 +4,11 @@
|
|||||||
# NON-GATING (wait:false): unlike the app waves, this does NOT health-wait. A VM's first-boot image
|
# NON-GATING (wait:false): unlike the app waves, this does NOT health-wait. A VM's first-boot image
|
||||||
# import (CDI, minutes) + in-guest software install mean a VM can be intentionally long-not-Ready, so
|
# import (CDI, minutes) + in-guest software install mean a VM can be intentionally long-not-Ready, so
|
||||||
# health-gating it would wedge reconciliation for no benefit. VMs have no in-cluster dependents.
|
# health-gating it would wedge reconciliation for no benefit. VMs have no in-cluster dependents.
|
||||||
# dependsOn platform-config so KubeVirt/CDI + the longhorn-vm StorageClass + CDI StorageProfiles
|
# dependsOn platform-config so KubeVirt/CDI + the longhorn-vm StorageClass exist first, AND
|
||||||
# exist first. prune:true — this Kustomization solely owns the VM manifests (no adoption concern).
|
# platform-config-cdi so the CDI StorageProfiles do (2026-09-05: the StorageProfiles MOVED OUT of
|
||||||
|
# platform-config into their own tier, so `dependsOn: platform-config` alone NO LONGER guarantees
|
||||||
|
# them — see the dependsOn block). prune:true — this Kustomization solely owns the VM manifests (no
|
||||||
|
# adoption concern).
|
||||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
metadata:
|
metadata:
|
||||||
@@ -19,6 +22,28 @@ spec:
|
|||||||
wait: false
|
wait: false
|
||||||
dependsOn:
|
dependsOn:
|
||||||
- name: platform-config
|
- name: platform-config
|
||||||
|
# LOAD-BEARING, not decoration. apps/vms/unifi/20-datavolume.yaml provisions a DataVolume on
|
||||||
|
# storageClassName `longhorn-vm`, and CDI reads StorageProfile/longhorn-vm AT PROVISIONING TIME to
|
||||||
|
# decide the claim's accessModes/volumeMode. Without that profile's `claimPropertySets`
|
||||||
|
# (ReadWriteMany + Block) the PVC is stamped RWO/Filesystem and the VM disk is PERMANENTLY not
|
||||||
|
# live-migratable — a silent, unrecoverable-without-recreate loss. The profiles used to arrive
|
||||||
|
# with platform-config; since 2026-09-05 they arrive with `platform-config-cdi`, so the edge has
|
||||||
|
# to move with them. platform-config-cdi runs wait:true and StorageProfile has no status
|
||||||
|
# conditions (kstatus -> Current on apply), so this edge cannot stall this tier.
|
||||||
|
#
|
||||||
|
# HONEST LIMIT: CDI provisions from the profile's STATUS, which its storageprofile-controller
|
||||||
|
# derives from our spec. This edge proves the spec is applied, not that CDI has folded it into
|
||||||
|
# status — that is a watch-triggered round-trip of seconds against a ~30s dependency requeue, so
|
||||||
|
# it is a timing argument rather than a guarantee. See clusters/base/platform-config-cdi.yaml.
|
||||||
|
#
|
||||||
|
# This edge also makes the StorageProfile prune/recreate transition safe: platform-config's prune
|
||||||
|
# deletes the two profiles for the ~30-60s before platform-config-cdi re-applies them, and this
|
||||||
|
# dependsOn is what guarantees Flux cannot create a DataVolume inside that window.
|
||||||
|
#
|
||||||
|
# The platform-config edge above is kept although platform-config-cdi already dependsOn it: the
|
||||||
|
# edges are a set, there is no cycle, and stating both means a future edit to platform-config-cdi
|
||||||
|
# cannot silently drop this tier's KubeVirt/StorageClass gate.
|
||||||
|
- name: platform-config-cdi
|
||||||
sourceRef:
|
sourceRef:
|
||||||
kind: GitRepository
|
kind: GitRepository
|
||||||
name: flux-system
|
name: flux-system
|
||||||
|
|||||||
@@ -34,6 +34,9 @@ spec:
|
|||||||
substituteFrom:
|
substituteFrom:
|
||||||
- kind: ConfigMap
|
- kind: ConfigMap
|
||||||
name: cluster-vars
|
name: cluster-vars
|
||||||
# SOPS decryption for velero-cloud.sops.yaml (the scoped MinIO `cloud` credential) is layered per
|
# SOPS decryption for the `cloud` INI credentials — velero-cloud.sops.yaml (dev's scoped MinIO key)
|
||||||
# environment by the overlay (clusters/dev patches THIS Kustomization by name), identical to how
|
# and velero-cloud-prod.sops.yaml (prod's Cloudflare R2 token) — is layered per environment by the
|
||||||
# headlamp/oauth2-proxy/crafty decrypt their own SOPS secrets. See clusters/dev/kustomization.yaml.
|
# overlay (clusters/dev and clusters/prod patch THIS Kustomization by name), identical to how
|
||||||
|
# headlamp/oauth2-proxy/crafty decrypt their own SOPS secrets. Both Secrets are applied on both
|
||||||
|
# clusters; ${BACKUP_CREDENTIALS_SUFFIX} (substituted from cluster-vars above) decides which one the
|
||||||
|
# velero Deployment actually mounts. See clusters/dev/kustomization.yaml.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
# Flux Kustomization: CILIUM (the CNI) — reconciles ./platform/cilium.
|
||||||
|
# Serves R-PLAT-01 (platform components reconciled by GitOps) and R-GITOPS-01/03.
|
||||||
|
#
|
||||||
|
# WHY CILIUM HAS ITS OWN KUSTOMIZATION (split out of `platform`, 2026-09-01)
|
||||||
|
#
|
||||||
|
# This is the MIRROR IMAGE of the oauth2-proxy / headlamp splits, not a repeat of them. Those are
|
||||||
|
# leaves that must NEVER gate a tier, so they left `platform` and run `wait: false` below it.
|
||||||
|
# Cilium is the one component that legitimately SHOULD gate everything — but inside the shared
|
||||||
|
# `platform` Kustomization its health was indistinguishable from eleven unrelated components:
|
||||||
|
#
|
||||||
|
# * `platform`'s Ready condition could not tell you "the CNI is down" from "longhorn is slow".
|
||||||
|
# * The only emergency valve was `flux suspend kustomization platform` — freezing the ENTIRE
|
||||||
|
# tier (storage, TLS, DNS, ingress, monitoring, Keycloak, KubeVirt) to stop touching the CNI.
|
||||||
|
# `flux suspend kustomization cilium` now freezes exactly the CNI and nothing else, which is
|
||||||
|
# the valve the 2026-09-01 substitution incident wanted and did not have.
|
||||||
|
# * `platform`'s 10m interval + 5m timeout are wrong for a CNI: a 3-node DaemonSet rollout with
|
||||||
|
# `wait: true` can exceed 5m, and re-rendering the CNI every 10m buys nothing.
|
||||||
|
#
|
||||||
|
# GATING IS PRESERVED, NOT WEAKENED: `wait: true` here plus `platform.dependsOn: [cilium]` means
|
||||||
|
# nothing in the graph reconciles until the CNI is healthy — the same guarantee as before, now
|
||||||
|
# expressed as an explicit edge instead of an accident of co-tenancy.
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: cilium
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
# A CNI does not need a 10m re-render cadence. A git push still reconciles immediately —
|
||||||
|
# kustomize-controller is source-triggered; `interval` is only the drift-correction floor.
|
||||||
|
interval: 1h
|
||||||
|
retryInterval: 2m
|
||||||
|
# 10m (not the 5m `platform` uses): a 3-node Cilium DaemonSet rollout under `wait: true`,
|
||||||
|
# rolled ONE NODE AT A TIME (maxUnavailable: 1, P2), legitimately exceeds 5m.
|
||||||
|
timeout: 10m
|
||||||
|
prune: true
|
||||||
|
# TRUE, deliberately: this is the ONE component whose health should gate the whole platform.
|
||||||
|
wait: true
|
||||||
|
# Same revision-gating reason `platform` has it (2026-09-01 incident): `cluster-vars` is APPLIED
|
||||||
|
# by the root `flux-system` Kustomization, and this Kustomization SUBSTITUTES from it. `dependsOn`
|
||||||
|
# is revision-gated (kustomize-controller v1.4.0 `checkDependencies`), so this cannot render
|
||||||
|
# revision N of helmrelease.yaml against the revision N-1 `cluster-vars`. That race is exactly how
|
||||||
|
# `k8sServiceHost: ""` once reached the Cilium HelmRelease and took down 2/3 agents.
|
||||||
|
dependsOn:
|
||||||
|
- name: flux-system # the ROOT Kustomization (sync.yaml) that applies cluster-vars
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: flux-system
|
||||||
|
path: ./platform/cilium
|
||||||
|
# MANDATORY, not decorative: platform/cilium/helmrelease.yaml references ${K3S_API_HOST}.
|
||||||
|
# kustomize-controller now runs --feature-gates=StrictPostBuildSubstitutions=true, so without
|
||||||
|
# this stanza an undefined ${K3S_API_HOST} FAILS this Kustomization outright (it no longer
|
||||||
|
# renders empty). That is the intended fail-closed behaviour — but it means the stanza is
|
||||||
|
# load-bearing for the CNI coming up at all. Do not remove it.
|
||||||
|
postBuild:
|
||||||
|
substituteFrom:
|
||||||
|
- kind: ConfigMap
|
||||||
|
name: cluster-vars
|
||||||
|
# No `decryption` stanza: platform/cilium carries no SOPS-encrypted resource (unlike
|
||||||
|
# oauth2-proxy/headlamp/crafty/velero, whose overlays patch one in by name).
|
||||||
+221
-11
@@ -17,9 +17,63 @@ data:
|
|||||||
CLUSTER_ENV: "base" # dev | prod (overlays set this)
|
CLUSTER_ENV: "base" # dev | prod (overlays set this)
|
||||||
|
|
||||||
# --- DNS / TLS ---
|
# --- DNS / TLS ---
|
||||||
BASE_DOMAIN: "home.keithjackson.dev" # platform hostname suffix (same both envs; split-horizon)
|
# THE platform hostname suffix. Every public hostname the cluster serves is `<svc>.${BASE_DOMAIN}`,
|
||||||
DNS_ZONE: "keithjackson.dev" # the actual Cloudflare ZONE (BASE_DOMAIN is a subdomain of it);
|
# the wildcard cert is `*.${BASE_DOMAIN}`, coredns-custom answers the `${BASE_DOMAIN}` zone with
|
||||||
# ExternalDNS domain-filter must be the zone, not the subdomain
|
# ${GATEWAY_VIP}, and every OIDC issuer/redirect URI is built from it.
|
||||||
|
#
|
||||||
|
# THIS KNOB WAS DEAD UNTIL 2026-09-04 — declared here but referenced by NO manifest, because every
|
||||||
|
# hostname was hardcoded. That is what made the 2026-09-04 incident possible: prod and dev were
|
||||||
|
# authored to serve the SAME hostnames out of the SAME Cloudflare zone, so prod's external-dns
|
||||||
|
# (policy: sync, owner `default`) concluded it owned dev's public records and DELETED ALL OF THEM.
|
||||||
|
# See infra/PROD-PROVISIONING.md §10. The fix is architectural: each environment gets its OWN
|
||||||
|
# subdomain, so the two clusters can no longer describe the same name.
|
||||||
|
#
|
||||||
|
# dev -> home.keithjackson.dev (UNCHANGED — see below)
|
||||||
|
# prod -> prod.home.keithjackson.dev
|
||||||
|
#
|
||||||
|
# DEV DELIBERATELY STAYS ON THE APEX `home.keithjackson.dev`. The target architecture puts dev on
|
||||||
|
# `dev.home.keithjackson.dev` with apex CNAMEs `<svc>.home` -> `<svc>.prod.home`, but moving dev
|
||||||
|
# relocates its Keycloak issuer, every OIDC client redirect URI, kubectl SSO, its wildcard cert and
|
||||||
|
# the Gitea host that IS the GitOps source — on a live cluster. Deferred to phase 2
|
||||||
|
# (infra/PROD-PROVISIONING.md §10). Base default is therefore the DEV value, the same convention as
|
||||||
|
# GATEWAY_VIP / LONGHORN_DATA_PATH: the base default is the known-good working value, dev restates
|
||||||
|
# it verbatim, and prod overrides it. Dev's rendered output is byte-identical to the pre-
|
||||||
|
# tokenization hardcoded manifests (verified 2026-09-04, all 12 substituted paths).
|
||||||
|
BASE_DOMAIN: "home.keithjackson.dev"
|
||||||
|
|
||||||
|
# ExternalDNS `--domain-filter` (platform/externaldns/helmrelease.yaml). SAFETY NET #2 behind
|
||||||
|
# EXTERNALDNS_TXT_OWNER_ID: a record whose name falls outside this filter is not even considered,
|
||||||
|
# so it cannot be created, updated or deleted regardless of TXT ownership.
|
||||||
|
#
|
||||||
|
# DEV KEEPS THE ZONE APEX `keithjackson.dev`, and must: dev serves the `home.keithjackson.dev`
|
||||||
|
# apex itself, and the Cloudflare provider filters the ACCOUNT'S ZONES by this value — a filter
|
||||||
|
# that is a strict subdomain of the zone dropped the parent zone outright ("no hosted zone matching
|
||||||
|
# record DNS Name", DECISIONS-LOG 2026-08) on the version in use then. So dev's value is unchanged
|
||||||
|
# from the retired `DNS_ZONE` knob this replaces, and dev renders byte-identically.
|
||||||
|
#
|
||||||
|
# PROD NARROWS TO ITS OWN SUBTREE `prod.home.keithjackson.dev`. VERIFY ON PROD'S FIRST RECONCILE
|
||||||
|
# (infra/PROD-PROVISIONING.md §10): if the zone-lookup limitation above still applies to the pinned
|
||||||
|
# chart, prod's external-dns will select NO zone and create NO records. That failure is FAIL-CLOSED
|
||||||
|
# — it can never delete another environment's records — and the fallback is to set prod's value
|
||||||
|
# back to "keithjackson.dev", leaving EXTERNALDNS_TXT_OWNER_ID as the sole (and sufficient) guard.
|
||||||
|
EXTERNALDNS_DOMAIN_FILTER: "keithjackson.dev"
|
||||||
|
|
||||||
|
# ExternalDNS TXT registry owner id (`--txt-owner-id`). THE ROOT CAUSE OF THE 2026-09-04 INCIDENT:
|
||||||
|
# neither cluster set it, so both inherited the chart default `default`, and ExternalDNS treats a
|
||||||
|
# record whose TXT registry entry carries ITS OWN owner id as its own to reconcile — and with
|
||||||
|
# `policy: sync`, to DELETE when no local resource claims it. Two clusters sharing an owner id on
|
||||||
|
# one zone therefore garbage-collect each other. Each environment MUST have a distinct value.
|
||||||
|
#
|
||||||
|
# DEV MUST RENDER EXACTLY `default`. Dev's LIVE public records are already registered in Cloudflare
|
||||||
|
# under owner `default`; changing dev's value would make dev's own external-dns stop recognising
|
||||||
|
# them, and it would re-create duplicates alongside the orphaned originals. Dev's overlay pins it.
|
||||||
|
#
|
||||||
|
# THE BASE DEFAULT IS A NON-COLLIDING SENTINEL, deliberately NOT `default`. A new environment that
|
||||||
|
# forgets this knob must not silently inherit dev's identity — that is precisely the incident. With
|
||||||
|
# this sentinel such a cluster owns nothing, so it can DELETE nothing; it would publish under a
|
||||||
|
# visibly wrong owner id (grep the TXT records) instead of destroying a live environment's DNS.
|
||||||
|
EXTERNALDNS_TXT_OWNER_ID: "unset-see-cluster-vars"
|
||||||
|
|
||||||
ACME_CLUSTER_ISSUER: "letsencrypt-staging" # dev: staging | prod: letsencrypt-prod (ADR-0006)
|
ACME_CLUSTER_ISSUER: "letsencrypt-staging" # dev: staging | prod: letsencrypt-prod (ADR-0006)
|
||||||
GATEWAY_VIP: "192.168.1.53" # in-cluster split-horizon target (R-DNS-01/03): the L2-announced
|
GATEWAY_VIP: "192.168.1.53" # in-cluster split-horizon target (R-DNS-01/03): the L2-announced
|
||||||
# ingress VIP of the `shared` Gateway. MUST equal the FIRST address of
|
# ingress VIP of the `shared` Gateway. MUST equal the FIRST address of
|
||||||
@@ -42,15 +96,55 @@ data:
|
|||||||
#
|
#
|
||||||
# Substitution coverage (verify before adding a knob here): every path that consumes these declares
|
# Substitution coverage (verify before adding a knob here): every path that consumes these declares
|
||||||
# postBuild.substituteFrom cluster-vars — platform (K3S_API_HOST), platform-config
|
# postBuild.substituteFrom cluster-vars — platform (K3S_API_HOST), platform-config
|
||||||
# (CILIUM_LB_POOL_*, S3_BACKUP_ENDPOINT), crafty (CRAFTY_L4_VIP), apps-wave-0 (S3_BACKUP_ENDPOINT),
|
# (CILIUM_LB_POOL_*, and keycloak-db's S3_BACKUP_ENDPOINT + CNPG_BACKUP_PATH: the Cluster lives at
|
||||||
# velero (S3_BACKUP_ENDPOINT), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an UNSUBSTITUTED path would
|
# platform/keycloak/postgres-cluster.yaml but is rendered by platform-config, which is the ONLY
|
||||||
# ship the literal string into the cluster.
|
# tier that lists it), crafty (CRAFTY_L4_VIP), apps-wave-0 (nextcloud-db + semaphore-db:
|
||||||
|
# S3_BACKUP_ENDPOINT, CNPG_BACKUP_PATH), velero (S3_BACKUP_ENDPOINT, S3_BACKUP_REGION,
|
||||||
|
# VELERO_BACKUP_BUCKET, VELERO_BACKUP_PREFIX), apps-vms (UNIFI_L4_VIP). A ${TOKEN} in an
|
||||||
|
# UNSUBSTITUTED path would ship the literal string into the cluster.
|
||||||
|
#
|
||||||
|
# ADDING A KNOB IS SAFE UNDER StrictPostBuildSubstitutions ONLY BECAUSE OF THE ROOT EDGE. An
|
||||||
|
# UNDEFINED variable is a HARD Kustomization failure, so a manifest that references a knob the
|
||||||
|
# live ConfigMap does not yet carry breaks its whole tier. `clusters/base/platform.yaml` gained
|
||||||
|
# `dependsOn: [flux-system]` for exactly this: dependsOn is REVISION-gated, so no substituting
|
||||||
|
# tier can reconcile revision N until the ROOT Kustomization has APPLIED revision N — i.e. until
|
||||||
|
# the new cluster-vars key EXISTS. Every substituting consumer chains transitively through
|
||||||
|
# `platform`, so one edge covers the graph, and a new knob plus its first reference may ship in
|
||||||
|
# ONE commit. (A hand-run `kubectl apply -k clusters/<env>` bypasses that ordering entirely: apply
|
||||||
|
# the overlay BEFORE the tier that references the knob, or the tier fails until it retries.)
|
||||||
|
|
||||||
# k3s API endpoint Cilium's kube-proxy replacement dials (platform/cilium/helmrelease.yaml).
|
# k3s API endpoint Cilium's kube-proxy replacement dials (platform/cilium/helmrelease.yaml).
|
||||||
# [parity] MUST equal the Ansible bootstrap's k3s_api_host (infra/ansible/roles/cilium/defaults/
|
#
|
||||||
# main.yml -> cilium-values.yaml.j2, already inventory-parameterized). Wrong value = Cilium cannot
|
# *** THIS IS THE CONTROL-PLANE VIP (kube-vip), NOT A NODE ADDRESS. *** Operator decision
|
||||||
# reach the API server on adopt and the CNI breaks. Port stays 6443 in both envs, so it is NOT a knob.
|
# 2026-09-06. It was each environment's PRIMARY NODE IP until then, which made the CNI's
|
||||||
K3S_API_HOST: "192.168.1.50"
|
# control-plane path depend on one machine in a cluster whose entire purpose is HA: verified on
|
||||||
|
# live prod, all three cilium-agents carried KUBERNETES_SERVICE_HOST=192.168.2.10 (k3s-prod-1's
|
||||||
|
# own address) while kube-vip held 192.168.2.9. Losing that node leaves the datapath running off
|
||||||
|
# Cilium's cache while NO service or policy update lands — a silent, unbounded staleness, not an
|
||||||
|
# outage you would notice. A virtual address that follows the control plane is the fix, and it is
|
||||||
|
# what kube-vip was stood up to provide.
|
||||||
|
#
|
||||||
|
# dev -> 192.168.1.49 (= kube_vip_address, group_vars/all.yml; base default, below)
|
||||||
|
# prod -> 192.168.2.9 (= kube_vip_address, group_vars/k3s_prod/vars.yml)
|
||||||
|
#
|
||||||
|
# THE NAME IS NOW SLIGHTLY WRONG AND IS KEPT ANYWAY: renaming the knob would be a second,
|
||||||
|
# unrelated change to every substituting Kustomization at the same time as a live CNI endpoint
|
||||||
|
# move. Read it as "the API host Cilium dials", which is what it always meant.
|
||||||
|
#
|
||||||
|
# [parity] MUST equal the Ansible bootstrap's **k3s_apiserver_endpoint** for the SAME environment
|
||||||
|
# (infra/ansible/inventory/group_vars/all.yml -> roles/cilium/templates/cilium-values.yaml.j2,
|
||||||
|
# already inventory-parameterized). NOT k3s_api_host — that variable still exists and still holds
|
||||||
|
# the FOUNDER's address, because the cold-bootstrap `--server` join needs a machine that is
|
||||||
|
# already up before any VIP exists. The two are deliberately separate; see the TWO API ADDRESSES
|
||||||
|
# block at the top of group_vars/all.yml.
|
||||||
|
#
|
||||||
|
# Wrong value = Cilium cannot reach the API server on adopt and the CNI breaks. Port stays 6443
|
||||||
|
# in both envs, so it is NOT a knob.
|
||||||
|
#
|
||||||
|
# TLS needs nothing: k3s_tls_sans has always carried kube_vip_address alongside every node IP, so
|
||||||
|
# both addresses are already in the serving cert (verified live 2026-09-06 — prod answers through
|
||||||
|
# https://192.168.2.9:6443 under full CA verification). No cert reissue, no k3s restart.
|
||||||
|
K3S_API_HOST: "192.168.1.49"
|
||||||
|
|
||||||
# CiliumLoadBalancerIPPool `default-pool` (platform-config/cilium/l2-ip-pool.yaml): the VIP range
|
# CiliumLoadBalancerIPPool `default-pool` (platform-config/cilium/l2-ip-pool.yaml): the VIP range
|
||||||
# L2-announced on the flat LAN. Must sit OUTSIDE the node IPs and any DHCP scope, and must match the
|
# L2-announced on the flat LAN. Must sit OUTSIDE the node IPs and any DHCP scope, and must match the
|
||||||
@@ -69,11 +163,34 @@ data:
|
|||||||
# Off-cluster S3 backup target (R-STATE-03, R-IDP-05) — the on-Proxmox MinIO, plain HTTP, path-style.
|
# Off-cluster S3 backup target (R-STATE-03, R-IDP-05) — the on-Proxmox MinIO, plain HTTP, path-style.
|
||||||
# Consumed by BOTH backup paths: every CNPG Cluster's barmanObjectStore.endpointURL (keycloak-db,
|
# Consumed by BOTH backup paths: every CNPG Cluster's barmanObjectStore.endpointURL (keycloak-db,
|
||||||
# nextcloud-db, semaphore-db) and Velero's backupStorageLocation s3Url. Full URL incl. scheme+port.
|
# nextcloud-db, semaphore-db) and Velero's backupStorageLocation s3Url. Full URL incl. scheme+port.
|
||||||
# Prod needs its OWN target: a backup that lands on the dev LAN's MinIO is not off-cluster DR.
|
# Prod needs its OWN target: a backup that lands on the dev LAN's MinIO is not off-cluster DR —
|
||||||
|
# and, measured 2026-09-05 from a pod INSIDE prod, it CANNOT reach it anyway (prod is on the SOHO
|
||||||
|
# network 192.168.2.0/24, which is pfSense's WAN, while this MinIO sits inside pfSense's LAN:
|
||||||
|
# `curl http://192.168.1.9:9000/minio/health/live` timed out after 15s). Prod uses Cloudflare R2.
|
||||||
S3_BACKUP_ENDPOINT: "http://192.168.1.9:9000"
|
S3_BACKUP_ENDPOINT: "http://192.168.1.9:9000"
|
||||||
|
|
||||||
|
# S3 region. Every S3 client REQUIRES one; MinIO ignores whatever it is; Cloudflare R2 demands
|
||||||
|
# exactly `auto` (its `head-bucket` reports the physical hint `WNAM`, which is NOT a usable region
|
||||||
|
# string). This was hardcoded `us-east-1` inside platform/velero/helmrelease.yaml — correct-by-
|
||||||
|
# accident for MinIO and wrong for anything else, which is why it is a knob now. The base default
|
||||||
|
# is dev's LIVE value, deliberately: dev's Velero has been signing requests with `us-east-1`
|
||||||
|
# against that MinIO since bootstrap, and this repo's rule is that base == the known-good dev
|
||||||
|
# value. Prod overrides to `auto`.
|
||||||
|
S3_BACKUP_REGION: "us-east-1"
|
||||||
|
|
||||||
# --- storage (ADR-0003) ---
|
# --- storage (ADR-0003) ---
|
||||||
LONGHORN_DEFAULT_REPLICA_COUNT: "3" # default StorageClass replica count (3 on 3 nodes)
|
LONGHORN_DEFAULT_REPLICA_COUNT: "3" # default StorageClass replica count (3 on 3 nodes)
|
||||||
|
# Longhorn's defaultDataPath (platform/longhorn/helmrelease.yaml): the on-NODE directory every
|
||||||
|
# replica is written into. It must be the path the NODE's own data disk is actually mounted at —
|
||||||
|
# roles/node_baseline owns that half (node_baseline_longhorn_data_path), and the two are ONE
|
||||||
|
# fact stated in two places, so [parity] holds per environment or replicas land on the OS disk.
|
||||||
|
# Base default = the DEV value, same convention as GATEWAY_VIP / the LAN block: the known-good
|
||||||
|
# working value. Dev's dedicated /dev/vdb IS mounted here and the LIVE cluster has 18 volumes
|
||||||
|
# on it — changing dev's value would orphan every replica, so the dev overlay restates it
|
||||||
|
# verbatim. PROD DIFFERS: its data partition serves TWO tenants (Longhorn plus k3s's whole data
|
||||||
|
# dir, moved off the 11.8 GiB /var — infra/PROD-PROVISIONING.md §2), so it is mounted at the
|
||||||
|
# neutral /data and Longhorn gets the /data/longhorn SUBDIRECTORY, not the mount root.
|
||||||
|
LONGHORN_DATA_PATH: "/var/lib/longhorn"
|
||||||
|
|
||||||
# --- observability sizing (docs/10) ---
|
# --- observability sizing (docs/10) ---
|
||||||
MONITORING_PROMETHEUS_RETENTION: "7d" # dev short | prod long
|
MONITORING_PROMETHEUS_RETENTION: "7d" # dev short | prod long
|
||||||
@@ -113,6 +230,99 @@ data:
|
|||||||
VELERO_BACKUP_SCHEDULE: "0 3 * * *" # cron; overlays may differ
|
VELERO_BACKUP_SCHEDULE: "0 3 * * *" # cron; overlays may differ
|
||||||
VELERO_RETENTION: "168h" # dev short | prod long
|
VELERO_RETENTION: "168h" # dev short | prod long
|
||||||
|
|
||||||
|
# ==========================================================================================
|
||||||
|
# OBJECT-STORE TENANCY — the three knobs that keep two clusters and two backup tools from
|
||||||
|
# writing into each other's data. THESE ARE SAFETY KNOBS, not preferences.
|
||||||
|
# ==========================================================================================
|
||||||
|
# Nothing about the object layout was per-environment before this: Velero's bucket was the
|
||||||
|
# hardcoded literal `velero` with no prefix, and every CNPG Cluster used the hardcoded
|
||||||
|
# `s3://cnpg` with barman's `serverName` defaulting to the CLUSTER NAME — and BOTH environments
|
||||||
|
# have a keycloak-db, a nextcloud-db and a semaphore-db. Point two clusters at one bucket under
|
||||||
|
# that layout and:
|
||||||
|
# (a) each cluster's Velero ADOPTS the other's backups and TTL-DELETES them on retention; and
|
||||||
|
# (b) two different Postgres servers stream WAL into ONE Barman prefix, which does not merely
|
||||||
|
# mix files — it CORRUPTS THE CHAIN and destroys PITR for both. That is the worse half:
|
||||||
|
# dev's DR restore is PROVEN (R-STATE-03) and this would silently un-prove it.
|
||||||
|
#
|
||||||
|
# WHY THERE IS NO SINGLE `S3_BACKUP_BUCKET`. The two consumers take differently-SHAPED values:
|
||||||
|
# the Velero chart wants `bucket` and `prefix` as separate fields, while CNPG's
|
||||||
|
# barmanObjectStore.destinationPath is ONE full s3:// URL whose first path segment IS the bucket.
|
||||||
|
# CNPG's bucket therefore already lives inside CNPG_BACKUP_PATH; adding a second knob that had to
|
||||||
|
# hold the same bucket name a second time would be a rotation waiting to drift — the exact class
|
||||||
|
# of duplication this repo has been bitten by. Three knobs, each shaped for its own consumer, and
|
||||||
|
# no two of them can contradict each other.
|
||||||
|
#
|
||||||
|
# BASE = DEV'S LIVE LAYOUT, BYTE-FOR-BYTE, AND MUST STAY THAT WAY. Dev's existing backups are at
|
||||||
|
# `s3://velero/` (bucket root, no prefix) and `s3://cnpg/<clusterName>/` on that MinIO. Giving dev
|
||||||
|
# a prefix, or renaming its buckets, would ORPHAN the very backups its proven restore depends on —
|
||||||
|
# the same reason dev's EXTERNALDNS_TXT_OWNER_ID stays `default` and its LONGHORN_DATA_PATH stays
|
||||||
|
# /var/lib/longhorn. Verified: with an EMPTY prefix the Velero chart omits `objectStorage.prefix`
|
||||||
|
# from the rendered BackupStorageLocation entirely (helm template, chart 12.1.0), so dev's live
|
||||||
|
# BSL is unchanged by this parameterization rather than merely equivalent to it.
|
||||||
|
#
|
||||||
|
# PROD, which has no history to preserve, gets ONE bucket with TWO disjoint top-level prefixes:
|
||||||
|
#
|
||||||
|
# s3://k3s-prod-backup/velero/… Velero (kopia repo + backup metadata)
|
||||||
|
# s3://k3s-prod-backup/barman/<clusterName>/… CNPG WAL + base backups
|
||||||
|
#
|
||||||
|
# The operator created exactly one R2 bucket, so velero-vs-barman separation has to come from the
|
||||||
|
# prefix rather than the bucket. The BUCKET ROOT IS DELIBERATELY LEFT EMPTY: if a prefix ever
|
||||||
|
# renders wrong, its objects land somewhere obviously bogus instead of silently colliding with a
|
||||||
|
# real tenant's data. Dev↔prod isolation is doubly independent — different endpoint AND different
|
||||||
|
# bucket — so neither cluster can reach the other's objects even with a mis-substituted prefix.
|
||||||
|
VELERO_BACKUP_BUCKET: "velero" # dev: the `velero` MinIO bucket | prod: k3s-prod-backup
|
||||||
|
VELERO_BACKUP_PREFIX: "" # dev: bucket root (live layout) | prod: "velero"
|
||||||
|
CNPG_BACKUP_PATH: "s3://cnpg" # dev: the `cnpg` MinIO bucket root | prod: s3://k3s-prod-backup/barman
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------------------------
|
||||||
|
# WHICH CREDENTIAL SECRET THE BACKUP CONSUMERS READ (added 2026-09-07 — the per-env credential split)
|
||||||
|
# ------------------------------------------------------------------------------------------------
|
||||||
|
# The three knobs above say WHERE the backups go. This one says WHICH KEY signs the requests, and
|
||||||
|
# it exists because the endpoint moved per-environment while the credential did not.
|
||||||
|
#
|
||||||
|
# THE BUG IT FIXES. The four backup-credential Secrets — velero-cloud.sops.yaml and the three
|
||||||
|
# <db>-backup-credentials.sops.yaml — are SHARED BASE manifests: platform/ and apps/ are rendered
|
||||||
|
# by the same Flux tiers on both clusters, and cluster-vars substitution is the ONLY thing that
|
||||||
|
# differs (R-ENV-02). So writing prod's R2 token into those four files did not GIVE prod a
|
||||||
|
# credential, it REPLACED DEV'S: dev's Velero and all three dev CNPG Clusters would have started
|
||||||
|
# signing MinIO requests with an R2 key id. For Velero that is a broken BSL; for CNPG it is worse,
|
||||||
|
# because `archive_mode=on` means Postgres REFUSES TO RECYCLE a WAL segment it could not archive,
|
||||||
|
# so a 403 loop is a monotonically filling pgdata PVC ending in a PANIC — the same failure prod
|
||||||
|
# spent its whole bring-up in with a placeholder endpoint. One shared Secret also means rotating
|
||||||
|
# either environment's key silently breaks the other; that is the structural reason for the split,
|
||||||
|
# independent of dev's eventual decommission.
|
||||||
|
#
|
||||||
|
# THE MECHANISM. Each credential is now TWO Secret objects, both applied to BOTH clusters (they are
|
||||||
|
# base resources; nothing here forks a manifest per environment). The consumer names one:
|
||||||
|
#
|
||||||
|
# velero: credentials.existingSecret: velero-cloud-credentials${BACKUP_CREDENTIALS_SUFFIX}
|
||||||
|
# CNPG: s3Credentials.*.name: <db>-backup-credentials${BACKUP_CREDENTIALS_SUFFIX}
|
||||||
|
#
|
||||||
|
# ONE knob, not four, ON PURPOSE. Four independent secret-name knobs could be updated three-
|
||||||
|
# quarters of the way, leaving one consumer pointed at the OTHER environment's key with nothing
|
||||||
|
# rendering wrong — and StrictPostBuildSubstitutions cannot catch that, because it only fails on an
|
||||||
|
# UNDEFINED variable, never on a defined-but-stale one. With a single suffix the four move together
|
||||||
|
# or not at all. The cost is that the rendered Secret name is not greppable from the consumer; the
|
||||||
|
# names are spelled out in full in each kustomization.yaml beside the resources.
|
||||||
|
#
|
||||||
|
# THE BASE DEFAULT IS THE EMPTY STRING, AND THAT IS LOAD-BEARING. It renders the UNSUFFIXED names,
|
||||||
|
# which are the objects that already exist and that the live dev cluster is running. A default
|
||||||
|
# naming a Secret that does not exist would be worse than the shared-Secret state it replaces:
|
||||||
|
# Velero's pod would sit in ContainerCreating forever on a missing `existingSecret` volume, and a
|
||||||
|
# CNPG Cluster whose s3Credentials Secret is absent stops archiving WAL — i.e. the disk-full clock
|
||||||
|
# again — so an unresolvable default breaks the referencing cluster HARDER than a wrong key does.
|
||||||
|
# Empty is also the value dev must carry, so base and dev agree, exactly like VELERO_BACKUP_PREFIX.
|
||||||
|
#
|
||||||
|
# An EMPTY VALUE IS SAFE UNDER StrictPostBuildSubstitutions: strict mode fails on a variable that is
|
||||||
|
# not DEFINED, not on one defined as "". VELERO_BACKUP_PREFIX above has shipped empty since the
|
||||||
|
# gate was enabled and proves it. Both overlays state this knob explicitly — dev "" and prod
|
||||||
|
# "-prod" — so neither inherits it silently.
|
||||||
|
#
|
||||||
|
# ADDING A NEW BACKUP CREDENTIAL: create BOTH halves (`x-credentials.sops.yaml` and
|
||||||
|
# `x-credentials-prod.sops.yaml`), list both in the kustomization, and reference
|
||||||
|
# `x-credentials${BACKUP_CREDENTIALS_SUFFIX}`. Never a single shared Secret again.
|
||||||
|
BACKUP_CREDENTIALS_SUFFIX: "" # dev: "" (MinIO creds) | prod: "-prod" (R2 creds)
|
||||||
|
|
||||||
# --- placement ---
|
# --- placement ---
|
||||||
PLATFORM_NODE_SELECTOR_KEY: "kubernetes.io/os" # base no-op selector; prod may pin roles
|
PLATFORM_NODE_SELECTOR_KEY: "kubernetes.io/os" # base no-op selector; prod may pin roles
|
||||||
PLATFORM_NODE_SELECTOR_VALUE: "linux"
|
PLATFORM_NODE_SELECTOR_VALUE: "linux"
|
||||||
@@ -5,8 +5,24 @@ apiVersion: kustomize.config.k8s.io/v1beta1
|
|||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
resources:
|
resources:
|
||||||
- flux-system.yaml # GitRepository (reconciliation source)
|
- flux-system.yaml # GitRepository (reconciliation source)
|
||||||
|
# Cilium (the CNI) — its OWN Kustomization, split OUT of `platform` (2026-09-01). The mirror image
|
||||||
|
# of the oauth2-proxy/headlamp splits: those are leaves that must never gate a tier (wait:false);
|
||||||
|
# Cilium is the one component that legitimately SHOULD gate everything, so it keeps wait:true and
|
||||||
|
# `platform` dependsOn it. Splitting it out buys a per-CNI Ready condition, CNI-appropriate
|
||||||
|
# interval/timeout, and `flux suspend kustomization cilium` as an emergency valve that does not
|
||||||
|
# freeze the other eleven platform components. See clusters/base/cilium.yaml.
|
||||||
|
- cilium.yaml # Flux Kustomization -> ./platform/cilium (dependsOn flux-system, wait:true)
|
||||||
- platform.yaml # Flux Kustomization -> ./platform (CONTROLLERS: HelmReleases / CRDs)
|
- platform.yaml # Flux Kustomization -> ./platform (CONTROLLERS: HelmReleases / CRDs)
|
||||||
- platform-config.yaml # Flux Kustomization -> ./platform-config (CONFIGS: CRs; dependsOn platform)
|
- platform-config.yaml # Flux Kustomization -> ./platform-config (CONFIGS: CRs; dependsOn platform)
|
||||||
|
# SECOND-ORDER CDI configs — CRs whose CRD is created by a controller AT RUNTIME, not by any
|
||||||
|
# manifest in git. cdi-operator.yaml registers only `cdis.cdi.kubevirt.io`; `storageprofiles` (and
|
||||||
|
# datavolumes, datasources, …) appear only after cdi-operator reconciles the `CDI` CR that
|
||||||
|
# platform-config applies. Because Flux dry-runs every object in a pass before applying any of them,
|
||||||
|
# keeping the StorageProfiles in platform-config was a TRUE DEADLOCK (unknown kind -> pass aborts ->
|
||||||
|
# CR never applied -> CRD never created -> tier never converges), invisible on dev and fatal on any
|
||||||
|
# cold cluster — observed on prod 2026-09-05. This tier is the durable fix; `standup.sh crds` stays
|
||||||
|
# as the belt-and-braces repair for clusters already wedged. See clusters/base/platform-config-cdi.yaml.
|
||||||
|
- platform-config-cdi.yaml # Flux Kustomization -> ./platform-config-cdi (dependsOn platform-config)
|
||||||
# App tier, split into dependency-ordered WAVES (was the single ./apps `apps` Kustomization).
|
# App tier, split into dependency-ordered WAVES (was the single ./apps `apps` Kustomization).
|
||||||
# Each apps-wave-<N> dependsOn wave N-1 (wave 0 dependsOn platform-config) and health-gates
|
# Each apps-wave-<N> dependsOn wave N-1 (wave 0 dependsOn platform-config) and health-gates
|
||||||
# (wait:true) ONLY its own wave, so a crashloop in a later wave can't stall an earlier one.
|
# (wait:true) ONLY its own wave, so a crashloop in a later wave can't stall an earlier one.
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
---
|
||||||
|
# Flux Kustomization: SECOND-ORDER CDI CONFIGS — reconciles ./platform-config-cdi.
|
||||||
|
# Serves R-GITOPS-01 (all desired state in git, reconciled by Flux) and R-ENV-02.
|
||||||
|
#
|
||||||
|
# WHY THIS TIER EXISTS — the durable fix for the 2026-09-05 prod CDI DEADLOCK
|
||||||
|
#
|
||||||
|
# `platform-config` applied the `CDI` CR and the CDI `StorageProfile`s in the SAME pass.
|
||||||
|
# kustomize-controller server-side DRY-RUNS EVERY object before applying ANY of them, and
|
||||||
|
# `storageprofiles.cdi.kubevirt.io` does not exist until cdi-operator has reconciled that `CDI` CR.
|
||||||
|
# So the unknown kind aborted the whole tier → the CR was never applied → the CRD was never created →
|
||||||
|
# platform-config could NEVER converge and every tier below it stayed blocked. Not a race: a
|
||||||
|
# deadlock. cdi-operator was Available for 12h on prod with no CR. Invisible on dev (its CDI CRDs
|
||||||
|
# have existed for months), FATAL on any cold cluster. See DECISIONS-LOG 2026-09-05.
|
||||||
|
#
|
||||||
|
# `bootstrap/standup.sh crds` pre-applies the `CDI` CR to break it by hand. That is a WORKAROUND and
|
||||||
|
# stays as belt-and-braces for clusters already wedged; THIS Kustomization is the fix that makes the
|
||||||
|
# graph order itself, so a cold cluster needs no human in the loop.
|
||||||
|
#
|
||||||
|
# THE ORDERING GUARANTEE, precisely: `dependsOn` is REVISION-gated, not merely Ready-gated (verified
|
||||||
|
# against kustomize-controller v1.4.0 `kustomization_controller.go :: checkDependencies`, our
|
||||||
|
# version): sharing a GitRepository with the dependency, this Kustomization cannot reconcile revision
|
||||||
|
# N until `platform-config` has APPLIED revision N — and, because platform-config runs `wait: true`,
|
||||||
|
# not until the `CDI` CR it applied is HEALTHY. cdi-operator creates the remaining CDI CRDs as part of
|
||||||
|
# reaching that state, so by the time this tier's first dry-run runs, `StorageProfile` is a known
|
||||||
|
# kind. One edge, no pre-apply.
|
||||||
|
#
|
||||||
|
# WHY NOT just move the CDI CR earlier, into `platform`? Because `platform` is the tier the CNI gates
|
||||||
|
# and everything else waits on: putting CDI's health on that critical path is the mistake the
|
||||||
|
# oauth2-proxy/headlamp splits were made to undo. The CR is correctly a platform-config CR; it is the
|
||||||
|
# StorageProfiles that were in the wrong pass.
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: platform-config-cdi
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 10m
|
||||||
|
# 2m (short, deliberately). kustomize-controller caches API discovery, so on a COLD cluster the
|
||||||
|
# very first attempt here can still fail `no matches for kind "StorageProfile"` even though the CRD
|
||||||
|
# now exists — a stale RESTMapper, not a missing CRD (the failure mode `standup.sh crds` warns
|
||||||
|
# about). The controller resets the mapper and the retry succeeds, so the only cost of that stale
|
||||||
|
# cache is one retryInterval. Keep it short.
|
||||||
|
retryInterval: 2m
|
||||||
|
# 2m is ample: this tier applies two tiny cluster-scoped CRs. There is no workload to roll out, no
|
||||||
|
# image to pull and no controller to become Available, so a slow apply here means a real error, not
|
||||||
|
# a slow rollout — failing fast surfaces it instead of burning 5m per attempt.
|
||||||
|
timeout: 2m
|
||||||
|
# TRUE, and this tier is the SOLE declarer of both StorageProfiles (checked repo-wide), so prune is
|
||||||
|
# component-scoped: the stale set can only ever be a StorageProfile this file stopped rendering.
|
||||||
|
# It is NOT the platform/cilium case — nothing here declares a shared Namespace, or any object a
|
||||||
|
# second path also declares. See the transition note at the bottom of this file.
|
||||||
|
prune: true
|
||||||
|
# TRUE, but understand exactly what it buys and what it does NOT.
|
||||||
|
#
|
||||||
|
# CANNOT STALL: `StorageProfile`'s status type (CDI v1beta1 StorageProfileStatus: storageClass,
|
||||||
|
# provisioner, claimPropertySets, cloneStrategy, dataImportCronSourceFormat) carries NO
|
||||||
|
# `conditions` and NO `observedGeneration`, so kstatus's generic path reports it Current the moment
|
||||||
|
# it is applied. `wait: true` therefore adds a Ready condition without adding a way to hang — which
|
||||||
|
# is why it is safe to let `apps-vms` gate on it (contrast the headlamp gating stall).
|
||||||
|
# If a resource that CAN be unhealthy is ever added to ./platform-config-cdi, re-derive this:
|
||||||
|
# `apps-vms` waits on it.
|
||||||
|
#
|
||||||
|
# WHAT IT DOES NOT PROVE (honest gap): CDI reads the profile's **status**, not its spec, when it
|
||||||
|
# provisions a claim (status.claimPropertySets / status.cloneStrategy), and CDI's
|
||||||
|
# storageprofile-controller computes that status from our spec override plus inference. So Ready
|
||||||
|
# here means "the spec override is accepted by the API server", not "CDI has folded it into
|
||||||
|
# status". That reconcile is a watch-triggered round-trip of seconds, and the dependency requeue
|
||||||
|
# below it is ~30s, so a DataVolume cannot realistically beat it — but it is a timing argument, not
|
||||||
|
# a guarantee, and no `wait` setting can turn it into one (there is no condition to wait on).
|
||||||
|
wait: true
|
||||||
|
dependsOn:
|
||||||
|
# platform-config applies the `CDI` CR and (wait:true) waits for it to be healthy — which is what
|
||||||
|
# causes cdi-operator to create `storageprofiles.cdi.kubevirt.io`. Revision-gated, so this is an
|
||||||
|
# ordering guarantee and not a hope. Transitively this also chains platform -> cilium ->
|
||||||
|
# flux-system, so `cluster-vars` at revision N is applied before this renders revision N.
|
||||||
|
- name: platform-config
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: flux-system
|
||||||
|
path: ./platform-config-cdi
|
||||||
|
# NO SOPS `decryption` stanza: ../platform/kubevirt/cdi-storageprofiles.yaml is two plain CRs with
|
||||||
|
# no Secret and no *.sops.yaml, so neither overlay needs to patch one in (unlike
|
||||||
|
# oauth2-proxy/headlamp/crafty/velero, which each carry an encrypted Secret). Nothing to add to
|
||||||
|
# clusters/dev or clusters/prod for this tier.
|
||||||
|
#
|
||||||
|
# substituteFrom cluster-vars: the manifest currently contains NO `${TOKEN}`, so this stanza
|
||||||
|
# substitutes nothing today. It is declared anyway for two concrete reasons, not for symmetry:
|
||||||
|
# 1. COVERAGE. conformance/tests/l0-static/flux-postbuild-substitution/check.sh only audits paths
|
||||||
|
# whose Kustomization declares substituteFrom. Omitting it would move
|
||||||
|
# cdi-storageprofiles.yaml OUT of the audited set — a blind spot where a future `${TOKEN}`
|
||||||
|
# could ship as a literal string, precisely the class of bug that check exists to stop.
|
||||||
|
# 2. FAIL-CLOSED. kustomize-controller runs --feature-gates=StrictPostBuildSubstitutions=true, so
|
||||||
|
# with this stanza an undefined token FAILS this Kustomization loudly instead of rendering
|
||||||
|
# empty. Without the stanza there is no substitution at all and the literal ships silently.
|
||||||
|
postBuild:
|
||||||
|
substituteFrom:
|
||||||
|
- kind: ConfigMap
|
||||||
|
name: cluster-vars
|
||||||
|
# ── FIRST RECONCILE AFTER MERGE: a TRANSIENT DELETE-THEN-RECREATE. READ BEFORE MERGING. ──────────
|
||||||
|
# Moving cdi-storageprofiles.yaml out of ./platform-config drops both StorageProfiles from THAT
|
||||||
|
# Kustomization's inventory, and platform-config has `prune: true` — so its next apply DELETES
|
||||||
|
# StorageProfile/longhorn and StorageProfile/longhorn-vm, and this Kustomization recreates them on
|
||||||
|
# the following reconcile (revision-gated: strictly after platform-config's apply, so there is no
|
||||||
|
# dual-ownership fight — the objects are pruned before they are re-applied under this tier's
|
||||||
|
# `kustomize.toolkit.fluxcd.io/name=platform-config-cdi` label).
|
||||||
|
#
|
||||||
|
# Why that is acceptable here — and it is NOT the platform/cilium case:
|
||||||
|
# * Neither object is the sole (or any) declarer of a shared object. No Namespace, no ConfigMap,
|
||||||
|
# nothing else in the repo declares a StorageProfile. The stale set is exactly two
|
||||||
|
# cluster-scoped CDI CRs.
|
||||||
|
# * The objects do not actually go missing: CDI's storageprofile-controller creates a
|
||||||
|
# StorageProfile per StorageClass and recreates a deleted one, with an EMPTY `.spec` and
|
||||||
|
# inferred `.status`. So the transient is "our .spec overrides are absent", not "the object is
|
||||||
|
# absent".
|
||||||
|
# * A StorageProfile is read at DataVolume/PVC PROVISIONING and CLONE time only. Existing PVCs,
|
||||||
|
# existing DataVolumes and running VMs do not consult it again, so nothing already provisioned
|
||||||
|
# is disturbed and no data path is touched.
|
||||||
|
# * The only exposure is a DataVolume/clone STARTED inside the window: it would clone by `copy`
|
||||||
|
# instead of `snapshot`, and on `longhorn-vm` be stamped RWO/Filesystem instead of RWX/Block —
|
||||||
|
# permanently non-live-migratable for that disk. Flux cannot cause that, because `apps-vms` now
|
||||||
|
# dependsOn THIS tier (clusters/base/apps-vms.yaml), so no Flux-managed DataVolume can be
|
||||||
|
# created while the overrides are absent. A human running `virtctl`/`kubectl` in that minute
|
||||||
|
# could.
|
||||||
|
#
|
||||||
|
# PRECONDITION THE OPERATOR MUST CHECK, because it is what bounds the window: the recreate cannot
|
||||||
|
# happen until `platform-config` is Ready, and platform-config runs `wait: true` over the whole
|
||||||
|
# config tier (ClusterIssuers, wildcard Certificate, Gateway, Keycloak, CNPG, KubeVirt, CDI…). On a
|
||||||
|
# healthy cluster that is seconds and the window is ~30-60s (kustomize-controller's
|
||||||
|
# --requeue-dependency default is 30s). If platform-config is NOT Ready when this merges, the
|
||||||
|
# StorageProfiles are pruned and stay at CDI defaults until it recovers. So:
|
||||||
|
# `flux get kustomization platform-config` must be Ready BEFORE merging.
|
||||||
|
# If it is not, do the two-step the cilium move used instead: merge with `platform-config.prune`
|
||||||
|
# temporarily set to `false` (the objects are then dropped from its inventory but NOT deleted, and
|
||||||
|
# this tier ADOPTS them with zero window), verify the ownership label transferred, then restore
|
||||||
|
# `prune: true` in a second commit.
|
||||||
@@ -11,8 +11,53 @@ spec:
|
|||||||
interval: 10m
|
interval: 10m
|
||||||
retryInterval: 2m
|
retryInterval: 2m
|
||||||
timeout: 5m
|
timeout: 5m
|
||||||
prune: true # drift correction: delete resources removed from git (R-GITOPS-03)
|
# Cilium was moved OUT of this tier into its own Kustomization (clusters/base/cilium.yaml)
|
||||||
|
# on 2026-09-01. `prune: true` is restored here now that the ownership transfer is VERIFIED
|
||||||
|
# live: Namespace/kube-system, the Cilium HelmRelease and the HelmRepository all carry
|
||||||
|
# `kustomize.toolkit.fluxcd.io/name=cilium`, this Kustomization's inventory no longer lists
|
||||||
|
# any of the three, and the `cilium` Kustomization's inventory holds exactly those three.
|
||||||
|
#
|
||||||
|
# LESSON WORTH KEEPING: `platform/cilium/namespace.yaml` was the ONLY declaration of
|
||||||
|
# Namespace/kube-system under ./platform. Moving that path out of a prune:true Kustomization
|
||||||
|
# made the stale set `delete namespace kube-system` — coredns, metrics-server, local-path AND
|
||||||
|
# Cilium. Before moving ANY platform/* component in future, check whether it is the sole
|
||||||
|
# declarer of a shared Namespace; that turns a component-scoped prune into a cluster-scoped one.
|
||||||
|
prune: true
|
||||||
wait: true # block `apps` until platform is healthy
|
wait: true # block `apps` until platform is healthy
|
||||||
|
# ORDERING FIX (2026-09-01 incident: a substitution race that failed OPEN broke Cilium).
|
||||||
|
#
|
||||||
|
# `cluster-vars` — the ConfigMap every substituting Kustomization reads — is APPLIED by the root
|
||||||
|
# `flux-system` Kustomization (clusters/<env>/sync.yaml), not by this one. Without an edge here,
|
||||||
|
# `platform` could render a manifest from revision N against the revision N-1 `cluster-vars`, so a
|
||||||
|
# NEWLY ADDED knob was undefined and (non-strict substitution) silently became the EMPTY STRING.
|
||||||
|
# That is how `k8sServiceHost: ""` reached the Cilium HelmRelease.
|
||||||
|
#
|
||||||
|
# `dependsOn` is REVISION-gated, not merely Ready-gated (verified against
|
||||||
|
# kustomize-controller v1.4.0 `kustomization_controller.go :: checkDependencies`, our version):
|
||||||
|
# when the dependency shares a GitRepository with the dependent, the dependent cannot reconcile
|
||||||
|
# revision N until the dependency has APPLIED revision N. Applying revision N is exactly what
|
||||||
|
# writes a newly-added `cluster-vars` knob — so this one edge closes the race.
|
||||||
|
#
|
||||||
|
# `platform` is the ONLY Kustomization in the graph with no `dependsOn`; every other substituting
|
||||||
|
# consumer (platform-config, apps-wave-0/1/2, apps-vms, oauth2-proxy, headlamp, homepage, crafty,
|
||||||
|
# velero) chains transitively through it. One edge therefore protects the whole graph.
|
||||||
|
#
|
||||||
|
# TRADE-OFF — READ THIS BEFORE YOU CURSE IT: the root is now a GATE. A malformed manifest
|
||||||
|
# ANYWHERE under the root's path (clusters/<env>, which includes clusters/base) fails the root
|
||||||
|
# Kustomization and therefore stops `platform` and EVERYTHING downstream, where before this edge
|
||||||
|
# the rest of the graph would have kept reconciling. That is deliberate and fail-closed: a broken
|
||||||
|
# entrypoint should stop the line, not let consumers render against a half-applied variable set.
|
||||||
|
# No deadlock risk — the root runs `wait: false`, so it reaches Ready on APPLY success and never
|
||||||
|
# health-gates its own children (see clusters/dev/sync.yaml).
|
||||||
|
dependsOn:
|
||||||
|
- name: flux-system # the ROOT Kustomization (sync.yaml) that applies cluster-vars
|
||||||
|
# THE CNI GATES THE TIER. Cilium was split out of ./platform into its own Kustomization
|
||||||
|
# (clusters/base/cilium.yaml, wait:true) so its health has its own Ready condition and its own
|
||||||
|
# suspend valve. This edge preserves the ordering that co-tenancy used to provide implicitly:
|
||||||
|
# nothing in `platform` — and therefore nothing anywhere downstream — reconciles until the CNI
|
||||||
|
# is healthy. It is also revision-gated, which is what makes the prune-safety argument above
|
||||||
|
# deterministic rather than a race.
|
||||||
|
- name: cilium
|
||||||
sourceRef:
|
sourceRef:
|
||||||
kind: GitRepository
|
kind: GitRepository
|
||||||
name: flux-system
|
name: flux-system
|
||||||
|
|||||||
@@ -106,8 +106,11 @@ patches:
|
|||||||
secretRef:
|
secretRef:
|
||||||
name: sops-age
|
name: sops-age
|
||||||
# velero (backup controller) has its own post-platform Flux Kustomization (clusters/base/backup.yaml).
|
# velero (backup controller) has its own post-platform Flux Kustomization (clusters/base/backup.yaml).
|
||||||
# It carries velero-cloud.sops.yaml (the scoped MinIO `cloud` credential), so it needs its own SOPS
|
# It carries BOTH halves of the per-env `cloud` INI credential — velero-cloud.sops.yaml (dev's scoped
|
||||||
# decryption stanza (same as headlamp/oauth2-proxy/crafty). The `sops-age` Secret is out-of-band (name only).
|
# MinIO key) and velero-cloud-prod.sops.yaml (prod's R2 token) — so it needs its own SOPS decryption
|
||||||
|
# stanza (same as headlamp/oauth2-proxy/crafty). Which one the Deployment mounts is chosen by
|
||||||
|
# BACKUP_CREDENTIALS_SUFFIX in the cluster-vars patch below, not here. The `sops-age` Secret is
|
||||||
|
# out-of-band (name only).
|
||||||
- patch: |-
|
- patch: |-
|
||||||
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
@@ -148,6 +151,33 @@ patches:
|
|||||||
- op: replace
|
- op: replace
|
||||||
path: /data/ACME_CLUSTER_ISSUER
|
path: /data/ACME_CLUSTER_ISSUER
|
||||||
value: "letsencrypt-prod"
|
value: "letsencrypt-prod"
|
||||||
|
# --- DNS identity (R-DNS-01/02/03) -----------------------------------------------------
|
||||||
|
# DEV STAYS ON THE APEX. Restated explicitly even though it equals the base default, exactly
|
||||||
|
# like the LAN block and LONGHORN_DATA_PATH below: this is what the LIVE cluster is running.
|
||||||
|
# Every one of dev's public DNS records, its `*.home.keithjackson.dev` wildcard cert, its
|
||||||
|
# Keycloak issuer, every OIDC client redirect URI, kubectl SSO, and gitea.home.keithjackson.dev
|
||||||
|
# (which IS this repo's GitOps source) are all on this exact suffix RIGHT NOW. Prod moved to
|
||||||
|
# prod.home.keithjackson.dev instead (2026-09-04) — that alone ended the collision, so dev did
|
||||||
|
# not have to move. Relocating dev to dev.home.keithjackson.dev is phase 2 and is a migration,
|
||||||
|
# not a variable change: infra/PROD-PROVISIONING.md §10.
|
||||||
|
- op: replace
|
||||||
|
path: /data/BASE_DOMAIN
|
||||||
|
value: "home.keithjackson.dev"
|
||||||
|
# ExternalDNS scope. MUST stay the zone apex: dev serves the home.keithjackson.dev APEX itself,
|
||||||
|
# and the Cloudflare provider selects zones with this value (a strict-subdomain filter dropped
|
||||||
|
# the parent zone — DECISIONS-LOG 2026-08). Same value the retired DNS_ZONE knob held.
|
||||||
|
- op: replace
|
||||||
|
path: /data/EXTERNALDNS_DOMAIN_FILTER
|
||||||
|
value: "keithjackson.dev"
|
||||||
|
# DO NOT CHANGE THIS VALUE. Dev's LIVE public records are registered in Cloudflare's TXT
|
||||||
|
# registry under owner `default` (the external-dns chart default, which is what both clusters
|
||||||
|
# silently shared until the 2026-09-04 incident). Dev's external-dns identifies its own records
|
||||||
|
# BY this string: any other value and it stops recognising every record it manages, then
|
||||||
|
# re-creates duplicates beside the orphaned originals. Prod is `prod` — distinct, which is the
|
||||||
|
# whole point. The base default is a non-colliding sentinel, so this line is load-bearing.
|
||||||
|
- op: replace
|
||||||
|
path: /data/EXTERNALDNS_TXT_OWNER_ID
|
||||||
|
value: "default"
|
||||||
# --- dev LAN addressing (192.168.1.0/24 on the Proxmox LAN) -----------------------------
|
# --- dev LAN addressing (192.168.1.0/24 on the Proxmox LAN) -----------------------------
|
||||||
# EVERY knob in cluster-vars' "LAN addressing" block is restated here EXPLICITLY, even where it
|
# EVERY knob in cluster-vars' "LAN addressing" block is restated here EXPLICITLY, even where it
|
||||||
# equals the base default. These values are what the LIVE dev cluster is running right now, so
|
# equals the base default. These values are what the LIVE dev cluster is running right now, so
|
||||||
@@ -160,11 +190,25 @@ patches:
|
|||||||
- op: replace
|
- op: replace
|
||||||
path: /data/GATEWAY_VIP
|
path: /data/GATEWAY_VIP
|
||||||
value: "192.168.1.53"
|
value: "192.168.1.53"
|
||||||
# k3s API endpoint Cilium's kube-proxy replacement dials — the primary control-plane node
|
# k3s API endpoint Cilium's kube-proxy replacement dials — DEV'S CONTROL-PLANE VIP,
|
||||||
# (k3s-1). [parity] equals the Ansible inventory's k3s_api_host for dev.
|
# the address kube-vip floats, NOT the primary node .50.
|
||||||
|
# [parity] equals the Ansible inventory's k3s_apiserver_endpoint for dev
|
||||||
|
# (= kube_vip_address, group_vars/all.yml). See clusters/base/cluster-vars.yaml for why.
|
||||||
|
#
|
||||||
|
# *** THE ONE EXCEPTION TO "NEVER LET THIS BLOCK DRIFT FROM THE LIVE CLUSTER" ***
|
||||||
|
# Every other value in this block is what dev is running right now. This one is not:
|
||||||
|
# the live dev cilium-agents dial 192.168.1.50 (k3s-1). Changing it here is a
|
||||||
|
# DELIBERATE, OPERATOR-APPROVED move (2026-09-06) and reconciling it ROLLS THE CILIUM
|
||||||
|
# AGENT DAEMONSET on dev — one node at a time (updateStrategy.maxUnavailable: 1, kept
|
||||||
|
# in parity for exactly this reason). Do not push this to dev-live casually, and not
|
||||||
|
# while dev's DNS migration is mid-flight; the L2 datapath survives an agent restart
|
||||||
|
# but a wrong value here is the 2026-09-01 incident. .49 is in dev's serving-cert SANs
|
||||||
|
# already (k3s_tls_sans) and dev's own kubeconfig validates against it via
|
||||||
|
# tls-server-name, so TLS is not the risk — the risk is reachability, and .49 is on the
|
||||||
|
# same LAN as the nodes and ARP-announced by kube-vip on every server.
|
||||||
- op: replace
|
- op: replace
|
||||||
path: /data/K3S_API_HOST
|
path: /data/K3S_API_HOST
|
||||||
value: "192.168.1.50"
|
value: "192.168.1.49"
|
||||||
# Cilium LB IP pool = the tofu reservation cilium_l2_vip_range .53-.60, outside the node IPs
|
# Cilium LB IP pool = the tofu reservation cilium_l2_vip_range .53-.60, outside the node IPs
|
||||||
# (.50/.51/.52) and outside the DHCP scope. .53 Gateway, .54 unifi, .55 crafty; .56-.60 free.
|
# (.50/.51/.52) and outside the DHCP scope. .53 Gateway, .54 unifi, .55 crafty; .56-.60 free.
|
||||||
- op: replace
|
- op: replace
|
||||||
@@ -183,13 +227,70 @@ patches:
|
|||||||
value: "192.168.1.55"
|
value: "192.168.1.55"
|
||||||
# Off-cluster backup target: the on-Proxmox MinIO (plain HTTP, path-style) that every CNPG
|
# Off-cluster backup target: the on-Proxmox MinIO (plain HTTP, path-style) that every CNPG
|
||||||
# barmanObjectStore and Velero's backupStorageLocation write to.
|
# barmanObjectStore and Velero's backupStorageLocation write to.
|
||||||
|
#
|
||||||
|
# DEV IS DELIBERATELY STAYING ON THIS MinIO while prod goes to Cloudflare R2 (2026-09-07).
|
||||||
|
# Prod could not use it at all — it cannot even route to it — but dev's move is a separate,
|
||||||
|
# operator-gated decision, because R2 would start dev on an EMPTY prefix: Velero would write
|
||||||
|
# a fresh kopia repository, CNPG would take new base backups, and the 30-day Barman window
|
||||||
|
# would rebuild from zero. Dev's DR restore is the one PROVEN recovery path in this repo
|
||||||
|
# (R-STATE-03); resetting its PITR window is not a side effect to take while fixing prod.
|
||||||
- op: replace
|
- op: replace
|
||||||
path: /data/S3_BACKUP_ENDPOINT
|
path: /data/S3_BACKUP_ENDPOINT
|
||||||
value: "http://192.168.1.9:9000"
|
value: "http://192.168.1.9:9000"
|
||||||
|
# DO NOT CHANGE THESE FOUR. They are dev's LIVE object layout, restated verbatim for the same
|
||||||
|
# reason as LONGHORN_DATA_PATH and EXTERNALDNS_TXT_OWNER_ID below/above: they equal the base
|
||||||
|
# defaults today, and a silent drift in base would ORPHAN dev's existing backups — the ones
|
||||||
|
# its proven restore depends on — without any manifest looking wrong.
|
||||||
|
#
|
||||||
|
# `us-east-1` is what dev's Velero has signed every request with since bootstrap; MinIO
|
||||||
|
# ignores the value, but changing it is a needless risk against a live, working backup path.
|
||||||
|
# An EMPTY VELERO_BACKUP_PREFIX means the bucket root, which is where dev's backups already
|
||||||
|
# are — and the Velero chart omits `objectStorage.prefix` entirely when it is empty (verified
|
||||||
|
# against chart 12.1.0), so dev's live BackupStorageLocation is byte-unchanged by this.
|
||||||
|
- op: replace
|
||||||
|
path: /data/S3_BACKUP_REGION
|
||||||
|
value: "us-east-1"
|
||||||
|
- op: replace
|
||||||
|
path: /data/VELERO_BACKUP_BUCKET
|
||||||
|
value: "velero"
|
||||||
|
- op: replace
|
||||||
|
path: /data/VELERO_BACKUP_PREFIX
|
||||||
|
value: ""
|
||||||
|
- op: replace
|
||||||
|
path: /data/CNPG_BACKUP_PATH
|
||||||
|
value: "s3://cnpg"
|
||||||
|
# WHICH CREDENTIAL SIGNS THOSE REQUESTS. Empty suffix = the UNSUFFIXED Secrets
|
||||||
|
# (velero-cloud-credentials, <db>-backup-credentials) — the scoped MinIO keys dev's Velero and
|
||||||
|
# its three CNPG Clusters are running RIGHT NOW, byte-identical to what is on the cluster. Prod
|
||||||
|
# renders "-prod" and reads the Cloudflare R2 token from the parallel `-prod` Secrets instead.
|
||||||
|
#
|
||||||
|
# RESTATED EXPLICITLY even though it equals the base default, for the same reason as
|
||||||
|
# EXTERNALDNS_TXT_OWNER_ID and the four backup-layout knobs above: the four Secrets are SHARED
|
||||||
|
# base manifests, so before this split prod's R2 token landed in the objects DEV reads. Dev
|
||||||
|
# signing MinIO with an R2 key id is 403 on every barman-cloud-wal-archive, and archive_mode=on
|
||||||
|
# turns that into a filling pgdata PVC — the disk-full clock, on the one cluster whose DR
|
||||||
|
# restore is PROVEN (R-STATE-03). This line is what keeps dev on its own credential.
|
||||||
|
#
|
||||||
|
# DEV IS NOT BEING BACKED UP GOING FORWARD and is slated for decommission (operator,
|
||||||
|
# 2026-09-07), so this is not protecting a future backup — it is protecting the EXISTING
|
||||||
|
# backups and the WAL chain until dev is retired. Do not "simplify" it away while dev runs.
|
||||||
|
- op: replace
|
||||||
|
path: /data/BACKUP_CREDENTIALS_SUFFIX
|
||||||
|
value: ""
|
||||||
# dev mirrors prod's 3-node topology, so the replicated tier keeps 3 replicas.
|
# dev mirrors prod's 3-node topology, so the replicated tier keeps 3 replicas.
|
||||||
- op: replace
|
- op: replace
|
||||||
path: /data/LONGHORN_DEFAULT_REPLICA_COUNT
|
path: /data/LONGHORN_DEFAULT_REPLICA_COUNT
|
||||||
value: "3"
|
value: "3"
|
||||||
|
# DO NOT CHANGE THIS VALUE. The LIVE dev cluster has 18 Longhorn volumes whose replicas are
|
||||||
|
# on disk under /var/lib/longhorn — the dedicated /dev/vdb ext4 disk roles/node_baseline
|
||||||
|
# mounts there. Longhorn addresses a replica by dataPath; pointing defaultDataPath anywhere
|
||||||
|
# else would orphan every one of them (they are not recoverable from the old path by
|
||||||
|
# rescheduling). Restated explicitly even though it equals the base default, exactly like the
|
||||||
|
# LAN block above: this is what the live cluster is running. Prod uses /data/longhorn because
|
||||||
|
# its data partition also carries k3s's data dir — see clusters/prod and PROD-PROVISIONING §2.
|
||||||
|
- op: replace
|
||||||
|
path: /data/LONGHORN_DATA_PATH
|
||||||
|
value: "/var/lib/longhorn"
|
||||||
# smaller retention / volumes to keep the dev workstation footprint light.
|
# smaller retention / volumes to keep the dev workstation footprint light.
|
||||||
- op: replace
|
- op: replace
|
||||||
path: /data/MONITORING_PROMETHEUS_RETENTION
|
path: /data/MONITORING_PROMETHEUS_RETENTION
|
||||||
|
|||||||
@@ -5,11 +5,207 @@
|
|||||||
# When Phase 7 is opened, `flux bootstrap ... --path=clusters/prod` points the prod cluster's Flux
|
# When Phase 7 is opened, `flux bootstrap ... --path=clusters/prod` points the prod cluster's Flux
|
||||||
# here. Like dev, this overlay adds NO resources — it ONLY patches cluster-vars with prod-scoped
|
# here. Like dev, this overlay adds NO resources — it ONLY patches cluster-vars with prod-scoped
|
||||||
# values (R-ENV-02). Promotion to prod is a change to THESE variables, never a base edit.
|
# values (R-ENV-02). Promotion to prod is a change to THESE variables, never a base edit.
|
||||||
|
#
|
||||||
|
# *** PHASE-7 BLOCKER — READ BEFORE BOOTSTRAPPING PROD ***
|
||||||
|
# `clusters/base/platform.yaml` now carries `dependsOn: [flux-system]` (the 2026-09-01 substitution-
|
||||||
|
# race fix). That names the ROOT Kustomization, which clusters/dev supplies as `dev/sync.yaml`
|
||||||
|
# (path ./clusters/dev). THIS overlay has no sync.yaml yet, so as written a prod bootstrap would
|
||||||
|
# leave `platform` stuck on "dependency 'flux-system/flux-system' is not ready" FOREVER and nothing
|
||||||
|
# downstream would reconcile. Phase 7 MUST add `clusters/prod/sync.yaml` — a copy of dev/sync.yaml
|
||||||
|
# with `path: ./clusters/prod` — as part of opening this overlay. (Adding it now is deliberately NOT
|
||||||
|
# done here: prod is design-only and its root object is the prod bring-up owner's call.)
|
||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||||
kind: Kustomization
|
kind: Kustomization
|
||||||
resources:
|
resources:
|
||||||
- ../base
|
- ../base
|
||||||
|
- sync.yaml # self-managing root Kustomization -> ./clusters/prod (REQUIRED: see below)
|
||||||
patches:
|
patches:
|
||||||
|
# --- SOPS decryption (operator decision 2026-09-01: prod uses the SAME age key as dev) ---
|
||||||
|
# Mirrors clusters/dev exactly, one stanza per Kustomization that carries encrypted Secrets.
|
||||||
|
# The `sops-age` Secret is created OUT OF BAND on the prod cluster
|
||||||
|
# (bootstrap/sops/install-age-key.sh) — referenced by NAME only, never inlined.
|
||||||
|
# Add a stanza here whenever `render.py --emit-waves` introduces a new deepest wave.
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: platform
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: apps-wave-0
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: apps-wave-1
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: apps-wave-2
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: oauth2-proxy
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: headlamp
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: crafty
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: velero
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
decryption:
|
||||||
|
provider: sops
|
||||||
|
secretRef:
|
||||||
|
name: sops-age
|
||||||
|
# --- GitOps source (operator decision 2026-09-01: prod tracks the SAME BRANCH as dev) ---
|
||||||
|
#
|
||||||
|
# ############################################################################################
|
||||||
|
# ### ###
|
||||||
|
# ### *** T E M P O R A R Y B O O T S T R A P S O U R C E — NOT PERMANENT *** ###
|
||||||
|
# ### ###
|
||||||
|
# ### The url below is a Gitea container running ON THE OPERATOR'S WORKSTATION, outside ###
|
||||||
|
# ### any cluster. It exists ONLY to get prod's first reconcile off the ground. It MUST ###
|
||||||
|
# ### be cut over to the in-cluster URL once Gitea runs on prod. If you are reading this ###
|
||||||
|
# ### and prod has been serving traffic for months, THE CUTOVER WAS NEVER DONE — that is ###
|
||||||
|
# ### a live single point of failure, not a settled design. See "CUTOVER" below. ###
|
||||||
|
# ### ###
|
||||||
|
# ############################################################################################
|
||||||
|
#
|
||||||
|
# WHY THE IN-CLUSTER URL CANNOT BE USED YET (blocker found 2026-09-01, still true; the
|
||||||
|
# unusable value was `https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl.git`).
|
||||||
|
# Two independent reasons, both consequences of the split-horizon design:
|
||||||
|
# 1. SELF-HOSTING CIRCULARITY. Gitea runs ON THE DEV CLUSTER. Prod syncing from it means prod's
|
||||||
|
# GitOps source lives inside the very cluster slated for decommission. Gitea must move to
|
||||||
|
# prod (or off-cluster) BEFORE dev is retired, or prod loses its source of truth.
|
||||||
|
# 2. SPLIT-HORIZON DNS SENDS IT TO THE WRONG PLACE. platform/coredns/coredns-custom.yaml
|
||||||
|
# answers the ENTIRE home.keithjackson.dev zone with ${GATEWAY_VIP}. On prod that is
|
||||||
|
# 192.168.2.20 — PROD's own gateway, where no Gitea is listening. Prod's Flux would resolve
|
||||||
|
# gitea.home.keithjackson.dev to itself and never reach a repository.
|
||||||
|
#
|
||||||
|
# WHAT THE TEMPORARY SOURCE IS (stood up 2026-09-04 on explicit operator direction):
|
||||||
|
# bootstrap/gitea/bootstrap-gitea.sh with HOST_NAT_IP=192.168.2.132 — a podman container on the
|
||||||
|
# operator's workstation, Gitea 1.22.6, listening on 192.168.2.132:3000. All three prod nodes
|
||||||
|
# reach it DIRECTLY on the LAN: no libvirt NAT hop (that network is retired) and no firewall
|
||||||
|
# change was needed. This is the same "local bootstrap source" pattern dev itself used before
|
||||||
|
# its Phase-6 cutover (docs/05 "Source-of-truth").
|
||||||
|
#
|
||||||
|
# PLAIN HTTP, AND WHY THAT IS ACCEPTED HERE: the bootstrap Gitea has no TLS, so there is no cert
|
||||||
|
# for Flux to trust and no caCertSecretRef to wire. Traffic is LAN-local between prod nodes and
|
||||||
|
# the workstation, the repo carries NO plaintext secrets (everything sensitive is SOPS-encrypted,
|
||||||
|
# .sops.yaml), and the window is the bring-up only. Do NOT extend this posture past cutover.
|
||||||
|
#
|
||||||
|
# NO secretRef, DELIBERATELY: the repo is PUBLIC on the bootstrap Gitea, so anonymous clone
|
||||||
|
# works and Flux needs no git credentials. VERIFIED 2026-09-04 by unauthenticated request:
|
||||||
|
# curl http://192.168.2.132:3000/api/v1/repos/Home-Network/home-network-k3s-impl -> "private":false
|
||||||
|
# git ls-remote http://192.168.2.132:3000/Home-Network/home-network-k3s-impl.git -> refs/heads/main
|
||||||
|
# If the GitRepository ever reports an auth failure that looks like a network problem, RE-CHECK
|
||||||
|
# THAT FIRST: a private repo here fails exactly that way.
|
||||||
|
#
|
||||||
|
# *** CUTOVER — the exit condition, mirroring dev's Phase-6 cutover (docs/05) ***
|
||||||
|
# WHEN: Gitea runs on prod (reconciled in-cluster) and answers gitea.home.keithjackson.dev
|
||||||
|
# through prod's own Gateway VIP with a publicly-trusted cert.
|
||||||
|
# DO: 1. push/mirror this repo into the prod in-cluster Gitea;
|
||||||
|
# 2. change the url below to
|
||||||
|
# https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl.git
|
||||||
|
# and DELETE this whole TEMPORARY block (dev's equivalent stanza, ~15 lines above in
|
||||||
|
# clusters/dev/kustomization.yaml, is what "done" looks like);
|
||||||
|
# 3. confirm `flux get sources git -n flux-system` reconciles the new revision;
|
||||||
|
# 4. only THEN stop the workstation container. Keep it stopped-not-deleted as recovery,
|
||||||
|
# exactly as dev retained its bootstrap Gitea.
|
||||||
|
# UNTIL THEN: prod's reconciliation depends on a container on a workstation that may be
|
||||||
|
# powered off. Flux keeps the last-reconciled state running, but NO new revision lands while
|
||||||
|
# it is down. Tracked as an open blocker in infra/PROD-PROVISIONING.md §7.
|
||||||
|
#
|
||||||
|
# The BRANCH below is settled and correct: prod tracks `main`, the same branch as dev.
|
||||||
|
# CONSEQUENCE, recorded deliberately: once prod is live, EVERY MERGE TO main RECONCILES BOTH
|
||||||
|
# CLUSTERS. There is no promotion step and no soak window — dev and prod move together.
|
||||||
|
# NOTE for bring-up: bootstrap-gitea.sh pushes the operator's CURRENT HEAD to the bootstrap
|
||||||
|
# Gitea's `main`, whatever branch that is. Before bootstrapping prod, confirm that remote `main`
|
||||||
|
# is the revision you intend prod to run (`git ls-remote` above, against local `main`).
|
||||||
|
- patch: |-
|
||||||
|
apiVersion: source.toolkit.fluxcd.io/v1
|
||||||
|
kind: GitRepository
|
||||||
|
metadata:
|
||||||
|
name: flux-system
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
# CUTOVER DONE 2026-09-06 (operator direction: "move the repo to prod"). Prod now reconciles
|
||||||
|
# from PROD'S OWN in-cluster Gitea. The workstation bootstrap container
|
||||||
|
# (http://192.168.2.132:3000) is retired as prod's source — it was a podman container on the
|
||||||
|
# operator's workstation holding a DHCP lease on WIFI, i.e. production's source of truth was a
|
||||||
|
# laptop. It is kept only as a recovery path (bootstrap/gitea/bootstrap-gitea.sh).
|
||||||
|
#
|
||||||
|
# THIS LINE IS SELF-REFERENTIAL AND THAT IS THE WHOLE DIFFICULTY. The root Kustomization
|
||||||
|
# renders ./clusters/prod, which contains this patch — so a live `kubectl patch` of
|
||||||
|
# GitRepository.spec.url is REVERTED on the next reconcile unless the authored value moves
|
||||||
|
# too. The cutover therefore had to be: (1) push the repo to prod's Gitea, (2) fast-forward
|
||||||
|
# its `main` to the exact commit prod was already running so the source swap changed nothing
|
||||||
|
# else, (3) live-patch the url to open a window, (4) land THIS commit through that window so
|
||||||
|
# the authored value agrees. Dev hit the identical trap on 2026-09-05
|
||||||
|
# (infra/PROD-PROVISIONING.md §10a "D0/D1 as executed").
|
||||||
|
#
|
||||||
|
# Verified before the switch: a throwaway pod INSIDE prod resolved this host through
|
||||||
|
# in-cluster CoreDNS to the Gateway VIP and cloned over the public LE cert with no CA
|
||||||
|
# supplied. The workstation being able to reach it proves nothing about pod egress.
|
||||||
|
url: https://gitea.prod.home.keithjackson.dev/Home-Network/home-network-k3s-impl.git
|
||||||
|
ref:
|
||||||
|
branch: main
|
||||||
- target:
|
- target:
|
||||||
kind: ConfigMap
|
kind: ConfigMap
|
||||||
name: cluster-vars
|
name: cluster-vars
|
||||||
@@ -21,9 +217,80 @@ patches:
|
|||||||
- op: replace
|
- op: replace
|
||||||
path: /data/ACME_CLUSTER_ISSUER
|
path: /data/ACME_CLUSTER_ISSUER
|
||||||
value: "letsencrypt-prod"
|
value: "letsencrypt-prod"
|
||||||
|
# ==========================================================================================
|
||||||
|
# PROD DNS IDENTITY (R-DNS-01/02/03) — the fix for the 2026-09-04 incident.
|
||||||
|
# ==========================================================================================
|
||||||
|
# PROD SERVES ITS OWN SUBDOMAIN. Until 2026-09-04 every hostname was HARDCODED to
|
||||||
|
# home.keithjackson.dev in the base manifests, so prod was authored to serve the EXACT SAME
|
||||||
|
# public names as the live dev cluster out of the SAME Cloudflare zone. Two consequences, one
|
||||||
|
# of which already happened:
|
||||||
|
# 1. HAPPENED: prod's external-dns (policy: sync, owner id `default` — the same implicit
|
||||||
|
# default dev had) concluded it owned dev's records and DELETED EVERY ONE of them.
|
||||||
|
# 2. QUEUED BEHIND IT: platform-config would have requested a `*.home.keithjackson.dev`
|
||||||
|
# wildcard from letsencrypt-prod for a cert dev already holds, and both clusters would
|
||||||
|
# have raced each other's `_acme-challenge` TXT records on that one zone.
|
||||||
|
# Moving prod to its own suffix dissolves both: the two clusters can no longer name the same
|
||||||
|
# record. This is what makes ACME_CLUSTER_ISSUER: letsencrypt-prod above safe here.
|
||||||
|
#
|
||||||
|
# dev -> home.keithjackson.dev (UNCHANGED — dev is live; phase 2 moves it)
|
||||||
|
# prod -> prod.home.keithjackson.dev (this knob)
|
||||||
|
#
|
||||||
|
# CONSEQUENCE — prod's hostnames are NOT the apex names. Prod's Gitea is
|
||||||
|
# gitea.prod.home.keithjackson.dev, its Keycloak keycloak.prod.home.keithjackson.dev, and so
|
||||||
|
# on; the wildcard cert becomes `*.prod.home.keithjackson.dev`. The apex CNAMEs that make
|
||||||
|
# `<svc>.home.keithjackson.dev` point at prod are PHASE 2, deliberately not done here — they
|
||||||
|
# would take traffic off the live dev cluster. infra/PROD-PROVISIONING.md §10.
|
||||||
|
#
|
||||||
|
# [parity] MUST equal k3s_base_domain in infra/ansible/inventory/group_vars/k3s_prod/vars.yml.
|
||||||
|
# The kube-apiserver's oidc-issuer-url is an INSTALL-TIME k3s flag, not a manifest, so it is
|
||||||
|
# set Ansible-side from that variable and cannot read this one. If they disagree, kubectl SSO
|
||||||
|
# trusts a different Keycloak than the one prod actually runs.
|
||||||
|
- op: replace
|
||||||
|
path: /data/BASE_DOMAIN
|
||||||
|
value: "prod.home.keithjackson.dev"
|
||||||
|
# SAFETY NET #2 (independent of the owner id below): narrow ExternalDNS's name scope to prod's
|
||||||
|
# OWN subtree, so prod cannot create, update or delete any record outside it no matter what the
|
||||||
|
# TXT registry says. VERIFY ON FIRST RECONCILE — the Cloudflare provider uses this value for
|
||||||
|
# ZONE selection too, and on the version in use in 2026-08 a strict-subdomain filter dropped
|
||||||
|
# the parent zone ("no hosted zone matching record DNS Name"). If prod's external-dns logs no
|
||||||
|
# matching zone, it will create NOTHING — fail-closed, and it can still never delete dev's
|
||||||
|
# records. Fallback in that case: set this to "keithjackson.dev" (dev's value) and rely on
|
||||||
|
# EXTERNALDNS_TXT_OWNER_ID below, which is sufficient on its own.
|
||||||
|
- op: replace
|
||||||
|
path: /data/EXTERNALDNS_DOMAIN_FILTER
|
||||||
|
# MUST be the Cloudflare ZONE, not prod's subdomain. FINDING F-DNS1 (DECISIONS-LOG,
|
||||||
|
# 2026-08-17) proved a subdomain filter DROPS THE PARENT ZONE: ExternalDNS generated the
|
||||||
|
# record then skipped it with "no hosted zone matching record DNS Name", so prod would
|
||||||
|
# publish NOTHING. Isolation from dev does NOT come from this filter — it comes from
|
||||||
|
# (1) prod only requesting *.prod.home.keithjackson.dev hostnames, and
|
||||||
|
# (2) txtOwnerId: prod, so prod's registry never claims dev's `default`-owned records.
|
||||||
|
# That pairing is what the 2026-09-04 incident needed and this filter cannot provide.
|
||||||
|
value: "keithjackson.dev"
|
||||||
|
# SAFETY NET #1 — THE ACTUAL ROOT CAUSE OF THE INCIDENT. Both clusters left this unset and so
|
||||||
|
# both inherited the chart default `default`; prod then treated dev's TXT-registry entries as
|
||||||
|
# its own and, under policy: sync, deleted the records behind them. This value MUST differ from
|
||||||
|
# dev's (`default`, pinned in clusters/dev — dev's live records are registered under it and
|
||||||
|
# cannot be renamed without orphaning them). `prod` is distinct, which is the entire fix.
|
||||||
|
- op: replace
|
||||||
|
path: /data/EXTERNALDNS_TXT_OWNER_ID
|
||||||
|
value: "prod"
|
||||||
- op: replace
|
- op: replace
|
||||||
path: /data/LONGHORN_DEFAULT_REPLICA_COUNT
|
path: /data/LONGHORN_DEFAULT_REPLICA_COUNT
|
||||||
value: "3"
|
value: "3"
|
||||||
|
# Longhorn's on-node replica directory: a SUBDIRECTORY of the claimed data partition, not
|
||||||
|
# the mount root. Prod's ~424 GB partition (LABEL=k3s-data) is mounted at the neutral /data
|
||||||
|
# and serves TWO tenants — /data/longhorn here, and /data/k3s for k3s's entire data dir
|
||||||
|
# (embedded etcd + the containerd image store), which had to move off the 11.8 GiB /var:
|
||||||
|
# measured on dev, containerd alone is 21-25 GB per node, so prod would have filled /var on
|
||||||
|
# its first image pulls and etcd could not have written its WAL. infra/PROD-PROVISIONING.md §2.
|
||||||
|
#
|
||||||
|
# [parity] MUST equal node_baseline_longhorn_data_path in
|
||||||
|
# infra/ansible/inventory/group_vars/k3s_prod/vars.yml — Ansible creates and mounts the
|
||||||
|
# directory, this tells Longhorn to use it. If they disagree, Longhorn writes replicas to a
|
||||||
|
# path on the OS filesystem instead of the data partition and /var fills up.
|
||||||
|
- op: replace
|
||||||
|
path: /data/LONGHORN_DATA_PATH
|
||||||
|
value: "/data/longhorn"
|
||||||
# longer retention / larger volumes on prod NVMe.
|
# longer retention / larger volumes on prod NVMe.
|
||||||
- op: replace
|
- op: replace
|
||||||
path: /data/MONITORING_PROMETHEUS_RETENTION
|
path: /data/MONITORING_PROMETHEUS_RETENTION
|
||||||
@@ -89,14 +356,32 @@ patches:
|
|||||||
- op: replace
|
- op: replace
|
||||||
path: /data/CILIUM_LB_POOL_STOP
|
path: /data/CILIUM_LB_POOL_STOP
|
||||||
value: "192.168.2.30"
|
value: "192.168.2.30"
|
||||||
# --- still UNDECIDED -> loud placeholders (must be real before `flux bootstrap`) ------------
|
|
||||||
# k3s API endpoint Cilium's kube-proxy replacement dials. This is the prod CONTROL-PLANE VIP
|
# k3s API endpoint Cilium's kube-proxy replacement dials. This is the prod CONTROL-PLANE VIP
|
||||||
# (kube-vip), NOT .20 (the ingress VIP) and NOT the single node address .10. It is owned
|
# (kube-vip), NOT .20 (the ingress VIP) and NOT the single node address .10.
|
||||||
# Ansible-side (prod inventory k3s_api_host) and MUST equal that value — [parity] contract in
|
#
|
||||||
# platform/cilium/helmrelease.yaml. Not yet decided.
|
# *** DECIDED 2026-09-06 by the operator: the VIP, as intended. *** (This block previously
|
||||||
|
# read "Not yet decided" while shipping .10 — the founder's own address — and that is what
|
||||||
|
# the LIVE prod cluster is running: all three cilium-agents carry
|
||||||
|
# KUBERNETES_SERVICE_HOST=192.168.2.10 while kube-vip holds .9, verified 2026-09-06. On a
|
||||||
|
# 3-node HA cluster that makes the CNI's control-plane path depend on k3s-prod-1: lose it and
|
||||||
|
# the datapath keeps running off Cilium's cache while no service or policy update lands, with
|
||||||
|
# nothing looking broken. The VIP is a virtual address that reaches the cluster wherever the
|
||||||
|
# control plane is, which is the whole reason kube-vip exists here.)
|
||||||
|
#
|
||||||
|
# It is owned Ansible-side by **k3s_apiserver_endpoint** (group_vars/all.yml, derived from
|
||||||
|
# this environment's kube_vip_address = 192.168.2.9 in group_vars/k3s_prod/vars.yml) and MUST
|
||||||
|
# equal that value — [parity] contract in platform/cilium/helmrelease.yaml. NOT k3s_api_host:
|
||||||
|
# that variable still exists and still holds the FOUNDER's .10, because the cold-bootstrap
|
||||||
|
# `--server` join needs a host that is up before any VIP is floated. Two addresses, two
|
||||||
|
# variables, deliberately.
|
||||||
|
#
|
||||||
|
# .9 sits OUTSIDE the Cilium LB pool (.20-.30) and is not a node address, so kube-vip and
|
||||||
|
# LB-IPAM can never be handed the same IP — asserted by
|
||||||
|
# conformance/tests/l0-static/ha-control-plane-as-code.
|
||||||
- op: replace
|
- op: replace
|
||||||
path: /data/K3S_API_HOST
|
path: /data/K3S_API_HOST
|
||||||
value: "192.168.2.10"
|
value: "192.168.2.9"
|
||||||
|
# --- still UNDECIDED -> loud placeholders (must be real before `flux bootstrap`) ------------
|
||||||
# Pinned L4 VIPs, both carved out of the pool by lbipam.cilium.io/ips. Not yet assigned for
|
# Pinned L4 VIPs, both carved out of the pool by lbipam.cilium.io/ips. Not yet assigned for
|
||||||
# prod. Leaving the dev literals in place would hand a prod LoadBalancer a dev-LAN address, so
|
# prod. Leaving the dev literals in place would hand a prod LoadBalancer a dev-LAN address, so
|
||||||
# they stay placeholders; whatever is chosen MUST fall inside [POOL_START, POOL_STOP] and not
|
# they stay placeholders; whatever is chosen MUST fall inside [POOL_START, POOL_STOP] and not
|
||||||
@@ -107,10 +392,91 @@ patches:
|
|||||||
- op: replace
|
- op: replace
|
||||||
path: /data/CRAFTY_L4_VIP
|
path: /data/CRAFTY_L4_VIP
|
||||||
value: "192.168.2.22"
|
value: "192.168.2.22"
|
||||||
# Off-cluster DR target. Dev's versitygw/MinIO (192.168.1.9:9000) sits on the DEV side of the
|
# ==========================================================================================
|
||||||
# network — a prod backup written there is not off-cluster DR and would mix two environments'
|
# OFF-CLUSTER DR TARGET — Cloudflare R2 (operator, 2026-09-05; bucket + token 2026-09-07).
|
||||||
# PITR streams in one bucket. Operator supplies the prod endpoint (full URL incl. scheme+port).
|
# ==========================================================================================
|
||||||
|
# Dev's versitygw/MinIO (192.168.1.9:9000) sits on the DEV side of the network and is NOT an
|
||||||
|
# option for prod, for two independent reasons:
|
||||||
|
# 1. UNREACHABLE. Prod is on the SOHO network 192.168.2.0/24 — pfSense's WAN — while that
|
||||||
|
# MinIO is inside pfSense's LAN. Measured from a pod INSIDE prod (the workstation proves
|
||||||
|
# nothing about pod egress): `curl http://192.168.1.9:9000/minio/health/live` → connection
|
||||||
|
# timed out after 15s. Reaching it would need a WAN→LAN rule that does not exist.
|
||||||
|
# 2. NOT OFF-CLUSTER DR. It runs on the same Proxmox host as dev's VMs, so it already shares
|
||||||
|
# a physical failure domain with dev. R2 is genuinely off-site, and its free egress means
|
||||||
|
# restore REHEARSALS cost nothing — R-STATE-03 has no financial disincentive to stay proven.
|
||||||
|
#
|
||||||
|
# THIS UNBLOCKS AN ACTIVE INCIDENT, not just a roadmap item. All three prod CNPG Clusters ship
|
||||||
|
# `archive_mode=on` with `endpointURL: TBD-PROD-S3-BACKUP-ENDPOINT`, so barman-cloud-wal-archive
|
||||||
|
# exits 4 on every segment and Postgres — correctly — REFUSES TO RECYCLE WAL. Verified live
|
||||||
|
# 2026-09-07: keycloak-db / nextcloud-db / semaphore-db all report
|
||||||
|
# ContinuousArchiving=False/ContinuousArchivingFailing, and keycloak-db's 5 GiB PVC is at
|
||||||
|
# 579 MiB used / 4360 MiB free and climbing monotonically. A disk-full PANIC there takes the
|
||||||
|
# cluster's SSO database down, and with it kubectl OIDC, Grafana, Headlamp, Gitea and
|
||||||
|
# forward-auth. Fixing the endpoint lets Postgres drain the whole backlog.
|
||||||
|
#
|
||||||
|
# ENDPOINT IS ACCOUNT-SCOPED, so it stays a prod-overlay value even though it is not a LAN
|
||||||
|
# address: this is the operator's Cloudflare account id, confirmed from inside a prod pod
|
||||||
|
# (`head-bucket` on k3s-prod-backup → BucketRegion WNAM; PUT and DELETE both succeed).
|
||||||
|
# It is NOT put in base, because base is dev's live MinIO and dev is deliberately STAYING
|
||||||
|
# there — moving dev to R2 would reset its PITR window and orphan the backups its PROVEN
|
||||||
|
# restore depends on. Two environments, two targets, isolated by URL *and* by bucket.
|
||||||
- op: replace
|
- op: replace
|
||||||
path: /data/S3_BACKUP_ENDPOINT
|
path: /data/S3_BACKUP_ENDPOINT
|
||||||
value: "TBD-PROD-S3-BACKUP-ENDPOINT"
|
value: "https://7ce452a1b3d6c86daf2c020edcb82d37.r2.cloudflarestorage.com"
|
||||||
|
# R2 requires the region string to be exactly `auto`. (`head-bucket` answers
|
||||||
|
# `{"BucketRegion":"WNAM"}` — that is a PHYSICAL LOCATION HINT, not a signing region; using it
|
||||||
|
# here produces AuthorizationHeaderMalformed.) Dev keeps the base `us-east-1` its live MinIO
|
||||||
|
# has always been signed with.
|
||||||
|
- op: replace
|
||||||
|
path: /data/S3_BACKUP_REGION
|
||||||
|
value: "auto"
|
||||||
|
# ------------------------------------------------------------------------------------------
|
||||||
|
# OBJECT TENANCY INSIDE THE ONE BUCKET. The operator created a SINGLE R2 bucket, so velero and
|
||||||
|
# barman cannot be separated by bucket the way they are on dev's MinIO (`velero` and `cnpg`).
|
||||||
|
# They are separated by disjoint TOP-LEVEL PREFIXES instead:
|
||||||
|
#
|
||||||
|
# s3://k3s-prod-backup/velero/… Velero: kopia repo + backup metadata
|
||||||
|
# s3://k3s-prod-backup/barman/<clusterName>/… CNPG: WAL + base backups
|
||||||
|
#
|
||||||
|
# The bucket ROOT is left empty on purpose — a misrendered prefix then lands somewhere
|
||||||
|
# obviously wrong instead of silently colliding with a live tenant. Velero's retention TTL
|
||||||
|
# deletes objects under its own prefix only, so it can never reap a WAL segment; and barman's
|
||||||
|
# per-cluster serverName subdirectory keeps the three Postgres servers' chains apart, which
|
||||||
|
# matters because interleaving two servers' WAL into one prefix corrupts the chain outright.
|
||||||
|
# Dev cannot be reached from any of these: different endpoint AND different bucket names.
|
||||||
|
# ------------------------------------------------------------------------------------------
|
||||||
|
- op: replace
|
||||||
|
path: /data/VELERO_BACKUP_BUCKET
|
||||||
|
value: "k3s-prod-backup"
|
||||||
|
- op: replace
|
||||||
|
path: /data/VELERO_BACKUP_PREFIX
|
||||||
|
value: "velero"
|
||||||
|
- op: replace
|
||||||
|
path: /data/CNPG_BACKUP_PATH
|
||||||
|
value: "s3://k3s-prod-backup/barman"
|
||||||
|
# ------------------------------------------------------------------------------------------
|
||||||
|
# WHICH CREDENTIAL SIGNS THE REQUESTS TO THAT BUCKET (per-env credential split, 2026-09-07).
|
||||||
|
# ------------------------------------------------------------------------------------------
|
||||||
|
# The knobs above moved the ENDPOINT and the OBJECT LAYOUT per environment. The credential did
|
||||||
|
# not move with them, and it could not: the four backup-credential Secrets are SHARED BASE
|
||||||
|
# manifests, applied to both clusters by the same Flux tiers. Writing the R2 token into them
|
||||||
|
# gave prod a working key by TAKING DEV'S AWAY — dev would have signed MinIO requests with an
|
||||||
|
# R2 key id, and under archive_mode=on that is a filling pgdata PVC, not a missing backup.
|
||||||
|
#
|
||||||
|
# So each credential is now two objects, and this suffix selects prod's:
|
||||||
|
# velero-cloud-credentials-prod the R2 token in the AWS INI `cloud` shape
|
||||||
|
# <db>-backup-credentials-prod the same token as ACCESS_KEY_ID / ACCESS_SECRET_KEY
|
||||||
|
# for keycloak-db, nextcloud-db and semaphore-db. Both halves ship to both clusters (they are
|
||||||
|
# base resources); ONLY this value decides which one each consumer reads. It is a SUFFIX rather
|
||||||
|
# than four secret-name knobs deliberately — four could be updated three-quarters of the way,
|
||||||
|
# leaving one consumer on dev's MinIO key with nothing rendering wrong, and strict-mode
|
||||||
|
# substitution cannot catch a defined-but-stale variable.
|
||||||
|
#
|
||||||
|
# THE R2 TOKEN IS THE ONE PROVEN FROM INSIDE PROD (chore/r2-credentials 0a6a862): head-bucket on
|
||||||
|
# k3s-prod-backup returns BucketRegion WNAM, and PUT and DELETE both succeed. Rotating it now
|
||||||
|
# touches ONLY the four `-prod` files; dev's MinIO keys are untouched objects. That isolation is
|
||||||
|
# the structural reason for the split, independent of dev's eventual decommission.
|
||||||
|
- op: replace
|
||||||
|
path: /data/BACKUP_CREDENTIALS_SUFFIX
|
||||||
|
value: "-prod"
|
||||||
# TODO(WS-INFRA, Phase 7): pin prod node placement once the bare-metal role labels exist.
|
# TODO(WS-INFRA, Phase 7): pin prod node placement once the bare-metal role labels exist.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
# Self-managing ROOT Kustomization for PROD (mirrors clusters/dev/sync.yaml).
|
||||||
|
#
|
||||||
|
# Reconciles ./clusters/prod from the GitRepository so the ENTIRE prod graph — the cluster-vars
|
||||||
|
# ConfigMap, the GitRepository source, platform / platform-config / the app waves, and THIS object
|
||||||
|
# itself — is applied from git on every push. dev/sync.yaml carried the note "a prod overlay needs
|
||||||
|
# its own sync.yaml -> ./clusters/prod"; this is it.
|
||||||
|
#
|
||||||
|
# WHY IT IS NOW LOAD-BEARING, not just symmetry: `clusters/base/platform.yaml` gained
|
||||||
|
# `dependsOn: [flux-system]` (2026-09-01) to close the post-build substitution race — a dependent
|
||||||
|
# cannot reconcile revision N until the ROOT has APPLIED revision N. Without this object there is no
|
||||||
|
# `flux-system` Kustomization on prod, so `platform` would block forever on a dependency that does
|
||||||
|
# not exist, and NOTHING downstream would ever reconcile.
|
||||||
|
#
|
||||||
|
# `wait: false` is deliberate and is what keeps the dependsOn edge deadlock-free: the root reaches
|
||||||
|
# Ready on apply success without health-gating its children (platform does its own wait/health
|
||||||
|
# gating). Do not "harden" this to true.
|
||||||
|
apiVersion: kustomize.toolkit.fluxcd.io/v1
|
||||||
|
kind: Kustomization
|
||||||
|
metadata:
|
||||||
|
name: flux-system
|
||||||
|
namespace: flux-system
|
||||||
|
spec:
|
||||||
|
interval: 10m
|
||||||
|
retryInterval: 2m
|
||||||
|
timeout: 5m
|
||||||
|
prune: true
|
||||||
|
wait: false
|
||||||
|
sourceRef:
|
||||||
|
kind: GitRepository
|
||||||
|
name: flux-system
|
||||||
|
path: ./clusters/prod
|
||||||
@@ -8,3 +8,22 @@ _artifacts/
|
|||||||
|
|
||||||
# Python bytecode from lib/ (status.py imports coverage.py); never committed.
|
# Python bytecode from lib/ (status.py imports coverage.py); never committed.
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|
||||||
|
# --- THE ONE EXCEPTION: the committed run ledger (runs/) -------------------------------
|
||||||
|
# `runs/<UTC>-<short-sha>/{report.junit.xml,run.log}` + `runs/ledger.jsonl` are the
|
||||||
|
# machine-readable proof-of-pass record that lib/status.py's docstring asks for. They are
|
||||||
|
# SOURCE, not regenerated artifacts: a run's evidence cannot be reconstructed after the
|
||||||
|
# fact, so it must survive in git.
|
||||||
|
#
|
||||||
|
# The negations are needed because the ROOT .gitignore ignores `*.junit.xml` globally
|
||||||
|
# (and `coverage/`). A deeper .gitignore overrides a shallower one, and nothing ignores
|
||||||
|
# the `conformance/runs/` directory itself, so re-including the files here works (git
|
||||||
|
# cannot re-include a file whose PARENT DIRECTORY is excluded — that is why this must not
|
||||||
|
# live under _artifacts/).
|
||||||
|
#
|
||||||
|
# The run LOG is the load-bearing half: Chainsaw's JUnit carries no stdout, and the L2/L3
|
||||||
|
# tests self-skip GREEN, so the sentinel token in run.log is the only thing separating
|
||||||
|
# "proven" from "skipped". Never commit a report.junit.xml without its run.log.
|
||||||
|
!runs/
|
||||||
|
!runs/**
|
||||||
|
!runs/**/*.junit.xml
|
||||||
@@ -22,6 +22,7 @@ behavioral / L3 fault-injection** (this harness).
|
|||||||
./run.sh --lint # structural-validate test defs (no cluster)
|
./run.sh --lint # structural-validate test defs (no cluster)
|
||||||
./run.sh --strict-coverage # hygiene gate: fail on tag/annotation drift
|
./run.sh --strict-coverage # hygiene gate: fail on tag/annotation drift
|
||||||
./run.sh --require-all # phase-exit gate: every requirement must be covered
|
./run.sh --require-all # phase-exit gate: every requirement must be covered
|
||||||
|
./run.sh --record # run AND capture the evidence (see "Proof of pass")
|
||||||
```
|
```
|
||||||
|
|
||||||
**Gating is scoped, not "all 51 always"** (R-TEST-03). Three distinct checks:
|
**Gating is scoped, not "all 51 always"** (R-TEST-03). Three distinct checks:
|
||||||
@@ -105,13 +106,89 @@ lib/
|
|||||||
common.sh tagging convention + test discovery/selection helpers
|
common.sh tagging convention + test discovery/selection helpers
|
||||||
coverage.py requirement-coverage generator (stdlib only)
|
coverage.py requirement-coverage generator (stdlib only)
|
||||||
status.py requirement-STATUS generator (stdlib only; --selftest)
|
status.py requirement-STATUS generator (stdlib only; --selftest)
|
||||||
|
record_run.py run-ledger writer: PROOF-of-pass (stdlib only; --selftest)
|
||||||
emit_skipped_junit.py Phase-0 skipped-JUnit shim (no cluster)
|
emit_skipped_junit.py Phase-0 skipped-JUnit shim (no cluster)
|
||||||
|
runs/
|
||||||
|
ledger.jsonl TRACKED append-only proof-of-pass ledger (one row per
|
||||||
|
testcase per run) — the only source status.py trusts
|
||||||
|
<UTC>-<short-sha>/ TRACKED per-run evidence: run.log + report.junit.xml
|
||||||
tests/
|
tests/
|
||||||
l1-rendering/ l2-behavioral/ l3-fault-injection/ meta/ (see tests/README.md)
|
l1-rendering/ l2-behavioral/ l3-fault-injection/ meta/ (see tests/README.md)
|
||||||
incluster/
|
incluster/
|
||||||
chainsaw-job.yaml rbac.yaml in-cluster L2/L3 Job + scoped test SA (ADR-0011)
|
chainsaw-job.yaml rbac.yaml in-cluster L2/L3 Job + scoped test SA (ADR-0011)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Proof of pass — the run ledger (`runs/`)
|
||||||
|
|
||||||
|
`--status` used to report **PROVEN: 0** for all 51 requirements, and said so honestly:
|
||||||
|
proof-of-pass was not recoverable from repo state. `_artifacts/` is gitignored and the
|
||||||
|
root `.gitignore` kills `*.junit.xml` globally, so no run result ever reached git; the
|
||||||
|
only record of a green run was DECISIONS-LOG.md prose. `runs/` is the fix `lib/status.py`
|
||||||
|
asked for — **a committed, machine-readable run ledger**.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh --record # runs normally, and captures the evidence
|
||||||
|
# -> conformance/runs/<UTC>-<short-sha>/run.log (tee'd, carries the sentinels)
|
||||||
|
# -> conformance/runs/<UTC>-<short-sha>/report.junit.xml (that run's JUnit)
|
||||||
|
# -> conformance/runs/ledger.jsonl (one appended row per testcase)
|
||||||
|
python3 lib/record_run.py --run-dir runs/<id> [--dry-run] # or record an existing run dir
|
||||||
|
python3 lib/record_run.py --selftest # deterministic, no cluster
|
||||||
|
```
|
||||||
|
|
||||||
|
**Recording is explicit, never implicit.** Plain `./run.sh` writes no ledger row: a run
|
||||||
|
that silently records itself is how a bad run becomes a permanent false proof. **Commit
|
||||||
|
the run dir and the ledger** — an uncommitted ledger proves nothing.
|
||||||
|
|
||||||
|
### Why a committed JUnit alone would be WORSE than nothing
|
||||||
|
|
||||||
|
The L2/L3 tests are runnable and **self-skipping**: when a precondition is missing (no
|
||||||
|
`longhorn` StorageClass, no Velero CRDs, fewer than 3 Ready nodes) they echo a skip token
|
||||||
|
and `exit 0`, and their `check:` accepts either outcome —
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
(contains($stdout, 'DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK')
|
||||||
|
|| contains($stdout, 'DR_RESTORE_SKIPPED_OK')): true
|
||||||
|
```
|
||||||
|
|
||||||
|
So **a self-skip exits 0 and Chainsaw records `passed` exactly as a real run does**, and
|
||||||
|
Chainsaw's JUnit carries no stdout — its `<testcase>` elements hold only `<properties>`.
|
||||||
|
A committed JUnit on its own would therefore record `DR_RESTORE_SKIPPED_OK` as *proof of
|
||||||
|
R-STATE-03*: fake green, and worse than no ledger because it would look authoritative.
|
||||||
|
|
||||||
|
**The sentinel token in `run.log` is the only thing that separates proven from skipped.**
|
||||||
|
`record_run.py` derives each test's tokens from its own `chainsaw-test.yaml` (every
|
||||||
|
`contains($stdout, 'TOKEN')`; `*_SKIP*_OK` is a skip token, the last non-skip token is the
|
||||||
|
verdict token), so the table below is never hand-maintained:
|
||||||
|
|
||||||
|
| Test | pass token (proof) | skip token (never proof) |
|
||||||
|
|---|---|---|
|
||||||
|
| `l3/node-loss` | `NODE_LOSS_VOLUME_AVAILABLE_OK` | `NODE_LOSS_SKIPPED_OK` |
|
||||||
|
| `l3/dr-restore` | `DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK` (+ `OFF_CLUSTER_TARGET_OK`, `BACKUPS_ARE_SCHEDULED_OK`) | `DR_RESTORE_SKIPPED_OK`, `DR_SCHEDULE_SKIPPED_OK` |
|
||||||
|
| `l3/data-survival` | `DATA_SURVIVED_OK` | `DATA_SURVIVAL_SKIPPED_OK` |
|
||||||
|
| `l3/vm-data-survival` | `VM_DATA_SURVIVED_OK` | `VM_DATA_SURVIVAL_SKIPPED_OK` |
|
||||||
|
| `l3/hands-off-update` | `HANDS_OFF_UPDATE_OK` | `HANDS_OFF_UPDATE_SKIPPED_OK` |
|
||||||
|
| `l2/vm-lifecycle` | `VM_LIFECYCLE_OK` | `VM_LIFECYCLE_SKIPPED_OK` |
|
||||||
|
| `l2/vm-default-deny` | `VM_DEFAULT_DENY_OK` | `VM_DEFAULT_DENY_SKIPPED_OK` |
|
||||||
|
|
||||||
|
A row reaches `proof: "proven"` on **one** path: JUnit `passed`, **every** declared pass
|
||||||
|
token present in that test's log lines, and **no** skip token anywhere in them. A skip
|
||||||
|
token beats a pass token (worst wins). Anything else is `skipped`, `failed`,
|
||||||
|
`junit-skipped`, `unknown-no-sentinel-declared` or `unknown-sentinel-absent` — all
|
||||||
|
non-green. L0/L1 need no sentinel because `lib/run_l1_offline.py` already renders their
|
||||||
|
skips as JUnit `<skipped/>`, never as a green pass.
|
||||||
|
|
||||||
|
### Staleness — a proof is a claim about a tree, not a badge
|
||||||
|
|
||||||
|
`--status` honours a `proven` row only while the run's `git_sha` is a **descendant of the
|
||||||
|
last commit touching that requirement's implementing manifests or its verifying test**.
|
||||||
|
Otherwise the row degrades to **`stale-proof`**, which is not green. A run captured from a
|
||||||
|
dirty working tree is recorded as `<sha>-dirty`, which no ancestry check can satisfy — so
|
||||||
|
it never counts. Re-run with `--record` to refresh.
|
||||||
|
|
||||||
|
A requirement reads `proven` only when **every** verifying test does; one skipped, failed
|
||||||
|
or unrecorded test drags the whole requirement down. With an empty ledger `--status`
|
||||||
|
behaves exactly as before: every requirement `not-derivable`, headline **PROVEN 0**.
|
||||||
|
|
||||||
## How CI invokes it
|
## How CI invokes it
|
||||||
CI (Gitea Actions, wired in T-0.2 / migrated in T-6.1b) calls **this same `run.sh`**:
|
CI (Gitea Actions, wired in T-0.2 / migrated in T-6.1b) calls **this same `run.sh`**:
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,13 @@ rules:
|
|||||||
verbs: [create, delete, get, list, watch, patch]
|
verbs: [create, delete, get, list, watch, patch]
|
||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: [pods, services, configmaps, secrets, persistentvolumeclaims]
|
resources: [pods, services, configmaps, secrets, persistentvolumeclaims]
|
||||||
|
# LOAD-BEARING `get` ON CONFIGMAPS, BEYOND FIXTURE CREATION (R-ENV-02): the environment-
|
||||||
|
# agnostic L2 tests (dns-exposure, dns-internal-resolution, gateway-ingress-reachable,
|
||||||
|
# oidc-sso-grafana, trusted-tls) resolve the cluster's own BASE_DOMAIN / GATEWAY_VIP at run
|
||||||
|
# time from `flux-system/cluster-vars` — the same ConfigMap Flux substitutes from — via
|
||||||
|
# chainsaw `x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars')` bindings.
|
||||||
|
# If this rule is ever narrowed to the test's own namespace, those bindings fail and the
|
||||||
|
# tests error out. Narrow it only alongside a namespace-scoped read of that ConfigMap.
|
||||||
verbs: [create, delete, get, list, watch, patch]
|
verbs: [create, delete, get, list, watch, patch]
|
||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: [pods/exec]
|
resources: [pods/exec]
|
||||||
|
|||||||
Executable
+949
@@ -0,0 +1,949 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Append a conformance RUN LEDGER row per testcase — the committed proof-of-pass record.
|
||||||
|
|
||||||
|
WHY THIS EXISTS
|
||||||
|
---------------
|
||||||
|
`lib/status.py` reports PROOF as `not-derivable` for every requirement, and names its own
|
||||||
|
fix in its docstring: *"a committed, machine-readable run ledger"*. There was none, because
|
||||||
|
`conformance/_artifacts/` is git-ignored and the root `.gitignore` kills `*.junit.xml` and
|
||||||
|
`coverage/` globally, so nothing about a green run ever reached repo state. This tool is
|
||||||
|
that ledger's writer: it reads one run's JUnit + run log out of `conformance/runs/<run-id>/`
|
||||||
|
and appends one JSON object per testcase to `conformance/runs/ledger.jsonl`, which IS
|
||||||
|
tracked (see conformance/.gitignore for the negations that let it survive).
|
||||||
|
|
||||||
|
THE TRAP THIS TOOL EXISTS TO AVOID — READ BEFORE CHANGING ANYTHING
|
||||||
|
------------------------------------------------------------------
|
||||||
|
The L2/L3 tests are RUNNABLE / SELF-SKIPPING: when a precondition is absent (no `longhorn`
|
||||||
|
StorageClass, no Velero CRDs, <3 Ready nodes) they echo a skip token and `exit 0`, and their
|
||||||
|
`check:` block is deliberately written to accept either outcome, e.g. tests/l3-fault-
|
||||||
|
injection/dr-restore:
|
||||||
|
|
||||||
|
(contains($stdout, 'DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK')
|
||||||
|
|| contains($stdout, 'DR_RESTORE_SKIPPED_OK')): true
|
||||||
|
|
||||||
|
So a SELF-SKIP exits 0 and Chainsaw records `passed` EXACTLY as a real run does. And
|
||||||
|
Chainsaw's JUnit carries no stdout — its `<testcase>` elements hold only `<properties>`
|
||||||
|
(verified against the committed reports). Therefore:
|
||||||
|
|
||||||
|
*** A COMMITTED JUnit ALONE WOULD RECORD `DR_RESTORE_SKIPPED_OK` AS PROOF OF R-STATE-03. ***
|
||||||
|
|
||||||
|
AND THE SECOND TRAP, WHICH IS THE MIRROR OF THE FIRST
|
||||||
|
------------------------------------------------------
|
||||||
|
Chainsaw echoes each script's SOURCE into an indented `=== COMMAND` block before running
|
||||||
|
it, so `echo NODE_LOSS_SKIPPED_OK` — the skip branch that was NOT taken — is present in
|
||||||
|
the log of every run of that test. A section-blind grep therefore reads a skip token out
|
||||||
|
of the source of every skip-gated test and can never record `proven` for any of them: the
|
||||||
|
ledger fails closed so completely that it records nothing and proves nothing. Tokens are
|
||||||
|
consequently read from SECTIONS (see classify_sections): a pass token only from
|
||||||
|
`=== STDOUT`, a skip token from any section that is not script source. Skip still beats
|
||||||
|
pass — within genuine output, which is the only place either was ever evidence.
|
||||||
|
|
||||||
|
That is the fake-green this project refuses, and it would be worse than no ledger at all
|
||||||
|
because it would look authoritative. THE SENTINEL TOKEN IS THE ONLY THING THAT SEPARATES
|
||||||
|
PROVEN FROM SKIPPED, AND IT EXISTS ONLY IN THE RUN LOG. Hence: this tool requires BOTH
|
||||||
|
files, and for the Chainsaw layers it will NEVER emit `proof: "proven"` on a JUnit `passed`
|
||||||
|
unless the log carries that test's own pass token and carries NO skip token.
|
||||||
|
|
||||||
|
HOW THE TOKENS ARE OBTAINED (derived, never hand-listed)
|
||||||
|
--------------------------------------------------------
|
||||||
|
Hard-coding a token table would rot the moment a test is edited. Instead every token is
|
||||||
|
parsed out of the test's own `chainsaw-test.yaml`: each `contains($stdout, 'TOKEN')` in a
|
||||||
|
`check:` block is a declared sentinel. Classification is by the project's naming convention
|
||||||
|
(`*_SKIP*_OK` is a skip token, anything else is a pass token), and the TERMINAL pass token
|
||||||
|
— the last non-skip token in file order — is the test's verdict token. Edit a test's tokens
|
||||||
|
and this tool follows automatically; delete them and the test simply becomes unprovable
|
||||||
|
rather than silently proven.
|
||||||
|
|
||||||
|
node-loss pass NODE_LOSS_VOLUME_AVAILABLE_OK skip NODE_LOSS_SKIPPED_OK
|
||||||
|
dr-restore pass DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK skip DR_RESTORE_SKIPPED_OK
|
||||||
|
(+ OFF_CLUSTER_TARGET_OK, BACKUPS_ARE_SCHEDULED_OK / DR_SCHEDULE_SKIPPED_OK)
|
||||||
|
|
||||||
|
WHY L0/L1 NEED NO SENTINEL (and why that is not a loophole)
|
||||||
|
-----------------------------------------------------------
|
||||||
|
The offline layers are not run by Chainsaw. `lib/run_l1_offline.py` shells out to each
|
||||||
|
dir's `check.sh` / `render-check.sh` and maps exit 3 to a JUnit `<skipped/>` element and
|
||||||
|
any other non-zero to `<failure>`. A self-skip there is ALREADY a JUnit skip, never a green
|
||||||
|
pass, so the JUnit is a truthful record on its own. Only the Chainsaw layers (L2/L3/META)
|
||||||
|
can express "skipped" as "passed", and only they require a sentinel — see
|
||||||
|
SENTINEL_REQUIRED_LAYERS.
|
||||||
|
|
||||||
|
LEDGER ROW (conformance/runs/ledger.jsonl — append-only, one object per testcase per run)
|
||||||
|
ts_utc run-dir timestamp, else the JUnit's mtime (when)
|
||||||
|
git_sha `git rev-parse HEAD`, suffixed `-dirty` when tracked files were
|
||||||
|
modified — a proof captured from a dirty tree is NOT honoured by
|
||||||
|
status.py, because what ran is not what is committed (what)
|
||||||
|
env --env / CONF_ENV (cluster overlay the run targeted)
|
||||||
|
chainsaw_version CHAINSAW_VERSION from versions.env (the pin IS the truth)
|
||||||
|
kube_version `kubectl version` server gitVersion, else "unknown"
|
||||||
|
layer L0|L1|L2|L3|META, from the test's `layer` annotation / JUnit property
|
||||||
|
test testcase name (== the test dir basename, asserted by convention)
|
||||||
|
requirements[] the `conformance.home.k3s/R-*` labels on the test — the SAME
|
||||||
|
authoritative source coverage.py uses, not the JUnit classname
|
||||||
|
junit_result passed | failed | error | skipped
|
||||||
|
sentinel the DECIDING token grepped from run.log, or null
|
||||||
|
proof proven | skipped | failed | junit-skipped
|
||||||
|
| unknown-no-sentinel-declared | unknown-sentinel-absent
|
||||||
|
run_id the runs/<dir> name (provenance: which log backs this row)
|
||||||
|
note one line saying WHY this row landed on that proof state
|
||||||
|
|
||||||
|
`proven` is emitted on exactly one path (see decide()); every other path is non-green.
|
||||||
|
|
||||||
|
RECORDING IS EXPLICIT, NEVER IMPLICIT
|
||||||
|
-------------------------------------
|
||||||
|
Invoke this tool by hand, or via `run.sh --record`. A run that silently records itself is
|
||||||
|
how a bad run becomes a permanent false proof, so `run.sh` without `--record` writes no
|
||||||
|
ledger row at all.
|
||||||
|
|
||||||
|
EXIT CODES (ci/lib.sh convention: 0 pass / 1 fail / 3 skipped-not-green)
|
||||||
|
0 rows appended.
|
||||||
|
1 a real failure: unparsable JUnit, unreadable test tree, unwritable ledger.
|
||||||
|
3 the inputs a ledger row needs are absent (no report.junit.xml, or no run.log).
|
||||||
|
NOTHING is recorded and this is NEVER green — a run we cannot evidence is not a run.
|
||||||
|
|
||||||
|
Pure standard library, matching the harness's no-pip-dependency posture (coverage.py,
|
||||||
|
status.py). PyYAML lives in catalog/render.py; the harness does not depend on it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_LIB = Path(__file__).resolve().parent
|
||||||
|
if str(_LIB) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_LIB))
|
||||||
|
# Importing a sibling would otherwise litter conformance/lib/__pycache__/ into the working
|
||||||
|
# tree, and the lint stage scans untracked files. Same guard as status.py.
|
||||||
|
sys.dont_write_bytecode = True
|
||||||
|
import coverage as cov # noqa: E402 — reuse the registry/test parsers, don't fork them
|
||||||
|
|
||||||
|
CONF_ROOT = _LIB.parent
|
||||||
|
REPO_ROOT = CONF_ROOT.parent
|
||||||
|
DEFAULT_LEDGER = CONF_ROOT / "runs" / "ledger.jsonl"
|
||||||
|
|
||||||
|
# The ONLY layers exempt from needing a pass sentinel: the offline ones, whose skips
|
||||||
|
# lib/run_l1_offline.py already renders as JUnit <skipped/> rather than as a green pass
|
||||||
|
# (see docstring). Written as an EXEMPT set, not a REQUIRED set, so that an unrecognised
|
||||||
|
# or new layer fails CLOSED — it needs a sentinel — instead of quietly becoming provable
|
||||||
|
# by JUnit alone.
|
||||||
|
SENTINEL_EXEMPT_LAYERS = frozenset({"L0", "L1"})
|
||||||
|
|
||||||
|
# Layer from the test's subtree. Authoritative over the annotation: coverage.py's layer
|
||||||
|
# regex stops at the first non-[L/A/M/0-9] character, so `layer: "META"` parses as "M" —
|
||||||
|
# harmless for its own report, but here a mis-read layer decides whether a sentinel is
|
||||||
|
# required at all, so it is derived from the path exactly as emit_skipped_junit.py does.
|
||||||
|
_LAYER_BY_SUBTREE = (("/l0-static/", "L0"), ("/l1-rendering/", "L1"),
|
||||||
|
("/l2-behavioral/", "L2"), ("/l3-fault-injection/", "L3"),
|
||||||
|
("/meta/", "META"))
|
||||||
|
|
||||||
|
|
||||||
|
def layer_of(path: Path) -> str:
|
||||||
|
rel = str(path)
|
||||||
|
for frag, layer in _LAYER_BY_SUBTREE:
|
||||||
|
if frag in rel:
|
||||||
|
return layer
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# A declared sentinel: `contains($stdout, 'TOKEN')` inside a Chainsaw `check:` block.
|
||||||
|
# Both quote styles are accepted; the repo uses single quotes throughout.
|
||||||
|
_SENTINEL_RE = re.compile(r"contains\(\s*\$stdout\s*,\s*['\"]([A-Za-z0-9_]+)['\"]\s*\)")
|
||||||
|
|
||||||
|
# Naming convention for a SKIP token (tests/l3-fault-injection/*: NODE_LOSS_SKIPPED_OK,
|
||||||
|
# DR_RESTORE_SKIPPED_OK, DR_SCHEDULE_SKIPPED_OK, VM_DATA_SURVIVAL_SKIPPED_OK, ...).
|
||||||
|
# Deliberately broad: mis-classifying a PASS token as a skip only ever loses a proof;
|
||||||
|
# mis-classifying a SKIP token as a pass would manufacture one.
|
||||||
|
_SKIP_TOKEN_RE = re.compile(r"_SKIP")
|
||||||
|
|
||||||
|
# A Chainsaw log line header, as it really appears (Go `t.Log` prefixes the source site and
|
||||||
|
# pads it with backspaces, and the fields are ANSI-coloured — both are stripped first):
|
||||||
|
# l.go:52: | 23:51:18 | node-loss | volume-stays-available... | SCRIPT | LOG |
|
||||||
|
# Field 2 is the test name; it is how output is attributed back to a testcase. The
|
||||||
|
# `<file>.go:<line>:` prefix is optional so a redirected/plain log still attributes.
|
||||||
|
_LOG_HEADER_RE = re.compile(
|
||||||
|
r"^\s*(?:[\w./-]+\.go:\d+:\s*)?"
|
||||||
|
r"\|\s*\d{1,2}:\d{2}:\d{2}(?:\.\d+)?\s*\|\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*\|")
|
||||||
|
|
||||||
|
# ANSI colour + the backspace padding Go writes ahead of a `t.Log` continuation. Stripped
|
||||||
|
# before ANY parsing; neither ever appears at the head of a line, so indentation survives.
|
||||||
|
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_noise(line: str) -> str:
|
||||||
|
return _ANSI_RE.sub("", line).replace("\b", "")
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== output sections
|
||||||
|
# THE BUG THIS SECTION MACHINERY EXISTS TO KILL — READ BEFORE CHANGING ANYTHING
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Chainsaw echoes the script it is ABOUT TO RUN, verbatim, in an indented `=== COMMAND`
|
||||||
|
# block, and only afterwards prints what the script actually wrote in `=== STDOUT`. Every
|
||||||
|
# skip-gated test contains its own skip token as SOURCE TEXT (`echo NODE_LOSS_SKIPPED_OK`),
|
||||||
|
# so a raw whole-log grep finds that token in EVERY run of that test — including runs that
|
||||||
|
# never took the skip branch. Combined with decide()'s (correct) rule that a skip token
|
||||||
|
# outranks a pass token, a section-blind scan can never record `proven` for ANY test that
|
||||||
|
# has a skip gate: the ledger fails closed so hard it records nothing. Observed on
|
||||||
|
# runs/20260901T235117Z-e9a4b66, where NODE_LOSS_SKIPPED_OK appears twice as source at
|
||||||
|
# lines 51/62 (`=== COMMAND`) and never in output, yet the run genuinely proved R-STATE-02.
|
||||||
|
#
|
||||||
|
# So tokens are attributed to SECTIONS, never to raw lines:
|
||||||
|
# l.go:52: | 23:51:18 | node-loss | step | CMD | RUN | <- header, no section
|
||||||
|
# === COMMAND <- marker (indent 8)
|
||||||
|
# /usr/bin/sh -c set -euo pipefail <- source, indent >= 8
|
||||||
|
# echo NODE_LOSS_SKIPPED_OK <- SOURCE, not output
|
||||||
|
# l.go:52: | 23:52:42 | node-loss | step | SCRIPT | LOG | <- indent 4 => section ends
|
||||||
|
# === STDOUT <- marker
|
||||||
|
# NODE_LOSS_VOLUME_AVAILABLE_OK <- the REAL emission
|
||||||
|
#
|
||||||
|
# A marker is an INDENTED `=== NAME` on a line of its own. Go's own test markers
|
||||||
|
# (`=== RUN chainsaw`, `=== PAUSE`, `=== CONT`) are at column 0 and carry a test name
|
||||||
|
# after the verb, so they match neither the indent nor the end-of-line anchor; they are
|
||||||
|
# additionally named in _GO_TEST_MARKERS so a future indented variant still cannot open a
|
||||||
|
# section that would swallow the whole log.
|
||||||
|
_SECTION_MARKER_RE = re.compile(r"^(\s+)=== ([A-Z][A-Z0-9_]*)\s*$")
|
||||||
|
_GO_TEST_MARKERS = frozenset({"RUN", "PAUSE", "CONT"})
|
||||||
|
|
||||||
|
# Pseudo-section for a log that contains NO section markers at all (the plain/redirected
|
||||||
|
# logs the earlier ledger rows were written from, and the selftest fixtures). Such a log
|
||||||
|
# never echoes script source, so there is nothing to be confused by and the legacy
|
||||||
|
# whole-log scan is exactly as safe as it was — narrowing is only needed where a COMMAND
|
||||||
|
# echo exists. Lines OUTSIDE a section in a log that DOES use sections are attributed to
|
||||||
|
# "" and are evidence for nothing.
|
||||||
|
UNSECTIONED = "*"
|
||||||
|
|
||||||
|
# A pass token counts ONLY where Chainsaw's own `contains($stdout, ...)` check looks:
|
||||||
|
# real stdout. Anything else — source echo, stderr, an unrecognised future section — can
|
||||||
|
# never manufacture a proof.
|
||||||
|
PASS_SECTIONS = frozenset({"STDOUT", UNSECTIONED})
|
||||||
|
|
||||||
|
# Sections that carry SOURCE rather than output. A skip token is honoured in every OTHER
|
||||||
|
# section (STDOUT, STDERR, any section name added by a later Chainsaw release), because
|
||||||
|
# over-reading a skip only ever COSTS a proof, while over-reading a pass would fabricate
|
||||||
|
# one. Unknown section names therefore fail closed in both directions.
|
||||||
|
SOURCE_SECTIONS = frozenset({"COMMAND"})
|
||||||
|
|
||||||
|
|
||||||
|
def classify_sections(log_text: str) -> list[tuple[str, str]]:
|
||||||
|
"""[(section, line)] for every line of a run log.
|
||||||
|
|
||||||
|
`section` is the enclosing `=== NAME` block, "" for a line inside no block, or
|
||||||
|
UNSECTIONED for every line when the log declares no sections at all. A block runs from
|
||||||
|
its marker until the first non-blank line indented LESS than the marker (blank lines do
|
||||||
|
not end it) or until the next marker; the marker line itself is content of nothing.
|
||||||
|
"""
|
||||||
|
rows: list[tuple[str, str]] = []
|
||||||
|
cur, cur_indent, sectioned = "", 0, False
|
||||||
|
for raw in log_text.splitlines():
|
||||||
|
line = _strip_noise(raw)
|
||||||
|
m = _SECTION_MARKER_RE.match(line)
|
||||||
|
if m and m.group(2) not in _GO_TEST_MARKERS:
|
||||||
|
sectioned = True
|
||||||
|
cur, cur_indent = m.group(2), len(m.group(1))
|
||||||
|
rows.append(("", line))
|
||||||
|
continue
|
||||||
|
if cur:
|
||||||
|
if not line.strip() or len(line) - len(line.lstrip()) >= cur_indent:
|
||||||
|
rows.append((cur, line))
|
||||||
|
continue
|
||||||
|
cur = ""
|
||||||
|
rows.append(("", line))
|
||||||
|
if not sectioned:
|
||||||
|
return [(UNSECTIONED, line) for _, line in rows]
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def scan_texts(lines) -> tuple[str, str]:
|
||||||
|
"""(text a PASS token may be read from, text a SKIP token may be read from).
|
||||||
|
|
||||||
|
Accepts either [(section, line)] pairs from classify_sections() or a plain list of
|
||||||
|
strings, which is treated as an unsectioned log (legacy whole-log scanning).
|
||||||
|
"""
|
||||||
|
pass_lines, skip_lines = [], []
|
||||||
|
for item in lines:
|
||||||
|
section, line = item if isinstance(item, tuple) else (UNSECTIONED, item)
|
||||||
|
if section in PASS_SECTIONS:
|
||||||
|
pass_lines.append(line)
|
||||||
|
if section and section not in SOURCE_SECTIONS:
|
||||||
|
skip_lines.append(line)
|
||||||
|
return "\n".join(pass_lines), "\n".join(skip_lines)
|
||||||
|
|
||||||
|
_RUN_ID_TS_RE = re.compile(r"^(\d{8}T\d{6}Z)-")
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================= sentinels
|
||||||
|
def parse_sentinels(test_yaml: Path) -> dict:
|
||||||
|
"""Declared sentinel tokens of one Chainsaw test, derived from the test file itself.
|
||||||
|
|
||||||
|
Returns {"pass": [...], "skip": [...], "terminal_pass": str|None}. `terminal_pass` is
|
||||||
|
the LAST non-skip token in file order — the test's verdict token, i.e. the one its
|
||||||
|
final `check:` block asserts (dr-restore -> DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK).
|
||||||
|
A test with no `contains($stdout, ...)` assertion declares no sentinel and is reported
|
||||||
|
unprovable rather than proven; see decide().
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
text = test_yaml.read_text()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return {"pass": [], "skip": [], "terminal_pass": None}
|
||||||
|
toks = _SENTINEL_RE.findall(text)
|
||||||
|
passes, skips = [], []
|
||||||
|
for t in toks:
|
||||||
|
(skips if _SKIP_TOKEN_RE.search(t) else passes).append(t)
|
||||||
|
# De-duplicate, preserving order (dr-restore names DR_RESTORE_SKIPPED_OK twice).
|
||||||
|
def uniq(xs: list[str]) -> list[str]:
|
||||||
|
return list(dict.fromkeys(xs))
|
||||||
|
passes, skips = uniq(passes), uniq(skips)
|
||||||
|
return {"pass": passes, "skip": skips,
|
||||||
|
"terminal_pass": passes[-1] if passes else None}
|
||||||
|
|
||||||
|
|
||||||
|
def sentinel_map(tests_dir: Path) -> dict[str, dict]:
|
||||||
|
"""{test-name: sentinels} for every test in the tree.
|
||||||
|
|
||||||
|
Keyed on the Chainsaw `metadata.name`, which is what lands in the JUnit `<testcase
|
||||||
|
name=...>`. This repo keeps that equal to the test's directory basename; the mapping
|
||||||
|
is by name, so it stays correct if that ever drifts.
|
||||||
|
"""
|
||||||
|
out: dict[str, dict] = {}
|
||||||
|
for f in sorted(tests_dir.rglob("chainsaw-test.yaml")):
|
||||||
|
meta = cov.parse_test(f)
|
||||||
|
name = meta["name"] or f.parent.name
|
||||||
|
s = parse_sentinels(f)
|
||||||
|
s["dir"] = str(f.parent)
|
||||||
|
s["layer"] = layer_of(f) or meta["layer"]
|
||||||
|
s["requirements"] = meta["labels"]
|
||||||
|
out[name] = s
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================== log
|
||||||
|
def attribute_log(log_text: str, known: set[str]) -> tuple[dict[str, list[tuple[str, str]]],
|
||||||
|
bool]:
|
||||||
|
"""Split a run log into per-testcase buckets of (section, line) pairs.
|
||||||
|
|
||||||
|
Chainsaw prefixes its log lines with `| HH:MM:SS | <test> | <step> | ...` and prints a
|
||||||
|
script's sections underneath that header, so the test name is carried by a state
|
||||||
|
machine: a header naming a KNOWN test switches the current bucket, every following line
|
||||||
|
lands in it. Requiring the name to be known is what stops run.sh's own `[run.sh]`
|
||||||
|
chatter (and any other pipe-shaped line) from opening a bogus bucket.
|
||||||
|
|
||||||
|
Section labels come from classify_sections() and travel WITH the lines, so a bucket
|
||||||
|
still knows which of its lines were script source and which were real output.
|
||||||
|
|
||||||
|
Returns (buckets, attributed) where `attributed` is False when the log contained no
|
||||||
|
recognisable header at all — the signal for the degraded global scan in decide().
|
||||||
|
"""
|
||||||
|
buckets: dict[str, list[tuple[str, str]]] = {}
|
||||||
|
cur: str | None = None
|
||||||
|
attributed = False
|
||||||
|
for section, line in classify_sections(log_text):
|
||||||
|
m = _LOG_HEADER_RE.match(line)
|
||||||
|
if m and m.group(1) in known:
|
||||||
|
cur = m.group(1)
|
||||||
|
attributed = True
|
||||||
|
if cur is not None:
|
||||||
|
buckets.setdefault(cur, []).append((section, line))
|
||||||
|
return buckets, attributed
|
||||||
|
|
||||||
|
|
||||||
|
def unique_tokens(smap: dict[str, dict]) -> set[str]:
|
||||||
|
"""Tokens claimed by EXACTLY ONE test in the tree.
|
||||||
|
|
||||||
|
Only these may be used by the degraded global scan: `DNS_OK` is echoed by two different
|
||||||
|
tests, so in an unattributable log it can never be evidence for either; but
|
||||||
|
`NODE_LOSS_VOLUME_AVAILABLE_OK` names precisely one test and still can.
|
||||||
|
"""
|
||||||
|
owners: dict[str, set[str]] = {}
|
||||||
|
for name, s in smap.items():
|
||||||
|
for t in s["pass"] + s["skip"]:
|
||||||
|
owners.setdefault(t, set()).add(name)
|
||||||
|
return {t for t, o in owners.items() if len(o) == 1}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ decide
|
||||||
|
def decide(layer: str, junit_result: str, sent: dict, lines: list[str],
|
||||||
|
scope: str) -> tuple[str | None, str, str]:
|
||||||
|
"""The ONE place a testcase becomes `proven` — or, far more often, does not.
|
||||||
|
|
||||||
|
Returns (sentinel, proof, note).
|
||||||
|
|
||||||
|
Ordering is worst-first ON PURPOSE, so that ambiguity can only ever cost a proof:
|
||||||
|
1. JUnit failure/error -> failed (a red run is recorded as red)
|
||||||
|
2. JUnit <skipped/> -> junit-skipped (never green; ci/lib.sh rule)
|
||||||
|
3. ANY declared skip token seen -> skipped (BEATS a green JUnit and beats
|
||||||
|
IN REAL OUTPUT the pass token: if any gated
|
||||||
|
step self-skipped, the run did
|
||||||
|
not prove the requirement)
|
||||||
|
4. no sentinel declared -> proven only for L0/L1, whose skips are already
|
||||||
|
JUnit skips; unknown-no-sentinel-declared for
|
||||||
|
the Chainsaw layers
|
||||||
|
5. EVERY declared pass token seen -> proven
|
||||||
|
6. anything else -> unknown-sentinel-absent
|
||||||
|
Step 5 requires ALL pass tokens, not just the terminal one: every Chainsaw step here
|
||||||
|
asserts `pass || skip`, so a missing pass token means a step did not reach its proven
|
||||||
|
branch, whatever the JUnit says.
|
||||||
|
|
||||||
|
Steps 3 and 5 read from SECTION-SCOPED text, never from raw lines: a pass token counts
|
||||||
|
only in `=== STDOUT`, a skip token counts in any section that is not script source.
|
||||||
|
That narrows WHERE a token is read from and changes nothing about what it means — skip
|
||||||
|
still outranks pass, and it outranks it within genuine output, which is the only place
|
||||||
|
either of them was ever evidence. `lines` may be [(section, line)] pairs or plain
|
||||||
|
strings (an unsectioned log; see scan_texts / UNSECTIONED).
|
||||||
|
"""
|
||||||
|
if junit_result in ("failed", "error"):
|
||||||
|
return (None, "failed", f"JUnit recorded {junit_result}")
|
||||||
|
if junit_result == "skipped":
|
||||||
|
return (None, "junit-skipped",
|
||||||
|
"JUnit <skipped/> — the test did not execute; a skip is never green")
|
||||||
|
|
||||||
|
pass_text, skip_text = scan_texts(lines)
|
||||||
|
seen_skip = [t for t in sent["skip"] if t in skip_text]
|
||||||
|
if seen_skip:
|
||||||
|
return (seen_skip[0], "skipped",
|
||||||
|
f"run log OUTPUT carries the SELF-SKIP token {seen_skip[0]!r} — the test "
|
||||||
|
f"exited 0 without exercising the behaviour, so this is NOT proof")
|
||||||
|
|
||||||
|
if not sent["pass"] and not sent["skip"]:
|
||||||
|
if layer not in SENTINEL_EXEMPT_LAYERS:
|
||||||
|
return (None, "unknown-no-sentinel-declared",
|
||||||
|
f"{layer or 'unknown-layer'} test declares no contains($stdout, ...) "
|
||||||
|
f"sentinel, so a green JUnit cannot be told apart from a self-skip — "
|
||||||
|
f"refusing to call it proven")
|
||||||
|
return (None, "proven",
|
||||||
|
f"{layer} offline check exited 0; run_l1_offline.py renders its skips as "
|
||||||
|
f"JUnit <skipped/>, so a green JUnit is truthful without a sentinel")
|
||||||
|
|
||||||
|
missing = [t for t in sent["pass"] if t not in pass_text]
|
||||||
|
if not missing:
|
||||||
|
return (sent["terminal_pass"], "proven",
|
||||||
|
f"run log OUTPUT carries every declared pass token "
|
||||||
|
f"({', '.join(sent['pass'])}) and no skip token [{scope}]")
|
||||||
|
return (None, "unknown-sentinel-absent",
|
||||||
|
f"run log OUTPUT lacks pass token(s) {', '.join(missing)} for this test "
|
||||||
|
f"[{scope}] — refusing to infer proof from the JUnit alone")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================= junit
|
||||||
|
def junit_cases(path: Path) -> list[dict]:
|
||||||
|
"""[{name, layer, requirements, result}] for every <testcase> in a JUnit report."""
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
cases: list[dict] = []
|
||||||
|
for c in root.iter("testcase"):
|
||||||
|
if c.find("failure") is not None:
|
||||||
|
result = "failed"
|
||||||
|
elif c.find("error") is not None:
|
||||||
|
result = "error"
|
||||||
|
elif c.find("skipped") is not None:
|
||||||
|
result = "skipped"
|
||||||
|
else:
|
||||||
|
result = "passed"
|
||||||
|
props = {p.get("name"): (p.get("value") or "")
|
||||||
|
for p in c.iter("property") if p.get("name")}
|
||||||
|
reqs = sorted(set(cov.RID_RE.findall(props.get("requirements", ""))))
|
||||||
|
layer = (props.get("layer") or "").upper()
|
||||||
|
if not layer:
|
||||||
|
layer = (c.get("classname") or "").split(":", 1)[0].upper()
|
||||||
|
cases.append({"name": c.get("name") or "?", "layer": layer,
|
||||||
|
"requirements": reqs, "result": result})
|
||||||
|
return cases
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================== git
|
||||||
|
def git_sha(repo: Path) -> str:
|
||||||
|
"""HEAD, suffixed `-dirty` when TRACKED files differ from it.
|
||||||
|
|
||||||
|
Untracked files are deliberately ignored, matching status.py's `tracked_files()`
|
||||||
|
posture (untracked scratch is not repo state) — and because a `--record` run creates
|
||||||
|
untracked `runs/<id>/` files as it goes, which would otherwise mark every run dirty.
|
||||||
|
A `-dirty` sha is not a valid rev, so status.py's ancestry check cannot resolve it and
|
||||||
|
the proof degrades to `stale-proof`: correct, since what ran is not what is committed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
sha = subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"],
|
||||||
|
capture_output=True, text=True, check=True).stdout.strip()
|
||||||
|
except (subprocess.CalledProcessError, OSError):
|
||||||
|
return "unknown"
|
||||||
|
dirty = subprocess.run(["git", "-C", str(repo), "diff", "--quiet", "HEAD", "--"],
|
||||||
|
capture_output=True).returncode != 0
|
||||||
|
return f"{sha}-dirty" if dirty else sha
|
||||||
|
|
||||||
|
|
||||||
|
def pinned_chainsaw_version(conf_root: Path) -> str:
|
||||||
|
"""CHAINSAW_VERSION from versions.env — the pin is the single source of truth."""
|
||||||
|
f = conf_root / "versions.env"
|
||||||
|
if f.is_file():
|
||||||
|
m = re.search(r'^CHAINSAW_VERSION="?([^"\n]+)"?', f.read_text(), re.M)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def server_kube_version() -> str:
|
||||||
|
"""Server gitVersion via `kubectl version` (read-only), else "unknown"."""
|
||||||
|
for args in (["kubectl", "version", "-o", "json"],
|
||||||
|
["kubectl", "version", "--output=json"]):
|
||||||
|
try:
|
||||||
|
p = subprocess.run(args, capture_output=True, text=True, timeout=15)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
continue
|
||||||
|
if p.returncode != 0 or not p.stdout.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
v = json.loads(p.stdout).get("serverVersion") or {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if v.get("gitVersion"):
|
||||||
|
return v["gitVersion"]
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def run_timestamp(run_dir: Path, junit: Path) -> str:
|
||||||
|
"""UTC stamp: the run-dir name's own `<UTC>-<sha>` prefix, else the JUnit's mtime."""
|
||||||
|
m = _RUN_ID_TS_RE.match(run_dir.name)
|
||||||
|
if m:
|
||||||
|
t = m.group(1)
|
||||||
|
return f"{t[0:4]}-{t[4:6]}-{t[6:8]}T{t[9:11]}:{t[11:13]}:{t[13:15]}Z"
|
||||||
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(junit.stat().st_mtime))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================ record
|
||||||
|
def build_rows(run_dir: Path, tests_dir: Path, env: str, chainsaw_version: str,
|
||||||
|
kube_version: str, sha: str) -> list[dict]:
|
||||||
|
junit = run_dir / "report.junit.xml"
|
||||||
|
log = run_dir / "run.log"
|
||||||
|
smap = sentinel_map(tests_dir)
|
||||||
|
log_text = log.read_text(errors="replace")
|
||||||
|
buckets, attributed = attribute_log(log_text, set(smap))
|
||||||
|
uniq = unique_tokens(smap)
|
||||||
|
all_pairs = classify_sections(log_text)
|
||||||
|
sectioned = any(sec not in ("", UNSECTIONED) for sec, _ in all_pairs)
|
||||||
|
section_note = ("tokens read from real-output sections only"
|
||||||
|
if sectioned else "log declares no === sections; whole-log scan")
|
||||||
|
ts = run_timestamp(run_dir, junit)
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
for case in junit_cases(junit):
|
||||||
|
name = case["name"]
|
||||||
|
sent = smap.get(name, {"pass": [], "skip": [], "terminal_pass": None,
|
||||||
|
"layer": case["layer"], "requirements": []})
|
||||||
|
layer = sent.get("layer") or case["layer"] or "?"
|
||||||
|
# Requirements come from the test's LABELS (coverage.py's authoritative source);
|
||||||
|
# the JUnit property is the fallback for a testcase with no matching test file.
|
||||||
|
reqs = sent.get("requirements") or case["requirements"]
|
||||||
|
|
||||||
|
if name in buckets:
|
||||||
|
lines, scope = buckets[name], f"log scoped to this testcase; {section_note}"
|
||||||
|
elif not attributed:
|
||||||
|
# Degraded mode: the log had no recognisable Chainsaw header (a redirected or
|
||||||
|
# reformatted log). Fall back to a whole-log scan, but ONLY for tokens owned by
|
||||||
|
# exactly one test, so a token another test emitted can never prove this one.
|
||||||
|
lines = all_pairs
|
||||||
|
sent = dict(sent,
|
||||||
|
**{"pass": [t for t in sent["pass"] if t in uniq],
|
||||||
|
"skip": [t for t in sent["skip"] if t in uniq]})
|
||||||
|
sent["terminal_pass"] = sent["pass"][-1] if sent["pass"] else None
|
||||||
|
scope = ("DEGRADED: unattributable log, globally-unique tokens only; "
|
||||||
|
+ section_note)
|
||||||
|
else:
|
||||||
|
lines, scope = [], "no log lines attributed to this testcase"
|
||||||
|
|
||||||
|
sentinel, proof, note = decide(layer, case["result"], sent, lines, scope)
|
||||||
|
rows.append({
|
||||||
|
"ts_utc": ts,
|
||||||
|
"git_sha": sha,
|
||||||
|
"env": env,
|
||||||
|
"chainsaw_version": chainsaw_version,
|
||||||
|
"kube_version": kube_version,
|
||||||
|
"layer": layer,
|
||||||
|
"test": name,
|
||||||
|
"requirements": reqs,
|
||||||
|
"junit_result": case["result"],
|
||||||
|
"sentinel": sentinel,
|
||||||
|
"proof": proof,
|
||||||
|
"run_id": run_dir.name,
|
||||||
|
"note": note,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def append_rows(ledger: Path, rows: list[dict]) -> None:
|
||||||
|
"""Append-only, one compact JSON object per line. Never rewrites existing rows."""
|
||||||
|
ledger.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with ledger.open("a", encoding="utf-8") as fh:
|
||||||
|
for r in rows:
|
||||||
|
fh.write(json.dumps(r, sort_keys=True) + "\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================== selftest
|
||||||
|
def _selftest() -> int:
|
||||||
|
"""Deterministic, no-cluster self-test of every decision rule (house pattern:
|
||||||
|
`status.py --selftest`, `catalog/render.py --selftest`). Throwaway tree only.
|
||||||
|
|
||||||
|
The load-bearing case is `SKIP sentinel does NOT produce proven`: it is the single
|
||||||
|
assertion standing between this ledger and a permanent false proof of R-STATE-03.
|
||||||
|
"""
|
||||||
|
import tempfile
|
||||||
|
ok = True
|
||||||
|
|
||||||
|
def check(label: str, cond: bool) -> None:
|
||||||
|
nonlocal ok
|
||||||
|
if not cond:
|
||||||
|
sys.stderr.write(f"record_run.py selftest: FAIL — {label}\n")
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
# --- token derivation, against the dr-restore/node-loss shape ---------------------
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
tdir = root / "tests" / "l3-fault-injection" / "dr-restore"
|
||||||
|
tdir.mkdir(parents=True)
|
||||||
|
(tdir / "chainsaw-test.yaml").write_text(
|
||||||
|
"apiVersion: chainsaw.kyverno.io/v1alpha1\nkind: Test\nmetadata:\n"
|
||||||
|
" name: dr-restore\n labels:\n"
|
||||||
|
" conformance.home.k3s/R-STATE-03: \"true\"\n annotations:\n"
|
||||||
|
" conformance.home.k3s/requirements: \"R-STATE-03\"\n"
|
||||||
|
" conformance.home.k3s/layer: \"L3\"\nspec:\n steps:\n"
|
||||||
|
" - script:\n content: 'echo OFF_CLUSTER_TARGET_OK'\n"
|
||||||
|
" check:\n"
|
||||||
|
" (contains($stdout, 'OFF_CLUSTER_TARGET_OK')"
|
||||||
|
" || contains($stdout, 'DR_RESTORE_SKIPPED_OK')): true\n"
|
||||||
|
" - script:\n content: 'echo x'\n check:\n"
|
||||||
|
" (contains($stdout, 'DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK')"
|
||||||
|
" || contains($stdout, 'DR_RESTORE_SKIPPED_OK')): true\n")
|
||||||
|
s = parse_sentinels(tdir / "chainsaw-test.yaml")
|
||||||
|
check("pass tokens derived from the test file",
|
||||||
|
s["pass"] == ["OFF_CLUSTER_TARGET_OK",
|
||||||
|
"DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK"])
|
||||||
|
check("skip token classified by the *_SKIP*_OK convention, de-duplicated",
|
||||||
|
s["skip"] == ["DR_RESTORE_SKIPPED_OK"])
|
||||||
|
check("terminal pass token is the LAST non-skip token",
|
||||||
|
s["terminal_pass"] == "DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK")
|
||||||
|
|
||||||
|
smap = sentinel_map(root / "tests")
|
||||||
|
check("sentinel_map keys on metadata.name and carries layer + labels",
|
||||||
|
smap["dr-restore"]["layer"] == "L3"
|
||||||
|
and smap["dr-restore"]["requirements"] == ["R-STATE-03"])
|
||||||
|
|
||||||
|
# --- decide(): the SKIP-is-never-proven contract ----------------------------------
|
||||||
|
l3 = {"pass": ["DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK"],
|
||||||
|
"skip": ["DR_RESTORE_SKIPPED_OK"],
|
||||||
|
"terminal_pass": "DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK"}
|
||||||
|
|
||||||
|
# *** THE CASE THIS WHOLE FILE EXISTS FOR ***
|
||||||
|
sent, proof, _ = decide("L3", "passed", l3, ["DR_RESTORE_SKIPPED_OK"], "t")
|
||||||
|
check("SKIP sentinel on a GREEN JUnit is 'skipped', NEVER 'proven'",
|
||||||
|
proof == "skipped" and sent == "DR_RESTORE_SKIPPED_OK")
|
||||||
|
|
||||||
|
_, proof, _ = decide("L3", "passed", l3,
|
||||||
|
["DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK"], "t")
|
||||||
|
check("pass sentinel on a green JUnit is 'proven'", proof == "proven")
|
||||||
|
|
||||||
|
_, proof, _ = decide("L3", "passed", l3,
|
||||||
|
["DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK",
|
||||||
|
"DR_RESTORE_SKIPPED_OK"], "t")
|
||||||
|
check("a skip token BEATS a pass token in the same log (worst wins)",
|
||||||
|
proof == "skipped")
|
||||||
|
|
||||||
|
_, proof, _ = decide("L3", "passed", l3, ["nothing relevant here"], "t")
|
||||||
|
check("green JUnit with no sentinel in the log is unknown, not proven",
|
||||||
|
proof == "unknown-sentinel-absent")
|
||||||
|
|
||||||
|
_, proof, _ = decide("L3", "passed", l3, [], "t")
|
||||||
|
check("green JUnit with NO log lines attributed is unknown, not proven",
|
||||||
|
proof == "unknown-sentinel-absent")
|
||||||
|
|
||||||
|
_, proof, _ = decide("L3", "failed", l3,
|
||||||
|
["DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK"], "t")
|
||||||
|
check("a failing JUnit outranks a pass token in the log", proof == "failed")
|
||||||
|
|
||||||
|
_, proof, _ = decide("L3", "skipped", l3, [], "t")
|
||||||
|
check("JUnit <skipped/> records junit-skipped, never green",
|
||||||
|
proof == "junit-skipped")
|
||||||
|
|
||||||
|
multi = {"pass": ["A_OK", "B_OK"], "skip": [], "terminal_pass": "B_OK"}
|
||||||
|
_, proof, _ = decide("L3", "passed", multi, ["B_OK"], "t")
|
||||||
|
check("every declared pass token must be present, not just the terminal one",
|
||||||
|
proof == "unknown-sentinel-absent")
|
||||||
|
_, proof, _ = decide("L3", "passed", multi, ["A_OK", "B_OK"], "t")
|
||||||
|
check("all pass tokens present == proven", proof == "proven")
|
||||||
|
|
||||||
|
none = {"pass": [], "skip": [], "terminal_pass": None}
|
||||||
|
_, proof, _ = decide("L2", "passed", none, ["whatever"], "t")
|
||||||
|
check("a Chainsaw test declaring no sentinel is NOT proven by a green JUnit",
|
||||||
|
proof == "unknown-no-sentinel-declared")
|
||||||
|
_, proof, _ = decide("L1", "passed", none, [], "t")
|
||||||
|
check("L0/L1 offline checks are proven by JUnit (their skips are JUnit skips)",
|
||||||
|
proof == "proven")
|
||||||
|
_, proof, _ = decide("L0", "skipped", none, [], "t")
|
||||||
|
check("an L0 JUnit skip is still never green", proof == "junit-skipped")
|
||||||
|
_, proof, _ = decide("META", "passed", none, [], "t")
|
||||||
|
check("META (a Chainsaw layer) needs a sentinel like L2/L3",
|
||||||
|
proof == "unknown-no-sentinel-declared")
|
||||||
|
_, proof, _ = decide("", "passed", none, [], "t")
|
||||||
|
check("an unknown/blank layer fails CLOSED — sentinel required",
|
||||||
|
proof == "unknown-no-sentinel-declared")
|
||||||
|
|
||||||
|
# --- SECTION SCOPING: the COMMAND echo is SOURCE, never evidence -------------------
|
||||||
|
# Regression cases for the ledger's first real L3 run, which recorded a genuine PASS as
|
||||||
|
# `skipped` because `echo NODE_LOSS_SKIPPED_OK` — the skip branch NOT taken — is echoed
|
||||||
|
# inside `=== COMMAND` on every run of a skip-gated test.
|
||||||
|
nl = {"pass": ["NODE_LOSS_VOLUME_AVAILABLE_OK"],
|
||||||
|
"skip": ["NODE_LOSS_SKIPPED_OK"],
|
||||||
|
"terminal_pass": "NODE_LOSS_VOLUME_AVAILABLE_OK"}
|
||||||
|
_CMD = (" l.go:52: | 23:51:18 | node-loss | step | CMD | RUN |\n"
|
||||||
|
" === COMMAND\n"
|
||||||
|
" /usr/bin/sh -c set -euo pipefail\n"
|
||||||
|
" if ! kubectl get storageclass longhorn >/dev/null 2>&1; then\n"
|
||||||
|
" echo NODE_LOSS_SKIPPED_OK\n"
|
||||||
|
"\n"
|
||||||
|
" exit 0\n"
|
||||||
|
" fi\n"
|
||||||
|
" echo NODE_LOSS_VOLUME_AVAILABLE_OK\n"
|
||||||
|
" l.go:52: | 23:52:42 | node-loss | step | SCRIPT | LOG |\n")
|
||||||
|
|
||||||
|
ran = classify_sections(_CMD + " === STDOUT\n"
|
||||||
|
" ready nodes: 3\n"
|
||||||
|
" NODE_LOSS_VOLUME_AVAILABLE_OK\n"
|
||||||
|
" l.go:52: | 23:52:42 | node-loss | step | SCRIPT | DONE |\n")
|
||||||
|
sent, proof, _ = decide("L3", "passed", nl, ran, "t")
|
||||||
|
check("skip token ONLY in the COMMAND echo + pass token in STDOUT is PROVEN",
|
||||||
|
proof == "proven" and sent == "NODE_LOSS_VOLUME_AVAILABLE_OK")
|
||||||
|
|
||||||
|
selfskipped = classify_sections(_CMD + " === STDOUT\n"
|
||||||
|
" SKIP: 'longhorn' StorageClass absent\n"
|
||||||
|
" NODE_LOSS_SKIPPED_OK\n")
|
||||||
|
sent, proof, _ = decide("L3", "passed", nl, selfskipped, "t")
|
||||||
|
check("skip token GENUINELY in STDOUT is still 'skipped' (the guarantee holds)",
|
||||||
|
proof == "skipped" and sent == "NODE_LOSS_SKIPPED_OK")
|
||||||
|
|
||||||
|
neither = classify_sections(_CMD + " === STDOUT\n ready nodes: 3\n")
|
||||||
|
_, proof, _ = decide("L3", "passed", nl, neither, "t")
|
||||||
|
check("both tokens in COMMAND and neither in STDOUT is NOT proven",
|
||||||
|
proof == "unknown-sentinel-absent")
|
||||||
|
|
||||||
|
both_out = classify_sections(" === STDOUT\n"
|
||||||
|
" NODE_LOSS_VOLUME_AVAILABLE_OK\n"
|
||||||
|
" NODE_LOSS_SKIPPED_OK\n")
|
||||||
|
_, proof, _ = decide("L3", "passed", nl, both_out, "t")
|
||||||
|
check("skip still BEATS pass when both are in genuine output", proof == "skipped")
|
||||||
|
|
||||||
|
stderr_skip = classify_sections(" === STDERR\n NODE_LOSS_SKIPPED_OK\n"
|
||||||
|
" === STDOUT\n"
|
||||||
|
" NODE_LOSS_VOLUME_AVAILABLE_OK\n")
|
||||||
|
_, proof, _ = decide("L3", "passed", nl, stderr_skip, "t")
|
||||||
|
check("a skip token in a non-source section (STDERR) still costs the proof",
|
||||||
|
proof == "skipped")
|
||||||
|
|
||||||
|
stderr_pass = classify_sections(" === STDERR\n"
|
||||||
|
" NODE_LOSS_VOLUME_AVAILABLE_OK\n")
|
||||||
|
_, proof, _ = decide("L3", "passed", nl, stderr_pass, "t")
|
||||||
|
check("a pass token OUTSIDE stdout cannot prove anything",
|
||||||
|
proof == "unknown-sentinel-absent")
|
||||||
|
|
||||||
|
# --- classify_sections: markers, extents, and Go's own `=== RUN` --------------------
|
||||||
|
gorows = classify_sections("=== RUN chainsaw\n=== PAUSE chainsaw\n"
|
||||||
|
"=== CONT chainsaw/node-loss\n"
|
||||||
|
" === COMMAND\n echo NODE_LOSS_SKIPPED_OK\n"
|
||||||
|
" l.go:52: | 1:00:00 | node-loss | s | SCRIPT | DONE |\n"
|
||||||
|
"--- PASS: chainsaw/node-loss (131.09s)\n")
|
||||||
|
secs = [sec for sec, _ in gorows]
|
||||||
|
check("Go's column-0 `=== RUN/PAUSE/CONT` never open a section",
|
||||||
|
secs[:3] == ["", "", ""])
|
||||||
|
check("a section covers its indented body and ends at a less-indented line",
|
||||||
|
secs[3] == "" and secs[4] == "COMMAND" and secs[5] == "" and secs[6] == "")
|
||||||
|
check("a log with no === markers stays wholly scannable (UNSECTIONED)",
|
||||||
|
{sec for sec, _ in classify_sections("plain line\n another\n")} == {UNSECTIONED})
|
||||||
|
|
||||||
|
# --- the preserved run itself, as a fixture ----------------------------------------
|
||||||
|
# runs/20260901T235117Z-e9a4b66 IS the bug: NODE_LOSS_SKIPPED_OK twice in `=== COMMAND`
|
||||||
|
# (source), never in output; NODE_LOSS_VOLUME_AVAILABLE_OK once in `=== STDOUT`.
|
||||||
|
fixture = CONF_ROOT / "runs" / "20260901T235117Z-e9a4b66"
|
||||||
|
if (fixture / "run.log").is_file() and (fixture / "report.junit.xml").is_file():
|
||||||
|
rows = build_rows(fixture, CONF_ROOT / "tests", "dev", "v0.2.12", "v1.36.4", "sha")
|
||||||
|
row = next((r for r in rows if r["test"] == "node-loss"), None)
|
||||||
|
check("preserved real L3 run records node-loss/R-STATE-02 as proven",
|
||||||
|
row is not None and row["proof"] == "proven"
|
||||||
|
and row["sentinel"] == "NODE_LOSS_VOLUME_AVAILABLE_OK"
|
||||||
|
and row["requirements"] == ["R-STATE-02"])
|
||||||
|
check("preserved real L3 run attributes (no DEGRADED fallback needed)",
|
||||||
|
row is not None and "DEGRADED" not in row["note"])
|
||||||
|
pairs = classify_sections((fixture / "run.log").read_text(errors="replace"))
|
||||||
|
secs = {sec for sec, line in pairs if "NODE_LOSS_SKIPPED_OK" in line}
|
||||||
|
check("every NODE_LOSS_SKIPPED_OK in the preserved log is COMMAND source",
|
||||||
|
secs == {"COMMAND"})
|
||||||
|
secs = {sec for sec, line in pairs
|
||||||
|
if line.strip() == "NODE_LOSS_VOLUME_AVAILABLE_OK"}
|
||||||
|
check("the real emission in the preserved log is in STDOUT", secs == {"STDOUT"})
|
||||||
|
|
||||||
|
# --- log attribution --------------------------------------------------------------
|
||||||
|
log = ("[run.sh] Coverage report first\n"
|
||||||
|
" | 20:41:32 | node-loss | step | SCRIPT | RUN |\n"
|
||||||
|
" NODE_LOSS_SKIPPED_OK\n"
|
||||||
|
" | 20:44:01 | dr-restore | step | SCRIPT | RUN |\n"
|
||||||
|
" DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK\n")
|
||||||
|
b, attributed = attribute_log(log, {"node-loss", "dr-restore"})
|
||||||
|
check("log lines are attributed to the testcase whose header preceded them",
|
||||||
|
attributed
|
||||||
|
and any("NODE_LOSS_SKIPPED_OK" in l for _, l in b["node-loss"])
|
||||||
|
and not any("NODE_LOSS_SKIPPED_OK" in l for _, l in b["dr-restore"]))
|
||||||
|
check("a pass token does NOT leak across testcase boundaries",
|
||||||
|
not any("DR_RESTORED" in l for _, l in b["node-loss"]))
|
||||||
|
check("an unsectioned log keeps whole-log scanning (nothing narrowed away)",
|
||||||
|
all(sec == UNSECTIONED for sec, _ in b["node-loss"]))
|
||||||
|
|
||||||
|
# Chainsaw's real headers carry a Go `t.Log` source prefix and ANSI colour; the earlier
|
||||||
|
# regex matched neither, so every genuine Chainsaw log silently fell into DEGRADED mode.
|
||||||
|
real_hdr = (" l.go:52: \x08\x08\x08| 23:51:18 | \x1b[32;1mnode-loss\x1b[0;22m | "
|
||||||
|
"\x1b[32;1mstep\x1b[0;22m | SCRIPT | LOG |\n"
|
||||||
|
" === STDOUT\n NODE_LOSS_VOLUME_AVAILABLE_OK\n")
|
||||||
|
b2, attributed_real = attribute_log(real_hdr, {"node-loss"})
|
||||||
|
check("a REAL Chainsaw header (go source prefix + ANSI) attributes, not degrades",
|
||||||
|
attributed_real and "node-loss" in b2
|
||||||
|
and ("STDOUT", " NODE_LOSS_VOLUME_AVAILABLE_OK") in b2["node-loss"])
|
||||||
|
_, attributed2 = attribute_log("no chainsaw headers at all\n", {"node-loss"})
|
||||||
|
check("an unattributable log is reported as such (degraded mode)", not attributed2)
|
||||||
|
|
||||||
|
smap2 = {"a": {"pass": ["DNS_OK", "A_ONLY_OK"], "skip": []},
|
||||||
|
"b": {"pass": ["DNS_OK"], "skip": []}}
|
||||||
|
check("only tokens owned by exactly one test may be used in degraded mode",
|
||||||
|
unique_tokens(smap2) == {"A_ONLY_OK"})
|
||||||
|
|
||||||
|
# --- end-to-end: a self-skipped L3 run must land in the ledger as `skipped` --------
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
tdir = root / "tests" / "l3-fault-injection" / "node-loss"
|
||||||
|
tdir.mkdir(parents=True)
|
||||||
|
(tdir / "chainsaw-test.yaml").write_text(
|
||||||
|
"kind: Test\nmetadata:\n name: node-loss\n labels:\n"
|
||||||
|
" conformance.home.k3s/R-STATE-02: \"true\"\n annotations:\n"
|
||||||
|
" conformance.home.k3s/requirements: \"R-STATE-02\"\n"
|
||||||
|
" conformance.home.k3s/layer: \"L3\"\nspec:\n steps:\n"
|
||||||
|
" - script:\n content: 'echo hi'\n check:\n"
|
||||||
|
" (contains($stdout, 'NODE_LOSS_VOLUME_AVAILABLE_OK')"
|
||||||
|
" || contains($stdout, 'NODE_LOSS_SKIPPED_OK')): true\n")
|
||||||
|
rd = root / "runs" / "20260901T120000Z-abc1234"
|
||||||
|
rd.mkdir(parents=True)
|
||||||
|
(rd / "report.junit.xml").write_text(
|
||||||
|
'<testsuites><testsuite><testcase classname="L3:R-STATE-02" '
|
||||||
|
'name="node-loss"><properties>'
|
||||||
|
'<property name="requirements" value="R-STATE-02"/>'
|
||||||
|
'<property name="layer" value="L3"/></properties></testcase>'
|
||||||
|
"</testsuite></testsuites>")
|
||||||
|
(rd / "run.log").write_text(
|
||||||
|
" | 12:00:01 | node-loss | s | SCRIPT | RUN |\n"
|
||||||
|
" SKIP: 'longhorn' StorageClass absent\n"
|
||||||
|
" NODE_LOSS_SKIPPED_OK\n")
|
||||||
|
rows = build_rows(rd, root / "tests", "dev", "v0.2.12", "v1.36.4", "abc1234")
|
||||||
|
check("end-to-end: Chainsaw 'passed' + skip token => proof 'skipped'",
|
||||||
|
len(rows) == 1 and rows[0]["junit_result"] == "passed"
|
||||||
|
and rows[0]["proof"] == "skipped"
|
||||||
|
and rows[0]["sentinel"] == "NODE_LOSS_SKIPPED_OK")
|
||||||
|
check("end-to-end: row carries ts from the run-dir name and the R-ID label",
|
||||||
|
rows[0]["ts_utc"] == "2026-09-01T12:00:00Z"
|
||||||
|
and rows[0]["requirements"] == ["R-STATE-02"])
|
||||||
|
|
||||||
|
(rd / "run.log").write_text(
|
||||||
|
" | 12:00:01 | node-loss | s | SCRIPT | RUN |\n"
|
||||||
|
" NODE_LOSS_VOLUME_AVAILABLE_OK\n")
|
||||||
|
rows = build_rows(rd, root / "tests", "dev", "v0.2.12", "v1.36.4", "abc1234")
|
||||||
|
check("end-to-end: Chainsaw 'passed' + pass token => proof 'proven'",
|
||||||
|
rows[0]["proof"] == "proven"
|
||||||
|
and rows[0]["sentinel"] == "NODE_LOSS_VOLUME_AVAILABLE_OK")
|
||||||
|
|
||||||
|
ledger = root / "ledger.jsonl"
|
||||||
|
append_rows(ledger, rows)
|
||||||
|
append_rows(ledger, rows)
|
||||||
|
check("ledger is append-only JSONL (one parsable object per line)",
|
||||||
|
len(ledger.read_text().strip().splitlines()) == 2
|
||||||
|
and json.loads(ledger.read_text().splitlines()[0])["test"] == "node-loss")
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
return 1
|
||||||
|
sys.stderr.write("record_run.py selftest: PASS\n")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================== main
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
ap.add_argument("--run-dir", type=Path,
|
||||||
|
help="conformance/runs/<UTC>-<sha>/ holding report.junit.xml + run.log")
|
||||||
|
ap.add_argument("--ledger", type=Path, default=DEFAULT_LEDGER,
|
||||||
|
help=f"append-only ledger (default {DEFAULT_LEDGER})")
|
||||||
|
ap.add_argument("--tests", type=Path, default=CONF_ROOT / "tests")
|
||||||
|
ap.add_argument("--repo", type=Path, default=REPO_ROOT)
|
||||||
|
ap.add_argument("--env", default=os.environ.get("CONF_ENV", "dev"))
|
||||||
|
ap.add_argument("--chainsaw-version", default=None,
|
||||||
|
help="override the versions.env pin (recorded verbatim)")
|
||||||
|
ap.add_argument("--kube-version", default=os.environ.get("CONF_KUBE_VERSION"),
|
||||||
|
help="override the `kubectl version` probe (recorded verbatim)")
|
||||||
|
ap.add_argument("--dry-run", action="store_true",
|
||||||
|
help="print the rows that WOULD be appended; write nothing")
|
||||||
|
ap.add_argument("--selftest", action="store_true", help="run the built-in self-test")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.selftest:
|
||||||
|
return _selftest()
|
||||||
|
if args.run_dir is None:
|
||||||
|
sys.stderr.write("[record] --run-dir is required (or use --selftest)\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
run_dir = args.run_dir.resolve()
|
||||||
|
junit, log = run_dir / "report.junit.xml", run_dir / "run.log"
|
||||||
|
# BOTH files or nothing. The JUnit alone cannot tell a self-skip from a real pass, so
|
||||||
|
# recording from it alone is exactly the false proof this tool exists to prevent.
|
||||||
|
missing = [p.name for p in (junit, log) if not p.is_file()]
|
||||||
|
if missing:
|
||||||
|
sys.stderr.write(
|
||||||
|
f"[record] {run_dir} is missing {', '.join(missing)} — nothing recorded. "
|
||||||
|
f"The JUnit alone cannot distinguish a self-skip from a real pass "
|
||||||
|
f"(see module docstring). SKIPPED (not green).\n")
|
||||||
|
return 3
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = build_rows(
|
||||||
|
run_dir, args.tests, args.env,
|
||||||
|
args.chainsaw_version or pinned_chainsaw_version(CONF_ROOT),
|
||||||
|
args.kube_version or server_kube_version(),
|
||||||
|
git_sha(args.repo))
|
||||||
|
except ET.ParseError as e:
|
||||||
|
sys.stderr.write(f"[record] {junit} is not parsable JUnit: {e}\n")
|
||||||
|
return 1
|
||||||
|
except OSError as e:
|
||||||
|
sys.stderr.write(f"[record] cannot read the run inputs: {e}\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
sys.stderr.write(f"[record] {junit} contains no <testcase> — nothing to record. "
|
||||||
|
f"SKIPPED (not green).\n")
|
||||||
|
return 3
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
print(f"[record] {r['layer']:4s} {r['proof']:28s} {r['test']} "
|
||||||
|
f"({','.join(r['requirements']) or 'untagged'}) "
|
||||||
|
f"junit={r['junit_result']} sentinel={r['sentinel'] or '—'}")
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for r in rows:
|
||||||
|
counts[r["proof"]] = counts.get(r["proof"], 0) + 1
|
||||||
|
print(f"[record] {len(rows)} testcase(s): "
|
||||||
|
+ ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("[record] --dry-run: ledger NOT written.")
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
append_rows(args.ledger, rows)
|
||||||
|
except OSError as e:
|
||||||
|
sys.stderr.write(f"[record] cannot append to {args.ledger}: {e}\n")
|
||||||
|
return 1
|
||||||
|
print(f"[record] appended {len(rows)} row(s) -> {args.ledger}")
|
||||||
|
print("[record] COMMIT the run dir and the ledger; an uncommitted ledger proves nothing.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+666
-57
@@ -15,26 +15,42 @@ This generator derives, from the REPOSITORY ALONE, three orthogonal axes per req
|
|||||||
from a Flux Kustomization entrypoint (i.e. actually reconciled)?
|
from a Flux Kustomization entrypoint (i.e. actually reconciled)?
|
||||||
2. VERIFICATION — is there a requirement-tagged test, at which layer, and is it an
|
2. VERIFICATION — is there a requirement-tagged test, at which layer, and is it an
|
||||||
EXECUTABLE test or a TODO scaffold?
|
EXECUTABLE test or a TODO scaffold?
|
||||||
3. PROOF — has that test actually passed? *** DELIBERATELY NOT DERIVED. ***
|
3. PROOF — has that test actually passed? Derived ONLY from the committed
|
||||||
|
run ledger, and ONLY as far as that ledger's evidence reaches.
|
||||||
|
|
||||||
WHAT THIS TOOL REFUSES TO DERIVE (and why)
|
PROOF — WHAT CHANGED, AND WHAT DID NOT
|
||||||
------------------------------------------
|
--------------------------------------
|
||||||
PROOF-OF-PASS IS NOT DERIVABLE FROM REPO STATE. Nothing tracked in git records a test
|
This report used to say `not-derivable` for every requirement, because nothing tracked in
|
||||||
result: `conformance/_artifacts/` and `results/` are git-ignored, and the only prose
|
git recorded a test result and the only prose record of a green run lived in
|
||||||
record of a green run lives in DECISIONS-LOG.md — hand-maintained, unstructured, and
|
DECISIONS-LOG.md — hand-maintained, unstructured, exactly the source the operator ruled
|
||||||
exactly the kind of source the operator has ruled out. The project's own culture is that
|
out. The fix this docstring asked for now exists: `conformance/runs/ledger.jsonl`, an
|
||||||
"merged" is never "done" (agents/definition-of-done.md, R-TEST-03), so this report will
|
append-only ledger written by `lib/record_run.py` from a run's JUnit **and its run log**
|
||||||
NOT infer, guess, or default a requirement to green. Every requirement's proof state is
|
(see conformance/.gitignore for the negations that keep both in git).
|
||||||
reported as `not-derivable`, and the headline says so. If an untracked JUnit artifact
|
|
||||||
happens to be lying in the workspace it is surfaced ONLY as a quarantined advisory with
|
|
||||||
its mtime — never counted, never mixed into a status.
|
|
||||||
|
|
||||||
The honest fix is a committed, machine-readable run ledger; until that exists, the most
|
THE REFUSAL POSTURE IS UNCHANGED. Evidence replaced "not derivable" only where evidence
|
||||||
this report can say is "wired and testable", never "proven".
|
exists; nowhere is optimism substituted for it:
|
||||||
|
|
||||||
|
* A ledger row reaches `proven` only if `record_run.py` saw that test's own PASS
|
||||||
|
sentinel token in the run log. The L2/L3 tests SELF-SKIP GREEN — Chainsaw records
|
||||||
|
`passed` on `DR_RESTORE_SKIPPED_OK` exactly as on a real run, and its JUnit carries no
|
||||||
|
stdout — so a skip token maps to `skipped` and can never become proof.
|
||||||
|
* A proof is honoured only while it still describes the current code: if any of the
|
||||||
|
requirement's implementing manifests or verifying tests changed in a commit that is
|
||||||
|
NOT an ancestor of the run's `git_sha`, the row degrades to `stale-proof`, which is
|
||||||
|
not green. A run recorded from a dirty tree (`<sha>-dirty`) is never honoured.
|
||||||
|
* A requirement with no ledger row, or whose verifying tests did not ALL report proven,
|
||||||
|
stays `not-derivable` / `unknown` — never rounded up.
|
||||||
|
* The untracked `_artifacts/report.junit.xml` is STILL only a quarantined advisory with
|
||||||
|
its mtime. It is not the ledger, it is not evidence, and it feeds no status.
|
||||||
|
|
||||||
|
With an empty or absent ledger this report behaves exactly as it did before: every
|
||||||
|
requirement `not-derivable`, headline PROVEN 0, "the most this report can say is wired and
|
||||||
|
testable, never proven".
|
||||||
|
|
||||||
READS (all read-only)
|
READS (all read-only)
|
||||||
requirements/registry.yaml the authoritative R-* mirror (via lib/coverage.py)
|
requirements/registry.yaml the authoritative R-* mirror (via lib/coverage.py)
|
||||||
tests/**/chainsaw-test.yaml requirement tags + layer + executable annotation
|
tests/**/chainsaw-test.yaml requirement tags + layer + executable annotation
|
||||||
|
runs/ledger.jsonl COMMITTED proof-of-pass rows (via lib/record_run.py)
|
||||||
clusters/<env>/ Flux entrypoint -> the reconciliation graph
|
clusters/<env>/ Flux entrypoint -> the reconciliation graph
|
||||||
`git ls-files` TRACKED files only; untracked scratch is not repo state
|
`git ls-files` TRACKED files only; untracked scratch is not repo state
|
||||||
../home-network-k3s/docs/00-vision-and-scope.md OPTIONAL registry<->spec parity check
|
../home-network-k3s/docs/00-vision-and-scope.md OPTIONAL registry<->spec parity check
|
||||||
@@ -92,7 +108,14 @@ FLUX_API = "kustomize.toolkit.fluxcd.io"
|
|||||||
# conformance/tests — those are the TESTS; they are the VERIFICATION axis, counted there.
|
# conformance/tests — those are the TESTS; they are the VERIFICATION axis, counted there.
|
||||||
# ci/tests — gate unit-test fixtures embed example R-IDs as DATA.
|
# ci/tests — gate unit-test fixtures embed example R-IDs as DATA.
|
||||||
# registry.yaml — the requirement mirror itself, not an implementation of anything.
|
# registry.yaml — the requirement mirror itself, not an implementation of anything.
|
||||||
IMPL_EXCLUDE_PREFIXES = ("conformance/tests/", "ci/tests/")
|
# `conformance/runs/` is excluded for a subtler reason than the test dirs: the ledger and the
|
||||||
|
# preserved run logs CONTAIN the requirement IDs they are evidence FOR. Counting them as
|
||||||
|
# implementing evidence makes `last_change` move every time proof is recorded, so a row's
|
||||||
|
# git_sha always predates it and EVERY proof degrades to stale-proof the instant it is
|
||||||
|
# committed — recording the proof destroys it. The ledger is the proof record; it must not
|
||||||
|
# participate in the freshness computation about itself. (Found live 2026-09-01 on the first
|
||||||
|
# PROVEN row.)
|
||||||
|
IMPL_EXCLUDE_PREFIXES = ("conformance/tests/", "ci/tests/", "conformance/runs/")
|
||||||
IMPL_EXCLUDE_PATHS = ("conformance/requirements/registry.yaml",)
|
IMPL_EXCLUDE_PATHS = ("conformance/requirements/registry.yaml",)
|
||||||
|
|
||||||
|
|
||||||
@@ -274,6 +297,109 @@ _EXECUTABLE_RE = re.compile(
|
|||||||
_BLOCKED_RE = re.compile(
|
_BLOCKED_RE = re.compile(
|
||||||
r"^\s*" + re.escape(LABEL_PREFIX) + r"/blocked-by:\s*(.+)$", re.M)
|
r"^\s*" + re.escape(LABEL_PREFIX) + r"/blocked-by:\s*(.+)$", re.M)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- scaffold-marker detection
|
||||||
|
# WHY THIS IS NOT A WHOLE-FILE `"TODO" in text`
|
||||||
|
# ---------------------------------------------------
|
||||||
|
# It used to be, and that mis-reported a SAFETY-CRITICAL requirement. A test whose SUBJECT
|
||||||
|
# is placeholder detection has to name the idioms it hunts for:
|
||||||
|
# `l0-static/placeholder-guard-coupling/check.sh` carries
|
||||||
|
# `SEED = ["TBD", "TODO", "FIXME", "PLACEHOLDER", ...]` — its detection vocabulary, as data.
|
||||||
|
# A whole-file substring test read that as the test declaring itself unfinished, classified
|
||||||
|
# a 32-assertion executable check as a scaffold, raised a permanent `--strict` annotation
|
||||||
|
# conflict, and (because the same file is tagged R-STATE-03) reported R-STATE-03 as
|
||||||
|
# `exec=1 scaffold=1`. A previous engineer worked around it by rewording prose.
|
||||||
|
#
|
||||||
|
# The rule below is derived from the corpus (43 tests), not invented. Every genuine
|
||||||
|
# scaffold in this tree — `l2-behavioral/onboarding-completeness`, `l3-fault-injection/
|
||||||
|
# alerting`, `l3-fault-injection/gitops-drift`, and no others — declares itself in BOTH of
|
||||||
|
# two structural positions, and no executable test does either:
|
||||||
|
#
|
||||||
|
# 1. a `# STATUS: ... scaffold` line in the file's documentation header. This is the
|
||||||
|
# repo's DEDICATED declaration slot (tests/README.md): 3 of 43 tests say
|
||||||
|
# `STATUS: scaffold` and they are exactly the three scaffolds; 19 others declare
|
||||||
|
# `STATUS: EXECUTABLE` / `IMPLEMENTED` / `RUNNABLE`; the rest declare nothing.
|
||||||
|
# 2. a TODO/FIXME marker in the BODY that CARRIES A MESSAGE — `echo "TODO: assert PVC
|
||||||
|
# bound ..."`, `TODO(T-4.1): hermetic SMTP capture sink`. A marker that carries a
|
||||||
|
# message is a note about work that is missing. A marker that is the ENTIRE content of
|
||||||
|
# a quoted literal (`"TODO",` in a vocabulary list) carries no message: it is the
|
||||||
|
# marker as a *datum*, which is what a placeholder DETECTOR necessarily contains.
|
||||||
|
#
|
||||||
|
# Both are still evaluated over the body of `chainsaw-test.yaml` AND of any
|
||||||
|
# `render-check.sh` / `check.sh`, and either one still OUTRANKS an
|
||||||
|
# `executable: "true"` annotation (see classify_test) — the invariant that an annotation
|
||||||
|
# must never outrank the body is unchanged. Ambiguity still only ever DOWNGRADES: an
|
||||||
|
# unrecognised marker shape, a marker in body code outside a quoted literal, or a marker
|
||||||
|
# carrying any trailing text all classify as scaffold.
|
||||||
|
#
|
||||||
|
# KNOWN, ACCEPTED IMPRECISION (stated rather than hidden): a test that embeds a marker with
|
||||||
|
# text attached but as data — e.g. `re.compile(r"TODO|FIXME")`, or a single string holding
|
||||||
|
# several idioms — is still called a scaffold. That is the conservative direction, and the
|
||||||
|
# author's remedy is the same structural discipline the corpus already uses: keep each
|
||||||
|
# idiom a standalone literal in a list.
|
||||||
|
|
||||||
|
# Vocabulary: only the idioms that DECLARE unfinished work. Deliberately excludes `TBD`,
|
||||||
|
# `PLACEHOLDER`, `XXX`, `REPLACE-ME` — tests in this tree legitimately carry those in
|
||||||
|
# executable code as data (`l0-static/egress-paths-selected-by-rules/check.sh` compares
|
||||||
|
# authored values against `"TBD"`; `l0-static/no-plaintext-secrets/check.sh` matches
|
||||||
|
# `placeholder.*`), and none of them is used here to mark a placeholder body.
|
||||||
|
# Case-insensitive: no English word contains either token, and the corpus carries no
|
||||||
|
# lower-case occurrence, so folding case only ever ADDS detection (`echo "todo: ..."`).
|
||||||
|
_MARKER_RE = re.compile(r"\b(?:TODO|FIXME)\b", re.I)
|
||||||
|
|
||||||
|
# The dedicated declaration slot, on a comment line, case-insensitive. The declared state
|
||||||
|
# must be the FIRST token after `STATUS:` — a `STATUS:` line declares exactly one state,
|
||||||
|
# and all three real scaffolds read `# STATUS: scaffold — <what is missing>`. Anchoring it
|
||||||
|
# there is what keeps `# STATUS: EXECUTABLE ... this is not a scaffold` from matching; a
|
||||||
|
# loose `.*scaffold` would turn any prose that merely uses the word into a declaration.
|
||||||
|
_STATUS_SCAFFOLD_RE = re.compile(
|
||||||
|
r"^[ \t]*#[ \t]*STATUS:[ \t]*(?:scaffold|placeholder)\b", re.M | re.I)
|
||||||
|
|
||||||
|
# A quoted literal whose entire content is one marker word: the marker as data.
|
||||||
|
_MARKER_AS_DATUM_RE = re.compile(r"(['\"])[ \t]*(?:TODO|FIXME)[ \t]*\1", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_doc_header(text: str) -> tuple[str, str]:
|
||||||
|
"""Split a test file into (documentation header, body).
|
||||||
|
|
||||||
|
Both file kinds in this tree open with a banner comment block that is PROSE about the
|
||||||
|
test: the Given/When/Then, the incident being closed, the mandatory "what this does NOT
|
||||||
|
prove" list (tests/README.md, L0 authoring rule 1). Prose necessarily discusses
|
||||||
|
placeholder idioms when the test hunts them, so an incidental mention there is
|
||||||
|
documentation, not a placeholder body.
|
||||||
|
|
||||||
|
The body starts at the first line that is neither blank nor a `#` comment:
|
||||||
|
*.sh -> `set -euo pipefail` (the shebang is a comment)
|
||||||
|
*.yaml -> `apiVersion: chainsaw.kyverno.io/v1alpha1`
|
||||||
|
Comments BELOW that point are body, deliberately: a scaffold marks a missing fixture
|
||||||
|
with an inline comment (`l3-fault-injection/alerting`: `file: fixtures/smtp-sink.yaml
|
||||||
|
# TODO(T-4.1): hermetic SMTP capture sink`), and that must keep counting.
|
||||||
|
"""
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
for i, ln in enumerate(lines):
|
||||||
|
s = ln.strip()
|
||||||
|
if s and not s.startswith("#"):
|
||||||
|
return "".join(lines[:i]), "".join(lines[i:])
|
||||||
|
return text, "" # no executable line at all: it is all header, body is empty
|
||||||
|
|
||||||
|
|
||||||
|
def scaffold_marker(text: str) -> str:
|
||||||
|
"""Why `text` declares itself a placeholder body, or "" if it does not.
|
||||||
|
|
||||||
|
Returns a short human-readable reason (used verbatim in the hygiene report) so the
|
||||||
|
defect is actionable rather than a bare path.
|
||||||
|
"""
|
||||||
|
head, body = _split_doc_header(text)
|
||||||
|
m = _STATUS_SCAFFOLD_RE.search(head) or _STATUS_SCAFFOLD_RE.search(body)
|
||||||
|
if m:
|
||||||
|
return m.group(0).strip().lstrip("#").strip()[:80]
|
||||||
|
# Blank out markers held as pure data before looking for a marker that has a message.
|
||||||
|
m2 = _MARKER_RE.search(_MARKER_AS_DATUM_RE.sub('""', body))
|
||||||
|
if m2:
|
||||||
|
line = body[:m2.start()].count("\n")
|
||||||
|
snippet = body.splitlines()[line].strip()
|
||||||
|
return f"placeholder marker in body: {snippet[:80]}"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def classify_test(t: dict) -> dict:
|
def classify_test(t: dict) -> dict:
|
||||||
"""Decide EXECUTABLE vs SCAFFOLD for one discovered test, conservatively.
|
"""Decide EXECUTABLE vs SCAFFOLD for one discovered test, conservatively.
|
||||||
@@ -281,14 +407,18 @@ def classify_test(t: dict) -> dict:
|
|||||||
A tagged test is only worth counting if it can actually assert something. Rules, in
|
A tagged test is only worth counting if it can actually assert something. Rules, in
|
||||||
order, chosen so ambiguity NEVER upgrades a test:
|
order, chosen so ambiguity NEVER upgrades a test:
|
||||||
|
|
||||||
scaffold the body contains TODO markers (a placeholder), *even if* it
|
scaffold the test DECLARES itself a placeholder — a `# STATUS: scaffold`
|
||||||
is annotated executable — an annotation must never outrank the
|
header line, or a TODO/FIXME marker carrying a message in the
|
||||||
body. That mismatch is also reported as a hygiene defect.
|
body (see `scaffold_marker` for the rule and the corpus it was
|
||||||
executable annotated `conformance.home.k3s/executable: "true"` and no TODOs.
|
derived from) — *even if* it is annotated executable. An
|
||||||
executable-untagged no TODOs and a real runnable body, but the annotation is
|
annotation must never outrank the body. That mismatch is also
|
||||||
missing (true of the L1 render tests, which execute a
|
reported as a hygiene defect.
|
||||||
render-check.sh + golden diff, of the L0 static checks, and
|
executable annotated `conformance.home.k3s/executable: "true"` and no
|
||||||
of the META self-check).
|
scaffold declaration.
|
||||||
|
executable-untagged no scaffold declaration and a real runnable body, but the
|
||||||
|
annotation is missing (true of the L1 render tests, which
|
||||||
|
execute a render-check.sh + golden diff, of the L0 static
|
||||||
|
checks, and of the META self-check).
|
||||||
Counted as executable, and flagged as an annotation gap.
|
Counted as executable, and flagged as an annotation gap.
|
||||||
scaffold anything else.
|
scaffold anything else.
|
||||||
"""
|
"""
|
||||||
@@ -299,16 +429,26 @@ def classify_test(t: dict) -> dict:
|
|||||||
# downgrades the test to a scaffold — an annotation must never outrank the body,
|
# downgrades the test to a scaffold — an annotation must never outrank the body,
|
||||||
# whichever file the body lives in.
|
# whichever file the body lives in.
|
||||||
extra = ""
|
extra = ""
|
||||||
|
scripts: list[tuple[str, str]] = []
|
||||||
for script in ("render-check.sh", "check.sh"):
|
for script in ("render-check.sh", "check.sh"):
|
||||||
sp = p.parent / script
|
sp = p.parent / script
|
||||||
if sp.is_file():
|
if sp.is_file():
|
||||||
extra += sp.read_text()
|
body = sp.read_text()
|
||||||
|
extra += body
|
||||||
|
scripts.append((script, body))
|
||||||
declared = bool(_EXECUTABLE_RE.search(text))
|
declared = bool(_EXECUTABLE_RE.search(text))
|
||||||
has_todo = "TODO" in text or "TODO" in extra
|
# Each file is classified on ITS OWN structure — the yaml and the script have different
|
||||||
|
# header blocks, so they must not be concatenated before the header/body split.
|
||||||
|
reason = scaffold_marker(text)
|
||||||
|
for script, body in scripts:
|
||||||
|
if reason:
|
||||||
|
break
|
||||||
|
r = scaffold_marker(body)
|
||||||
|
reason = f"{script}: {r}" if r else ""
|
||||||
bm = _BLOCKED_RE.search(text)
|
bm = _BLOCKED_RE.search(text)
|
||||||
has_body = bool(extra) or bool(re.search(r"^\s+- (script|assert|apply):", text, re.M))
|
has_body = bool(extra) or bool(re.search(r"^\s+- (script|assert|apply):", text, re.M))
|
||||||
|
|
||||||
if has_todo:
|
if reason:
|
||||||
state = "scaffold"
|
state = "scaffold"
|
||||||
elif declared:
|
elif declared:
|
||||||
state = "executable"
|
state = "executable"
|
||||||
@@ -325,7 +465,8 @@ def classify_test(t: dict) -> dict:
|
|||||||
"name": t["name"], "path": rel, "layer": t["layer"],
|
"name": t["name"], "path": rel, "layer": t["layer"],
|
||||||
"state": state,
|
"state": state,
|
||||||
"declared_executable": declared,
|
"declared_executable": declared,
|
||||||
"annotation_conflict": bool(declared and has_todo),
|
"annotation_conflict": bool(declared and reason),
|
||||||
|
"scaffold_reason": reason,
|
||||||
"blocked_by": bm.group(1).strip().strip('"') if bm else "",
|
"blocked_by": bm.group(1).strip().strip('"') if bm else "",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,6 +484,169 @@ def spec_requirement_ids(spec_root: Path) -> set[str] | None:
|
|||||||
return set(RID_RE.findall(doc.read_text()))
|
return set(RID_RE.findall(doc.read_text()))
|
||||||
|
|
||||||
|
|
||||||
|
# ========================================================================== ledger
|
||||||
|
# PROOF axis. Everything here reads `conformance/runs/ledger.jsonl` (written ONLY by an
|
||||||
|
# explicit `lib/record_run.py` / `run.sh --record` invocation) and downgrades relentlessly.
|
||||||
|
# Nothing in this section may ever turn absent, ambiguous or aged evidence into green.
|
||||||
|
|
||||||
|
DEFAULT_LEDGER = CONF_ROOT / "runs" / "ledger.jsonl"
|
||||||
|
|
||||||
|
# Severity ranking, WORST FIRST. A requirement's proof is the MINIMUM over its verifying
|
||||||
|
# tests, so one failed/skipped/missing test drags the whole requirement down and a single
|
||||||
|
# proven test can never carry the others. `stale-proof` ranks below `proven` on purpose:
|
||||||
|
# it is a real past pass that no longer describes the current code, and it is NOT green.
|
||||||
|
PROOF_ORDER = {
|
||||||
|
"failed": 0, # a ledger row recorded a JUnit failure/error
|
||||||
|
"unknown": 1, # ran, but the log could not evidence pass vs self-skip
|
||||||
|
"not-derivable": 2, # no ledger row for this test at all (the pre-ledger default)
|
||||||
|
"junit-skipped": 3, # JUnit <skipped/> — did not execute
|
||||||
|
"skipped": 4, # SELF-SKIPPED green: exited 0 without exercising the behaviour
|
||||||
|
"stale-proof": 5, # proven at a sha the implementation/test has since moved past
|
||||||
|
"proven": 6, # the ONLY green state
|
||||||
|
}
|
||||||
|
# record_run.py's two "we cannot tell" states collapse to one reported state here; the
|
||||||
|
# per-test detail in status.json keeps the distinction for whoever has to fix it.
|
||||||
|
_PROOF_ALIASES = {
|
||||||
|
"unknown-no-sentinel-declared": "unknown",
|
||||||
|
"unknown-sentinel-absent": "unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_ledger(path: Path) -> list[dict]:
|
||||||
|
"""Parse the append-only JSONL ledger. A malformed line is SKIPPED, never guessed at.
|
||||||
|
|
||||||
|
Absent or empty file -> [] , which reproduces this report's pre-ledger behaviour
|
||||||
|
exactly (every requirement `not-derivable`, headline PROVEN 0).
|
||||||
|
"""
|
||||||
|
if not path.is_file():
|
||||||
|
return []
|
||||||
|
rows: list[dict] = []
|
||||||
|
for line in path.read_text(errors="replace").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
obj = json.loads(line)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
if isinstance(obj, dict) and obj.get("test"):
|
||||||
|
rows.append(obj)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def latest_by_test(rows: list[dict]) -> dict[str, dict]:
|
||||||
|
"""Most recent ledger row per test name (ts_utc, then file order as the tiebreak).
|
||||||
|
|
||||||
|
Latest wins rather than best-ever: a test that has since gone back to self-skipping
|
||||||
|
must stop reading as proven.
|
||||||
|
"""
|
||||||
|
latest: dict[str, dict] = {}
|
||||||
|
for i, r in enumerate(rows):
|
||||||
|
key = r["test"]
|
||||||
|
prev = latest.get(key)
|
||||||
|
if prev is None or (r.get("ts_utc", ""), i) >= (prev.get("ts_utc", ""),
|
||||||
|
prev["_i"]):
|
||||||
|
latest[key] = {**r, "_i": i}
|
||||||
|
return latest
|
||||||
|
|
||||||
|
|
||||||
|
class _GitOracle:
|
||||||
|
"""Small cached wrapper for the two git questions the staleness rule asks."""
|
||||||
|
|
||||||
|
def __init__(self, repo: Path):
|
||||||
|
self.repo = repo
|
||||||
|
self._last: dict[tuple[str, ...], str | None] = {}
|
||||||
|
self._anc: dict[tuple[str, str], bool] = {}
|
||||||
|
|
||||||
|
def _git(self, *args: str) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(["git", "-C", str(self.repo), *args],
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
def last_change(self, paths: tuple[str, ...]) -> str | None:
|
||||||
|
"""Commit that last touched any of `paths`; None if git knows none of them."""
|
||||||
|
if not paths:
|
||||||
|
return None
|
||||||
|
if paths in self._last:
|
||||||
|
return self._last[paths]
|
||||||
|
p = self._git("log", "-1", "--format=%H", "--", *paths)
|
||||||
|
sha = p.stdout.strip() if p.returncode == 0 else ""
|
||||||
|
self._last[paths] = sha or None
|
||||||
|
return self._last[paths]
|
||||||
|
|
||||||
|
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||||
|
"""True iff `older` is an ancestor of (or equal to) `newer`.
|
||||||
|
|
||||||
|
False whenever either rev is unresolvable — an unknown or `-dirty` sha (a run
|
||||||
|
recorded from a modified working tree) can never satisfy the freshness test, which
|
||||||
|
is the intended fail-closed direction.
|
||||||
|
"""
|
||||||
|
key = (older, newer)
|
||||||
|
if key in self._anc:
|
||||||
|
return self._anc[key]
|
||||||
|
ok = self._git("merge-base", "--is-ancestor", older, newer).returncode == 0
|
||||||
|
self._anc[key] = ok
|
||||||
|
return ok
|
||||||
|
|
||||||
|
|
||||||
|
def derive_proof(git: _GitOracle, rid: str, verifiers: list[dict],
|
||||||
|
impl_files: list[dict], latest: dict[str, dict]) -> dict:
|
||||||
|
"""PROOF for one requirement, from the ledger, with the staleness rule applied.
|
||||||
|
|
||||||
|
STALENESS RULE — a proof is a claim about a specific tree, not a permanent badge:
|
||||||
|
relevant paths = every implementing artifact this report found for the requirement
|
||||||
|
+ every verifying test's directory
|
||||||
|
last_change = `git log -1 -- <relevant paths>`
|
||||||
|
A row's `proven` is honoured only if `last_change` is an ANCESTOR of the row's
|
||||||
|
`git_sha`, i.e. the run happened at or after the newest change to the code it
|
||||||
|
claims to prove. Otherwise it degrades to `stale-proof` (not green). A `-dirty`
|
||||||
|
sha, a sha this clone does not have (rebased/dropped), or a path git has never
|
||||||
|
seen (an uncommitted test) all fail the ancestry test and degrade the same way.
|
||||||
|
|
||||||
|
The requirement's state is the MINIMUM over its verifying tests: every verifying test
|
||||||
|
must be proven and fresh before the requirement reads `proven`. A test with no ledger
|
||||||
|
row contributes `not-derivable`, so a partially-recorded run cannot manufacture green.
|
||||||
|
"""
|
||||||
|
paths = sorted({f["path"] for f in impl_files}
|
||||||
|
| {os.path.dirname(v["path"]) for v in verifiers if v.get("path")})
|
||||||
|
last_change = git.last_change(tuple(paths))
|
||||||
|
|
||||||
|
per_test: list[dict] = []
|
||||||
|
for v in verifiers:
|
||||||
|
row = latest.get(v["name"])
|
||||||
|
if row is None:
|
||||||
|
per_test.append({"test": v["name"], "state": "not-derivable",
|
||||||
|
"reason": "no ledger row — this test has never been "
|
||||||
|
"recorded by run.sh --record / record_run.py"})
|
||||||
|
continue
|
||||||
|
base = _PROOF_ALIASES.get(row.get("proof", ""), row.get("proof", "unknown"))
|
||||||
|
if base not in PROOF_ORDER:
|
||||||
|
base = "unknown"
|
||||||
|
state, reason = base, row.get("note", "")
|
||||||
|
if base == "proven":
|
||||||
|
sha = row.get("git_sha", "")
|
||||||
|
if last_change and not git.is_ancestor(last_change, sha):
|
||||||
|
state = "stale-proof"
|
||||||
|
reason = (f"proven at {sha[:12]}, but the implementing manifests / test "
|
||||||
|
f"changed in {last_change[:12]}, which that run predates")
|
||||||
|
per_test.append({"test": v["name"], "state": state, "reason": reason,
|
||||||
|
"run_id": row.get("run_id"), "ts_utc": row.get("ts_utc"),
|
||||||
|
"git_sha": row.get("git_sha"), "env": row.get("env"),
|
||||||
|
"sentinel": row.get("sentinel"),
|
||||||
|
"junit_result": row.get("junit_result")})
|
||||||
|
|
||||||
|
if not per_test:
|
||||||
|
state = "not-derivable"
|
||||||
|
reason = "no requirement-tagged test exists to be proven"
|
||||||
|
else:
|
||||||
|
worst = min(per_test, key=lambda d: PROOF_ORDER[d["state"]])
|
||||||
|
state, reason = worst["state"], worst["reason"]
|
||||||
|
if state == "not-derivable" and not reason:
|
||||||
|
reason = "no committed ledger row for this requirement's verifying test(s)"
|
||||||
|
|
||||||
|
return {"state": state, "reason": reason, "last_change_sha": last_change,
|
||||||
|
"evidence_paths": paths, "per_test": per_test}
|
||||||
|
|
||||||
|
|
||||||
# ========================================================================== build
|
# ========================================================================== build
|
||||||
IMPL_ORDER = {"none": 0, "off-cluster": 1, "unwired": 2, "reconciled-partial": 3,
|
IMPL_ORDER = {"none": 0, "off-cluster": 1, "unwired": 2, "reconciled-partial": 3,
|
||||||
"reconciled": 4}
|
"reconciled": 4}
|
||||||
@@ -369,7 +673,13 @@ LABELS = {
|
|||||||
|
|
||||||
def build(repo: Path, registry: list[dict], tests: list[dict], impl: dict[str, list[dict]],
|
def build(repo: Path, registry: list[dict], tests: list[dict], impl: dict[str, list[dict]],
|
||||||
applied: set[str], gitops_roots: set[str], spec_ids: set[str] | None,
|
applied: set[str], gitops_roots: set[str], spec_ids: set[str] | None,
|
||||||
env: str) -> dict:
|
env: str, ledger_rows: list[dict] | None = None) -> dict:
|
||||||
|
# PROOF axis inputs. `ledger_rows=None` / `[]` reproduces the pre-ledger behaviour:
|
||||||
|
# `latest` is empty, so every requirement derives `not-derivable` exactly as before.
|
||||||
|
ledger_rows = ledger_rows or []
|
||||||
|
latest = latest_by_test(ledger_rows)
|
||||||
|
git = _GitOracle(repo)
|
||||||
|
|
||||||
classified = [classify_test(t) for t in tests]
|
classified = [classify_test(t) for t in tests]
|
||||||
by_req_tests: dict[str, list[dict]] = {r["id"]: [] for r in registry}
|
by_req_tests: dict[str, list[dict]] = {r["id"]: [] for r in registry}
|
||||||
for t, c in zip(tests, classified):
|
for t, c in zip(tests, classified):
|
||||||
@@ -439,17 +749,19 @@ def build(repo: Path, registry: list[dict], tests: list[dict], impl: dict[str, l
|
|||||||
"blocked_by": sorted({t["blocked_by"] for t in ts if t["blocked_by"]}),
|
"blocked_by": sorted({t["blocked_by"] for t in ts if t["blocked_by"]}),
|
||||||
"tests": ts,
|
"tests": ts,
|
||||||
},
|
},
|
||||||
# NEVER derived. See the module docstring.
|
# Derived ONLY from the committed ledger, and only as far as it reaches.
|
||||||
"proof": {"state": "not-derivable",
|
# With no ledger row this stays `not-derivable`, as it always was.
|
||||||
"reason": "no committed, machine-readable record of a test run "
|
"proof": derive_proof(git, rid, ts, ev, latest),
|
||||||
"exists in the repo (_artifacts/ and results/ are "
|
|
||||||
"git-ignored); DECISIONS-LOG.md prose is not a signal"},
|
|
||||||
"status": LABELS[(impl_state, verify_state)],
|
"status": LABELS[(impl_state, verify_state)],
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- hygiene defects this tool owns -------------------------------------------
|
# --- hygiene defects this tool owns -------------------------------------------
|
||||||
hygiene = {
|
hygiene = {
|
||||||
"annotation_conflicts": [t["path"] for t in classified if t["annotation_conflict"]],
|
"annotation_conflicts": [t["path"] for t in classified if t["annotation_conflict"]],
|
||||||
|
# Same set, with WHY each one was called a placeholder body, so the fix is obvious
|
||||||
|
# from the report instead of requiring the reader to re-derive the rule.
|
||||||
|
"annotation_conflict_reasons": {t["path"]: t["scaffold_reason"]
|
||||||
|
for t in classified if t["annotation_conflict"]},
|
||||||
"executable_untagged": [t["path"] for t in classified
|
"executable_untagged": [t["path"] for t in classified
|
||||||
if t["state"] == "executable-untagged"],
|
if t["state"] == "executable-untagged"],
|
||||||
"registry_not_in_spec": [],
|
"registry_not_in_spec": [],
|
||||||
@@ -492,8 +804,19 @@ def build(repo: Path, registry: list[dict], tests: list[dict], impl: dict[str, l
|
|||||||
and v["verification"]["state"] == "executable"),
|
and v["verification"]["state"] == "executable"),
|
||||||
"safety_critical_total": count(lambda v: v["safety_critical"]),
|
"safety_critical_total": count(lambda v: v["safety_critical"]),
|
||||||
"safety_critical_without_executable_l3": len(safety_scaffold),
|
"safety_critical_without_executable_l3": len(safety_scaffold),
|
||||||
"proven": 0,
|
# PROVEN is now a real count — but only of requirements whose EVERY verifying
|
||||||
"proven_derivable": False,
|
# test has a committed ledger row that saw its pass sentinel, at a sha the
|
||||||
|
# implementation has not moved past. `stale-proof` and `skipped` are counted
|
||||||
|
# separately and are NOT green.
|
||||||
|
"proven": count(lambda v: v["proof"]["state"] == "proven"),
|
||||||
|
"proof_stale": count(lambda v: v["proof"]["state"] == "stale-proof"),
|
||||||
|
"proof_skipped": count(lambda v: v["proof"]["state"] == "skipped"),
|
||||||
|
"proof_failed": count(lambda v: v["proof"]["state"] == "failed"),
|
||||||
|
"proof_unknown": count(lambda v: v["proof"]["state"] == "unknown"),
|
||||||
|
"proof_not_derivable": count(
|
||||||
|
lambda v: v["proof"]["state"] in ("not-derivable", "junit-skipped")),
|
||||||
|
"ledger_rows": len(ledger_rows),
|
||||||
|
"proven_derivable": bool(ledger_rows),
|
||||||
"coverage_style_covered": count(lambda v: v["verification"]["state"] != "none"),
|
"coverage_style_covered": count(lambda v: v["verification"]["state"] != "none"),
|
||||||
}
|
}
|
||||||
summary["uncited_by_any_reconciled_manifest"] = count(
|
summary["uncited_by_any_reconciled_manifest"] = count(
|
||||||
@@ -552,16 +875,39 @@ def render_md(rep: dict, nodes: list[dict], advisory: dict | None) -> str:
|
|||||||
o.append(f"| Safety-critical requirements whose L3 test is a scaffold | "
|
o.append(f"| Safety-critical requirements whose L3 test is a scaffold | "
|
||||||
f"**{s['safety_critical_without_executable_l3']}** of "
|
f"**{s['safety_critical_without_executable_l3']}** of "
|
||||||
f"{s['safety_critical_total']} |")
|
f"{s['safety_critical_total']} |")
|
||||||
|
if s["proven_derivable"]:
|
||||||
|
o.append(f"| **PROVEN (every verifying test passed, evidenced)** | "
|
||||||
|
f"**{s['proven']}** |")
|
||||||
|
o.append(f"| Proof exists but is STALE (code changed after the run) | "
|
||||||
|
f"{s['proof_stale']} |")
|
||||||
|
o.append(f"| Verifying test SELF-SKIPPED green (not proof) | {s['proof_skipped']} |")
|
||||||
|
o.append(f"| Verifying test FAILED in the recorded run | {s['proof_failed']} |")
|
||||||
|
o.append(f"| Ran, but the log could not evidence pass vs self-skip | "
|
||||||
|
f"{s['proof_unknown']} |")
|
||||||
|
o.append(f"| No committed ledger row yet | {s['proof_not_derivable']} |")
|
||||||
|
o.append("")
|
||||||
|
o.append(f"> **Proof comes from `conformance/runs/ledger.jsonl`** "
|
||||||
|
f"({s['ledger_rows']} row(s)), written only by an explicit "
|
||||||
|
f"`run.sh --record` / `lib/record_run.py` invocation. A row reaches "
|
||||||
|
f"`proven` ONLY when the run log carried that test's own pass sentinel "
|
||||||
|
f"token — the L2/L3 tests self-skip GREEN (Chainsaw records `passed` on "
|
||||||
|
f"`DR_RESTORE_SKIPPED_OK` exactly as on a real run, and its JUnit carries "
|
||||||
|
f"no stdout), so a skip token maps to `skipped` and can never become "
|
||||||
|
f"proof. A proof is honoured only while the implementing manifests and "
|
||||||
|
f"the test itself have not changed since the recorded `git_sha`; "
|
||||||
|
f"otherwise it degrades to `stale-proof`, which is not green.\n")
|
||||||
|
else:
|
||||||
o.append(f"| **PROVEN (a test actually passed)** | **{s['proven']} — NOT DERIVABLE** |")
|
o.append(f"| **PROVEN (a test actually passed)** | **{s['proven']} — NOT DERIVABLE** |")
|
||||||
o.append("")
|
o.append("")
|
||||||
o.append("> **Proof is deliberately not derived.** Nothing tracked in git records a "
|
o.append("> **No proof is derivable: `conformance/runs/ledger.jsonl` is absent or "
|
||||||
"test result: `conformance/_artifacts/` and `results/` are git-ignored, and "
|
"empty.** The ledger is the committed, machine-readable run record; until "
|
||||||
"the only record of a green run is DECISIONS-LOG.md prose — hand-maintained, "
|
"a run is captured with `run.sh --record`, nothing tracked in git records "
|
||||||
"which is the very thing this report replaces. Per "
|
"a test result (`conformance/_artifacts/` and `results/` are git-ignored, "
|
||||||
"`agents/definition-of-done.md` a requirement is done only when its tagged "
|
"and DECISIONS-LOG.md prose is hand-maintained — the very thing this "
|
||||||
"test **passes**, so the strongest claim this report can make is "
|
"report replaces). Per `agents/definition-of-done.md` a requirement is "
|
||||||
"*wired and testable*. Closing the gap needs a committed, machine-readable "
|
"done only when its tagged test **passes**, so the strongest claim this "
|
||||||
"run ledger; until then, treat every row below as unproven.\n")
|
"report can make is *wired and testable*; treat every row below as "
|
||||||
|
"unproven.\n")
|
||||||
o.append(f"> For contrast, `coverage.md` counts **{s['coverage_style_covered']}** "
|
o.append(f"> For contrast, `coverage.md` counts **{s['coverage_style_covered']}** "
|
||||||
f"requirements as covered — that count includes the "
|
f"requirements as covered — that count includes the "
|
||||||
f"{s['verify_scaffold_only']} whose only verifying test is a TODO scaffold.\n")
|
f"{s['verify_scaffold_only']} whose only verifying test is a TODO scaffold.\n")
|
||||||
@@ -612,7 +958,15 @@ def render_md(rep: dict, nodes: list[dict], advisory: dict | None) -> str:
|
|||||||
if ver["blocked_by"]:
|
if ver["blocked_by"]:
|
||||||
vcell += f" · blocked-by: {'; '.join(ver['blocked_by'])}"
|
vcell += f" · blocked-by: {'; '.join(ver['blocked_by'])}"
|
||||||
sc = " ⚠safety" if v["safety_critical"] else ""
|
sc = " ⚠safety" if v["safety_critical"] else ""
|
||||||
o.append(f"| {rid}{sc} | **{v['status']}** | {icell} | {vcell} | not-derivable |")
|
pr = v["proof"]
|
||||||
|
if pr["state"] == "not-derivable":
|
||||||
|
pcell = "not-derivable"
|
||||||
|
else:
|
||||||
|
runs = sorted({d["run_id"] for d in pr["per_test"] if d.get("run_id")})
|
||||||
|
pcell = f"**{pr['state']}**" if pr["state"] == "proven" else pr["state"]
|
||||||
|
if runs:
|
||||||
|
pcell += f" ({', '.join(runs)})"
|
||||||
|
o.append(f"| {rid}{sc} | **{v['status']}** | {icell} | {vcell} | {pcell} |")
|
||||||
|
|
||||||
if rep["safety_critical_without_executable_l3"]:
|
if rep["safety_critical_without_executable_l3"]:
|
||||||
o.append("\n## Safety-critical requirements whose L3 fault test is a SCAFFOLD "
|
o.append("\n## Safety-critical requirements whose L3 fault test is a SCAFFOLD "
|
||||||
@@ -678,8 +1032,10 @@ def render_md(rep: dict, nodes: list[dict], advisory: dict | None) -> str:
|
|||||||
else:
|
else:
|
||||||
o.append("- registry ↔ spec parity: **NOT CHECKED** (spec tree not available; "
|
o.append("- registry ↔ spec parity: **NOT CHECKED** (spec tree not available; "
|
||||||
"this is a skip, not a pass)")
|
"this is a skip, not a pass)")
|
||||||
o.append(f"- tests annotated executable but containing TODO bodies: "
|
o.append("- tests annotated executable but declaring a placeholder body: "
|
||||||
f"{h['annotation_conflicts'] or 'none'}")
|
+ (", ".join(f"`{k}` ({v})"
|
||||||
|
for k, v in sorted(h["annotation_conflict_reasons"].items()))
|
||||||
|
if h["annotation_conflicts"] else "none"))
|
||||||
o.append(f"- executable tests missing the `{LABEL_PREFIX}/executable` annotation: "
|
o.append(f"- executable tests missing the `{LABEL_PREFIX}/executable` annotation: "
|
||||||
f"{h['executable_untagged'] or 'none'}")
|
f"{h['executable_untagged'] or 'none'}")
|
||||||
|
|
||||||
@@ -698,25 +1054,82 @@ def render_md(rep: dict, nodes: list[dict], advisory: dict | None) -> str:
|
|||||||
"reconciled |")
|
"reconciled |")
|
||||||
o.append("| verifying test | `conformance.home.k3s/R-*` label on a Chainsaw test "
|
o.append("| verifying test | `conformance.home.k3s/R-*` label on a Chainsaw test "
|
||||||
"(the authoritative tag, same source as `coverage.md`) | exact |")
|
"(the authoritative tag, same source as `coverage.md`) | exact |")
|
||||||
o.append("| executable vs scaffold | TODO markers in the test body outrank the "
|
o.append("| executable vs scaffold | a `# STATUS: scaffold` header line, or a "
|
||||||
"`executable` annotation; a real body without the annotation still counts "
|
"TODO/FIXME marker CARRYING A MESSAGE in the body (a marker held as a bare "
|
||||||
"and is flagged | conservative — ambiguity never upgrades |")
|
"quoted datum — a placeholder detector's own vocabulary — is not one); "
|
||||||
o.append("| **test actually passed** | **NOT DERIVED** — nothing in git records a "
|
"either outranks the `executable` annotation, and a real body without the "
|
||||||
"run result | refused |")
|
"annotation still counts and is flagged | conservative — ambiguity never "
|
||||||
|
"upgrades |")
|
||||||
|
o.append("| **test actually passed** | the committed `runs/ledger.jsonl` row for "
|
||||||
|
"every verifying test: JUnit result **plus** that test's own pass sentinel "
|
||||||
|
"token grepped from the run log | exact where a run was recorded, refused "
|
||||||
|
"everywhere else — a self-skip token or a missing/unrecognised sentinel "
|
||||||
|
"NEVER yields `proven` |")
|
||||||
|
o.append("| proof still current | the row's `git_sha` must be a descendant of the "
|
||||||
|
"last commit touching the requirement's implementing manifests or its test "
|
||||||
|
"| conservative — an unresolvable or `-dirty` sha degrades to `stale-proof` |")
|
||||||
o.append("| cluster liveness (pods/CRs healthy) | **NOT DERIVED** — no cluster is "
|
o.append("| cluster liveness (pods/CRs healthy) | **NOT DERIVED** — no cluster is "
|
||||||
"contacted; `reconciled` means Flux *would* apply it, not that it is "
|
"contacted; `reconciled` means Flux *would* apply it, not that it is "
|
||||||
"healthy | refused |")
|
"healthy | refused |")
|
||||||
o.append("| phase/board status | **NOT DERIVED** — `agents/task-board.md` is stale "
|
o.append("| phase/board status | **NOT DERIVED** — `agents/task-board.md` is stale "
|
||||||
"and is not a signal | refused |")
|
"and is not a signal | refused |")
|
||||||
|
|
||||||
|
o.append("\n## Run ledger — the committed proof-of-pass evidence\n")
|
||||||
|
if not s["proven_derivable"]:
|
||||||
|
o.append("- `conformance/runs/ledger.jsonl` holds no rows. Capture a run with "
|
||||||
|
"`./run.sh --record` (recording is never implicit: a run that silently "
|
||||||
|
"records itself is how a bad run becomes a permanent false proof), then "
|
||||||
|
"commit `conformance/runs/<run-id>/` **with its `run.log`** and the "
|
||||||
|
"ledger. The log is the load-bearing half — Chainsaw's JUnit carries no "
|
||||||
|
"stdout, so without it a self-skip is indistinguishable from a pass.")
|
||||||
|
else:
|
||||||
|
o.append(f"- {s['ledger_rows']} row(s) in `conformance/runs/ledger.jsonl`. "
|
||||||
|
f"Per-test evidence for every non-`not-derivable` requirement below; "
|
||||||
|
f"full rows (env, chainsaw/kube versions, sentinel, note) are in "
|
||||||
|
f"`status.json` under `by_requirement.<R-ID>.proof.per_test`.\n")
|
||||||
|
o.append("| Requirement | Proof | Test | Ledger state | Sentinel | Run |")
|
||||||
|
o.append("|---|---|---|---|---|---|")
|
||||||
|
for rid, v in rep["by_requirement"].items():
|
||||||
|
pr = v["proof"]
|
||||||
|
if pr["state"] == "not-derivable":
|
||||||
|
continue
|
||||||
|
for d in pr["per_test"]:
|
||||||
|
o.append(f"| {rid} | {pr['state']} | `{d['test']}` | {d['state']} | "
|
||||||
|
f"`{d.get('sentinel') or '—'}` | {d.get('run_id') or '—'} |")
|
||||||
|
stale = [(rid, v) for rid, v in rep["by_requirement"].items()
|
||||||
|
if v["proof"]["state"] == "stale-proof"]
|
||||||
|
if stale:
|
||||||
|
o.append("\n### Stale proofs — a real pass that no longer describes this tree\n")
|
||||||
|
o.append("These requirements DID pass, but their implementing manifests or "
|
||||||
|
"their verifying test changed in a commit the recorded run predates. "
|
||||||
|
"A stale proof is not green; re-run with `--record`.\n")
|
||||||
|
for rid, v in stale:
|
||||||
|
pr = v["proof"]
|
||||||
|
o.append(f"- **{rid}** — {pr['reason']}")
|
||||||
|
skipped = [(rid, v) for rid, v in rep["by_requirement"].items()
|
||||||
|
if v["proof"]["state"] == "skipped"]
|
||||||
|
if skipped:
|
||||||
|
o.append("\n### SELF-SKIPPED green — recorded, and deliberately NOT proof\n")
|
||||||
|
o.append("Chainsaw reported `passed` for these, because the test exited 0 "
|
||||||
|
"after emitting its skip token: a precondition (Longhorn "
|
||||||
|
"StorageClass, Velero CRDs, >=3 Ready nodes) was absent, so the "
|
||||||
|
"behaviour was never exercised. This is exactly the row a "
|
||||||
|
"JUnit-only ledger would have recorded as green.\n")
|
||||||
|
for rid, v in skipped:
|
||||||
|
toks = ", ".join(sorted({d.get("sentinel") or "?"
|
||||||
|
for d in v["proof"]["per_test"]
|
||||||
|
if d["state"] == "skipped"}))
|
||||||
|
o.append(f"- **{rid}** — skip token(s): `{toks}`")
|
||||||
|
|
||||||
o.append("\n## Advisory — untracked local run artifact (NOT counted)\n")
|
o.append("\n## Advisory — untracked local run artifact (NOT counted)\n")
|
||||||
if advisory:
|
if advisory:
|
||||||
o.append(f"- `{advisory['path']}` (untracked, git-ignored), last written "
|
o.append(f"- `{advisory['path']}` (untracked, git-ignored), last written "
|
||||||
f"{advisory['mtime']}: {advisory['tests']} testcase(s), "
|
f"{advisory['mtime']}: {advisory['tests']} testcase(s), "
|
||||||
f"{advisory['failures']} failure(s), {advisory['skipped']} skipped.")
|
f"{advisory['failures']} failure(s), {advisory['skipped']} skipped.")
|
||||||
o.append(f"- requirement IDs it names: {', '.join(advisory['requirements']) or '—'}")
|
o.append(f"- requirement IDs it names: {', '.join(advisory['requirements']) or '—'}")
|
||||||
o.append("- This is one developer's local workspace, not repo state. It is shown "
|
o.append("- This is one developer's local workspace, not repo state, and it is NOT "
|
||||||
"for orientation only and contributes to NO status above.")
|
"the ledger. It is shown for orientation only and contributes to NO "
|
||||||
|
"status above — a JUnit alone cannot tell a self-skip from a pass.")
|
||||||
else:
|
else:
|
||||||
o.append("- none present.")
|
o.append("- none present.")
|
||||||
return "\n".join(o) + "\n"
|
return "\n".join(o) + "\n"
|
||||||
@@ -841,6 +1254,186 @@ def _selftest() -> int:
|
|||||||
classify_test({"name": "s", "path": str(f), "layer": "L2"})["state"]
|
classify_test({"name": "s", "path": str(f), "layer": "L2"})["state"]
|
||||||
== "scaffold")
|
== "scaffold")
|
||||||
|
|
||||||
|
# === THE BUG THIS DETECTOR WAS REWRITTEN FOR ==============================
|
||||||
|
# A whole-file `"TODO" in text` mis-classified `l0-static/placeholder-guard-
|
||||||
|
# coupling` — a 32-assertion executable check whose SUBJECT is placeholder
|
||||||
|
# detection, so it must name the idioms it hunts — as a scaffold, and through it
|
||||||
|
# reported the SAFETY-CRITICAL R-STATE-03 as `exec=1 scaffold=1`. These
|
||||||
|
# assertions pin BOTH directions: a marker held as DATA must not downgrade, and a
|
||||||
|
# real placeholder body must still be caught. Both halves are load-bearing; the
|
||||||
|
# second is what stops this fix from degrading into "always executable".
|
||||||
|
yml = ("metadata:\n annotations:\n"
|
||||||
|
" conformance.home.k3s/executable: \"true\"\n"
|
||||||
|
"spec:\n steps:\n - script:\n"
|
||||||
|
" content: bash ./check.sh\n")
|
||||||
|
f.write_text(yml)
|
||||||
|
detector = td2 / "check.sh"
|
||||||
|
# Shaped exactly like the real check: a prose header that discusses the idioms,
|
||||||
|
# then executable code holding them as a vocabulary list.
|
||||||
|
detector.write_text(
|
||||||
|
"#!/usr/bin/env bash\n"
|
||||||
|
"# L0 static check — placeholder vocabulary\n"
|
||||||
|
"# * SEED — loud-placeholder idioms (TBD, TODO, FIXME, PLACEHOLDER)\n"
|
||||||
|
"# STATUS: EXECUTABLE. A pure offline assertion script.\n"
|
||||||
|
"set -euo pipefail\n"
|
||||||
|
"python3 - <<'EOF'\n"
|
||||||
|
"SEED = [\"TBD\", \"TODO\", \"FIXME\", \"PLACEHOLDER\"]\n"
|
||||||
|
"assert any(w in open('/etc/hostname').read().upper() for w in SEED) is False\n"
|
||||||
|
"EOF\n")
|
||||||
|
c = classify_test({"name": "s", "path": str(f), "layer": "L0"})
|
||||||
|
check("a marker in HEADER PROSE does not downgrade an executable check",
|
||||||
|
c["state"] == "executable" and not c["annotation_conflict"])
|
||||||
|
check("a marker held as a bare quoted DATUM does not downgrade (the R-STATE-03 bug)",
|
||||||
|
c["scaffold_reason"] == "")
|
||||||
|
|
||||||
|
# ... and the protection is intact: a real placeholder body is still caught, in
|
||||||
|
# each of the three shapes the corpus actually uses.
|
||||||
|
detector.write_text("#!/usr/bin/env bash\nset -euo pipefail\n"
|
||||||
|
"echo \"TODO: assert the PVC is bound\"\n")
|
||||||
|
c = classify_test({"name": "s", "path": str(f), "layer": "L0"})
|
||||||
|
check("a TODO carrying a message in check.sh IS still a scaffold",
|
||||||
|
c["state"] == "scaffold" and c["annotation_conflict"])
|
||||||
|
check("the scaffold reason names the file and the offending line",
|
||||||
|
c["scaffold_reason"].startswith("check.sh:")
|
||||||
|
and "TODO: assert the PVC is bound" in c["scaffold_reason"])
|
||||||
|
detector.write_text("#!/usr/bin/env bash\nset -euo pipefail\n"
|
||||||
|
"# TODO(T-4.1) write the hermetic sink fixture\ntrue\n")
|
||||||
|
check("a TODO(T-x.y) roadmap marker in an inline body comment IS still a scaffold",
|
||||||
|
classify_test({"name": "s", "path": str(f), "layer": "L0"})["state"]
|
||||||
|
== "scaffold")
|
||||||
|
detector.write_text("#!/usr/bin/env bash\nset -euo pipefail\n"
|
||||||
|
"echo \"FIXME the extraction before trusting this\"\n")
|
||||||
|
check("an unpunctuated FIXME carrying trailing prose IS still a scaffold",
|
||||||
|
classify_test({"name": "s", "path": str(f), "layer": "L0"})["state"]
|
||||||
|
== "scaffold")
|
||||||
|
# The dedicated declaration slot outranks a body that would otherwise look real —
|
||||||
|
# this is how all three genuine scaffolds in the tree declare themselves.
|
||||||
|
detector.unlink()
|
||||||
|
f.write_text("# L3 FAULT-INJECTION SCAFFOLD — x\n"
|
||||||
|
"# STATUS: scaffold — needs T-1.4 (Flux bootstrapped).\n" + yml)
|
||||||
|
c = classify_test({"name": "s", "path": str(f), "layer": "L3"})
|
||||||
|
check("a `# STATUS: scaffold` header declaration IS a scaffold, body notwithstanding",
|
||||||
|
c["state"] == "scaffold" and c["annotation_conflict"])
|
||||||
|
check("`# STATUS: EXECUTABLE` is not read as a scaffold declaration",
|
||||||
|
scaffold_marker("# STATUS: EXECUTABLE. `check.sh` is a pure offline "
|
||||||
|
"assertion script; covers no scaffold.\nset -e\n") == "")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- PROOF / ledger
|
||||||
|
# These are the assertions that stand between this report and a fake green.
|
||||||
|
check("empty ledger parses to no rows (pre-ledger behaviour is preserved)",
|
||||||
|
read_ledger(Path("/nonexistent/ledger.jsonl")) == [])
|
||||||
|
|
||||||
|
def row(**kw):
|
||||||
|
base = {"ts_utc": "2026-09-01T12:00:00Z", "git_sha": "deadbeef", "env": "dev",
|
||||||
|
"chainsaw_version": "v0.2.12", "kube_version": "v1.36.4", "layer": "L3",
|
||||||
|
"test": "dr-restore", "requirements": ["R-STATE-03"],
|
||||||
|
"junit_result": "passed", "sentinel": None, "proof": "skipped",
|
||||||
|
"run_id": "20260901T120000Z-deadbee", "note": ""}
|
||||||
|
base.update(kw)
|
||||||
|
return base
|
||||||
|
|
||||||
|
verifiers = [{"name": "dr-restore",
|
||||||
|
"path": "conformance/tests/l3-fault-injection/dr-restore/"
|
||||||
|
"chainsaw-test.yaml"}]
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
gr = Path(td)
|
||||||
|
env = {**os.environ, "GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
|
||||||
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t"}
|
||||||
|
|
||||||
|
def git(*a):
|
||||||
|
return subprocess.run(["git", "-C", str(gr), *a], capture_output=True,
|
||||||
|
text=True, env=env)
|
||||||
|
|
||||||
|
git("init", "-q", "-b", "main")
|
||||||
|
(gr / "platform").mkdir()
|
||||||
|
(gr / "platform" / "velero.yaml").write_text("kind: X # R-STATE-03\n")
|
||||||
|
git("add", "-A"); git("commit", "-qm", "one")
|
||||||
|
sha_a = git("rev-parse", "HEAD").stdout.strip()
|
||||||
|
(gr / "platform" / "velero.yaml").write_text("kind: X # R-STATE-03 v2\n")
|
||||||
|
git("add", "-A"); git("commit", "-qm", "two")
|
||||||
|
sha_b = git("rev-parse", "HEAD").stdout.strip()
|
||||||
|
|
||||||
|
oracle = _GitOracle(gr)
|
||||||
|
impl = [{"path": "platform/velero.yaml"}]
|
||||||
|
|
||||||
|
# *** THE CASE THIS WHOLE FEATURE EXISTS FOR ***
|
||||||
|
pr = derive_proof(oracle, "R-STATE-03", verifiers, impl,
|
||||||
|
latest_by_test([row(proof="skipped", junit_result="passed",
|
||||||
|
sentinel="DR_RESTORE_SKIPPED_OK",
|
||||||
|
git_sha=sha_b)]))
|
||||||
|
check("a SELF-SKIP row on a GREEN JUnit is 'skipped', NEVER 'proven'",
|
||||||
|
pr["state"] == "skipped")
|
||||||
|
|
||||||
|
pr = derive_proof(oracle, "R-STATE-03", verifiers, impl,
|
||||||
|
latest_by_test([row(proof="proven", git_sha=sha_b,
|
||||||
|
sentinel="DR_RESTORED_OK")]))
|
||||||
|
check("a proven row at the CURRENT sha is honoured", pr["state"] == "proven")
|
||||||
|
|
||||||
|
pr = derive_proof(oracle, "R-STATE-03", verifiers, impl,
|
||||||
|
latest_by_test([row(proof="proven", git_sha=sha_a,
|
||||||
|
sentinel="DR_RESTORED_OK")]))
|
||||||
|
check("STALENESS: proven at a sha the implementation has moved past degrades "
|
||||||
|
"to stale-proof", pr["state"] == "stale-proof")
|
||||||
|
|
||||||
|
pr = derive_proof(oracle, "R-STATE-03", verifiers, impl,
|
||||||
|
latest_by_test([row(proof="proven", git_sha=f"{sha_b}-dirty",
|
||||||
|
sentinel="DR_RESTORED_OK")]))
|
||||||
|
check("a run recorded from a DIRTY tree is never honoured as proof",
|
||||||
|
pr["state"] == "stale-proof")
|
||||||
|
|
||||||
|
pr = derive_proof(oracle, "R-STATE-03", verifiers, impl,
|
||||||
|
latest_by_test([row(proof="proven", git_sha="0" * 40)]))
|
||||||
|
check("a git_sha this clone cannot resolve degrades, never upgrades",
|
||||||
|
pr["state"] == "stale-proof")
|
||||||
|
|
||||||
|
for bad, expect in (("failed", "failed"),
|
||||||
|
("junit-skipped", "junit-skipped"),
|
||||||
|
("unknown-sentinel-absent", "unknown"),
|
||||||
|
("unknown-no-sentinel-declared", "unknown"),
|
||||||
|
("something-new-from-the-future", "unknown")):
|
||||||
|
pr = derive_proof(oracle, "R-STATE-03", verifiers, impl,
|
||||||
|
latest_by_test([row(proof=bad, git_sha=sha_b)]))
|
||||||
|
check(f"ledger state {bad!r} maps to {expect!r}, never to proven",
|
||||||
|
pr["state"] == expect)
|
||||||
|
|
||||||
|
pr = derive_proof(oracle, "R-STATE-03", verifiers, impl, {})
|
||||||
|
check("no ledger row at all stays not-derivable",
|
||||||
|
pr["state"] == "not-derivable")
|
||||||
|
|
||||||
|
# Two verifying tests, only one proven: the requirement must NOT read proven.
|
||||||
|
two = verifiers + [{"name": "data-survival",
|
||||||
|
"path": "conformance/tests/l3-fault-injection/"
|
||||||
|
"data-survival/chainsaw-test.yaml"}]
|
||||||
|
pr = derive_proof(oracle, "R-STATE-03", two, impl,
|
||||||
|
latest_by_test([row(proof="proven", git_sha=sha_b)]))
|
||||||
|
check("a requirement is proven only when EVERY verifying test is",
|
||||||
|
pr["state"] == "not-derivable")
|
||||||
|
pr = derive_proof(oracle, "R-STATE-03", two, impl,
|
||||||
|
latest_by_test([row(proof="proven", git_sha=sha_b),
|
||||||
|
row(test="data-survival", proof="skipped",
|
||||||
|
git_sha=sha_b)]))
|
||||||
|
check("worst-of wins across verifying tests (skip drags proven down)",
|
||||||
|
pr["state"] == "skipped")
|
||||||
|
|
||||||
|
# Latest row wins, not best-ever: a test that regressed to self-skipping must
|
||||||
|
# stop reading as proven.
|
||||||
|
pr = derive_proof(oracle, "R-STATE-03", verifiers, impl,
|
||||||
|
latest_by_test([row(ts_utc="2026-09-01T10:00:00Z",
|
||||||
|
proof="proven", git_sha=sha_b),
|
||||||
|
row(ts_utc="2026-09-02T10:00:00Z",
|
||||||
|
proof="skipped", git_sha=sha_b)]))
|
||||||
|
check("the LATEST ledger row wins, not the best one ever recorded",
|
||||||
|
pr["state"] == "skipped")
|
||||||
|
|
||||||
|
# A malformed ledger line is skipped, never guessed at.
|
||||||
|
lf = gr / "ledger.jsonl"
|
||||||
|
lf.write_text('{"test":"a","proof":"proven"}\nnot json at all\n\n'
|
||||||
|
'{"proof":"proven"}\n')
|
||||||
|
rows = read_ledger(lf)
|
||||||
|
check("malformed / test-less ledger lines are dropped, not guessed at",
|
||||||
|
len(rows) == 1 and rows[0]["test"] == "a")
|
||||||
|
|
||||||
if not ok:
|
if not ok:
|
||||||
return 1
|
return 1
|
||||||
sys.stderr.write("status.py selftest: PASS\n")
|
sys.stderr.write("status.py selftest: PASS\n")
|
||||||
@@ -865,6 +1458,10 @@ def main() -> int:
|
|||||||
help="exit 1 on defects this tool owns: registry/spec drift, "
|
help="exit 1 on defects this tool owns: registry/spec drift, "
|
||||||
"executable-annotation conflicts, or a safety-critical "
|
"executable-annotation conflicts, or a safety-critical "
|
||||||
"requirement whose only L3 test is a scaffold")
|
"requirement whose only L3 test is a scaffold")
|
||||||
|
ap.add_argument("--ledger", type=Path, default=DEFAULT_LEDGER,
|
||||||
|
help=f"committed proof-of-pass ledger written by lib/record_run.py "
|
||||||
|
f"(default {DEFAULT_LEDGER}). Absent or empty => every "
|
||||||
|
f"requirement stays `not-derivable`, as before the ledger existed")
|
||||||
ap.add_argument("--selftest", action="store_true", help="run the built-in self-test")
|
ap.add_argument("--selftest", action="store_true", help="run the built-in self-test")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
@@ -895,7 +1492,12 @@ def main() -> int:
|
|||||||
applied, nodes = reconciled_files(repo, entry)
|
applied, nodes = reconciled_files(repo, entry)
|
||||||
gitops_roots = {n["resolved"].split("/", 1)[0] for n in nodes if n["exists"]}
|
gitops_roots = {n["resolved"].split("/", 1)[0] for n in nodes if n["exists"]}
|
||||||
impl = scan_implementation(repo, tracked_files(repo))
|
impl = scan_implementation(repo, tracked_files(repo))
|
||||||
rep = build(repo, registry, tests, impl, applied, gitops_roots, spec_ids, args.env)
|
ledger_rows = read_ledger(args.ledger)
|
||||||
|
rep = build(repo, registry, tests, impl, applied, gitops_roots, spec_ids, args.env,
|
||||||
|
ledger_rows)
|
||||||
|
rep["ledger"] = {"path": os.path.relpath(args.ledger, REPO_ROOT),
|
||||||
|
"rows": len(ledger_rows),
|
||||||
|
"present": args.ledger.is_file()}
|
||||||
rep["flux_graph"] = nodes
|
rep["flux_graph"] = nodes
|
||||||
rep["gitops_roots"] = sorted(gitops_roots)
|
rep["gitops_roots"] = sorted(gitops_roots)
|
||||||
rep["reconciled_file_count"] = len(applied)
|
rep["reconciled_file_count"] = len(applied)
|
||||||
@@ -915,7 +1517,11 @@ def main() -> int:
|
|||||||
f"(+{s['wired_or_partial_and_testable'] - s['wired_and_testable']} partial) | "
|
f"(+{s['wired_or_partial_and_testable'] - s['wired_and_testable']} partial) | "
|
||||||
f"scaffold-only {s['verify_scaffold_only']} | "
|
f"scaffold-only {s['verify_scaffold_only']} | "
|
||||||
f"safety-critical w/o executable L3 "
|
f"safety-critical w/o executable L3 "
|
||||||
f"{s['safety_critical_without_executable_l3']} | PROVEN: not derivable")
|
f"{s['safety_critical_without_executable_l3']} | "
|
||||||
|
+ (f"PROVEN: {s['proven']} (stale {s['proof_stale']}, self-skipped "
|
||||||
|
f"{s['proof_skipped']}, failed {s['proof_failed']}, unknown "
|
||||||
|
f"{s['proof_unknown']}) from {s['ledger_rows']} ledger row(s)"
|
||||||
|
if s["proven_derivable"] else "PROVEN: not derivable (empty run ledger)"))
|
||||||
print(f"[status] wrote {args.out/'status.md'} and {args.out/'status.json'}")
|
print(f"[status] wrote {args.out/'status.md'} and {args.out/'status.json'}")
|
||||||
|
|
||||||
rc = 0
|
rc = 0
|
||||||
@@ -926,8 +1532,11 @@ def main() -> int:
|
|||||||
f"{h['registry_not_in_spec']} {h['spec_not_in_registry']}\n")
|
f"{h['registry_not_in_spec']} {h['spec_not_in_registry']}\n")
|
||||||
rc = 1
|
rc = 1
|
||||||
if h["annotation_conflicts"]:
|
if h["annotation_conflicts"]:
|
||||||
sys.stderr.write(f"[status] STRICT: tests annotated executable with TODO "
|
sys.stderr.write(
|
||||||
f"bodies: {h['annotation_conflicts']}\n")
|
"[status] STRICT: tests annotated executable that declare a placeholder "
|
||||||
|
"body: " + "; ".join(f"{k} ({v})" for k, v in
|
||||||
|
sorted(h["annotation_conflict_reasons"].items()))
|
||||||
|
+ "\n")
|
||||||
rc = 1
|
rc = 1
|
||||||
if rep["safety_critical_without_executable_l3"]:
|
if rep["safety_critical_without_executable_l3"]:
|
||||||
sys.stderr.write(f"[status] STRICT: safety-critical requirement(s) whose L3 "
|
sys.stderr.write(f"[status] STRICT: safety-critical requirement(s) whose L3 "
|
||||||
|
|||||||
+108
-16
@@ -14,12 +14,33 @@
|
|||||||
# ./run.sh --status # DERIVED STATUS report only (no cluster): per
|
# ./run.sh --status # DERIVED STATUS report only (no cluster): per
|
||||||
# # requirement, is it implemented, is it actually
|
# # requirement, is it implemented, is it actually
|
||||||
# # reconciled by Flux, is its test executable or a
|
# # reconciled by Flux, is its test executable or a
|
||||||
# # scaffold. Proof-of-pass is deliberately NOT
|
# # scaffold, and whether the committed run ledger
|
||||||
# # derived — see lib/status.py.
|
# # (runs/ledger.jsonl) actually proves it — see
|
||||||
|
# # lib/status.py. No ledger row => not-derivable.
|
||||||
# ./run.sh --lint # structural-validate test defs (no cluster)
|
# ./run.sh --lint # structural-validate test defs (no cluster)
|
||||||
# ./run.sh --strict-coverage # hygiene gate: fail on tag/annotation drift
|
# ./run.sh --strict-coverage # hygiene gate: fail on tag/annotation drift
|
||||||
# ./run.sh --requirements R-NET-01 # ALSO asserts those IDs are covered (merge gate)
|
# ./run.sh --requirements R-NET-01 # ALSO asserts those IDs are covered (merge gate)
|
||||||
# ./run.sh --require-all # phase-exit: every requirement must be covered
|
# ./run.sh --require-all # phase-exit: every requirement must be covered
|
||||||
|
# ./run.sh --record # RUN AND CAPTURE: tee the whole run to
|
||||||
|
# # runs/<UTC>-<short-sha>/run.log, copy the JUnit
|
||||||
|
# # beside it, and append one ledger row per
|
||||||
|
# # testcase to runs/ledger.jsonl (lib/record_run.py)
|
||||||
|
#
|
||||||
|
# --record — PROOF-OF-PASS, CAPTURED AT RUN TIME (why it is a separate flag)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Without it, nothing about a green run ever reaches repo state: _artifacts/ is gitignored
|
||||||
|
# and the root .gitignore kills *.junit.xml globally, which is why `--status` reports
|
||||||
|
# PROVEN 0 for all 51 requirements. `--record` writes the one tracked thing that fixes
|
||||||
|
# that — `conformance/runs/` (see conformance/.gitignore for the negations).
|
||||||
|
#
|
||||||
|
# RECORDING IS DELIBERATELY EXPLICIT. A run that silently records itself is how a bad run
|
||||||
|
# becomes a permanent false proof, so the plain `./run.sh` writes no ledger row at all.
|
||||||
|
#
|
||||||
|
# BOTH FILES ARE KEPT, and the LOG is the load-bearing one. The L2/L3 tests self-skip
|
||||||
|
# GREEN: when a precondition is missing they echo e.g. `DR_RESTORE_SKIPPED_OK` and exit 0,
|
||||||
|
# and Chainsaw records `passed` exactly as on a real run. Chainsaw's JUnit carries no
|
||||||
|
# stdout, so the sentinel token in run.log is the ONLY thing that separates proven from
|
||||||
|
# skipped. A JUnit committed on its own would record a skip as proof of R-STATE-03.
|
||||||
#
|
#
|
||||||
# Artifacts (always written under conformance/_artifacts/, gitignored):
|
# Artifacts (always written under conformance/_artifacts/, gitignored):
|
||||||
# _artifacts/report.junit.xml JUnit (Chainsaw output, or skipped-scaffold shim)
|
# _artifacts/report.junit.xml JUnit (Chainsaw output, or skipped-scaffold shim)
|
||||||
@@ -28,6 +49,11 @@
|
|||||||
# _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)
|
||||||
#
|
#
|
||||||
|
# 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>/report.junit.xml the JUnit for that same run
|
||||||
|
# runs/ledger.jsonl append-only, one row per testcase per run
|
||||||
|
#
|
||||||
# Phase 0: no cluster exists. The behavior tests are valid scaffolds that cannot
|
# Phase 0: no cluster exists. The behavior tests are valid scaffolds that cannot
|
||||||
# execute yet, so run.sh emits a *skipped* JUnit and a coverage report, and exits
|
# execute yet, so run.sh emits a *skipped* JUnit and a coverage report, and exits
|
||||||
# green — the harness is proven reproducible while later phases fill in behavior.
|
# green — the harness is proven reproducible while later phases fill in behavior.
|
||||||
@@ -47,27 +73,30 @@ STATUS_OUT="${ARTIFACTS}/status"
|
|||||||
JUNIT_OUT="${ARTIFACTS}/report.junit.xml"
|
JUNIT_OUT="${ARTIFACTS}/report.junit.xml"
|
||||||
PYTHON="${PYTHON:-python3}"
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
# Every argument as given, so `--record` can re-exec this script unchanged (minus itself)
|
||||||
|
# under `tee`. Captured BEFORE parsing so nothing is normalized away.
|
||||||
|
ORIG_ARGS=("$@")
|
||||||
|
|
||||||
LAYER="all"
|
LAYER="all"
|
||||||
REQS=""
|
REQS=""
|
||||||
MODE="run" # run | coverage-only | status | lint
|
MODE="run" # run | coverage-only | status | lint
|
||||||
STRICT_COVERAGE=0
|
STRICT_COVERAGE=0
|
||||||
REQUIRE_ALL=0
|
REQUIRE_ALL=0
|
||||||
FORCE_NO_CLUSTER=0
|
FORCE_NO_CLUSTER=0
|
||||||
|
RECORD=0
|
||||||
|
|
||||||
# Documented, out-of-scope coverage gaps exempt from the completeness gate: requirements
|
# Documented, out-of-scope coverage gaps exempt from the completeness gate: requirements
|
||||||
# owned by another workstream that WS-TEST does not yet scaffold. Listed here so the
|
# owned by another workstream that WS-TEST does not yet scaffold. Listed here so the
|
||||||
# exemption is explicit and reviewable, not silent.
|
# exemption is explicit and reviewable, not silent.
|
||||||
# R-ENV-02 (dev->prod promote) — owned by WS-INFRA/WS-GITOPS; unscaffolded (a GAP in
|
#
|
||||||
# coverage.md). The coarse path->requirement map keys
|
# EMPTY as of 2026-09-01. R-ENV-02 and R-GITOPS-01 were the last two entries — exempted
|
||||||
# clusters/* AND ci/* to it and the R-ENV-02 doc token
|
# because they were unscaffolded while the coarse path->requirement map keys clusters/*
|
||||||
# appears in platform/kustomization.yaml's header, so ANY
|
# and ci/* to them, so any overlay/CI edit would have false-blocked. They now have a real
|
||||||
# infra/overlay/CI edit (e.g. enabling a component, bumping
|
# verifying L0 test (tests/l0-static/flux-postbuild-substitution), which asserts the
|
||||||
# SPEC_PIN) would otherwise false-block on it.
|
# substitution mechanism that IS the "authored once, promoted" contract, so they are
|
||||||
# R-GITOPS-01 (state in git/Flux) — same: owned by WS-GITOPS, unscaffolded, and the ci/*
|
# covered rather than exempt and the completeness gate can hold them to it. That test
|
||||||
# path-map keys to it, so editing any ci/ file (SPEC_PIN
|
# names its own limits in its header: it proves the STATIC property (no manifest renders
|
||||||
# adoption included) would false-block.
|
# an undefined variable), not the live-cluster half of either requirement.
|
||||||
# These are visible GAPs, not hidden — removing them from this list is the right move the
|
|
||||||
# moment WS-TEST adds their verifying tests.
|
|
||||||
#
|
#
|
||||||
# FOLLOW-UP (WS-TEST) — HALF DONE. The debt was "make the matcher stop keying on doc-header
|
# FOLLOW-UP (WS-TEST) — HALF DONE. The debt was "make the matcher stop keying on doc-header
|
||||||
# tokens so real requirements need not be exempted". The CONTENT-SCAN half is now fixed in
|
# tokens so real requirements need not be exempted". The CONTENT-SCAN half is now fixed in
|
||||||
@@ -83,9 +112,11 @@ FORCE_NO_CLUSTER=0
|
|||||||
# R-STATE-02 (node-loss) WAS listed here (owned by WS-STORAGE, "delivered with Longhorn/infra")
|
# R-STATE-02 (node-loss) WAS listed here (owned by WS-STORAGE, "delivered with Longhorn/infra")
|
||||||
# but now has a real verifying L3 test — tests/l3-fault-injection/node-loss — so it is no
|
# but now has a real verifying L3 test — tests/l3-fault-injection/node-loss — so it is no
|
||||||
# longer a gap and has been removed from this list; the coverage report counts it as covered.
|
# longer a gap and has been removed from this list; the coverage report counts it as covered.
|
||||||
DOCUMENTED_GAPS="R-ENV-02 R-GITOPS-01"
|
DOCUMENTED_GAPS=""
|
||||||
|
|
||||||
usage() { sed -n '2,40p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
|
# Print the header block (line 2 through the closing `####` rule, which is dropped) as
|
||||||
|
# help. Bounded by the rule rather than a fixed line number so the header can grow.
|
||||||
|
usage() { sed -n '2,/^#####/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; }
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -97,6 +128,7 @@ while [ $# -gt 0 ]; do
|
|||||||
--strict-coverage) STRICT_COVERAGE=1; shift ;;
|
--strict-coverage) STRICT_COVERAGE=1; shift ;;
|
||||||
--require-all) REQUIRE_ALL=1; shift ;;
|
--require-all) REQUIRE_ALL=1; shift ;;
|
||||||
--no-cluster) FORCE_NO_CLUSTER=1; shift ;;
|
--no-cluster) FORCE_NO_CLUSTER=1; shift ;;
|
||||||
|
--record) RECORD=1; shift ;;
|
||||||
-h|--help) usage; exit 0 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
|
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
@@ -106,9 +138,69 @@ mkdir -p "${ARTIFACTS}" "${COVERAGE_OUT}" "${STATUS_OUT}"
|
|||||||
|
|
||||||
log() { printf '\033[1m[run.sh]\033[0m %s\n' "$*"; }
|
log() { printf '\033[1m[run.sh]\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
|
###############################################################################
|
||||||
|
# --record: capture this run as committed evidence, then hand it to the ledger.
|
||||||
|
#
|
||||||
|
# Implemented as a ONE-LEVEL re-exec of this same script under `tee`, so the run itself is
|
||||||
|
# byte-for-byte the ordinary run (same code path, same artifacts) and the log is the exact
|
||||||
|
# operator-visible output — no second, differently-behaving "recording mode" to drift.
|
||||||
|
# CONF_RECORD_CHILD guards against recursion.
|
||||||
|
#
|
||||||
|
# The recorder runs REGARDLESS of the run's exit status: a red run must be recorded as red
|
||||||
|
# (that is evidence too), and run.sh still exits with the child's real rc, so no gate is
|
||||||
|
# softened by recording. `${PIPESTATUS[0]}` is what carries that rc past `tee`.
|
||||||
|
###############################################################################
|
||||||
|
if [ "${RECORD}" = 1 ] && [ -z "${CONF_RECORD_CHILD:-}" ]; then
|
||||||
|
case "${MODE}" in
|
||||||
|
run) ;;
|
||||||
|
# --record is about EVIDENCE OF A RUN. --coverage-only/--status/--lint execute no
|
||||||
|
# test, so a ledger row from one of them would be a row about nothing.
|
||||||
|
*) echo "--record captures a TEST RUN; --${MODE} executes none." >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
RUN_SHA="$(git -C "${CONF_ROOT}" rev-parse --short HEAD 2>/dev/null || echo nogit)"
|
||||||
|
RUN_ID="$(date -u +%Y%m%dT%H%M%SZ)-${RUN_SHA}"
|
||||||
|
RUN_DIR="${CONF_ROOT}/runs/${RUN_ID}"
|
||||||
|
mkdir -p "${RUN_DIR}"
|
||||||
|
# Drop any JUnit left behind by a PREVIOUS run before starting, so this run dir can only
|
||||||
|
# ever capture a report THIS run produced. Copying a stale artifact would pair someone
|
||||||
|
# else's result with this run's log — a fabricated pairing, which is precisely the class
|
||||||
|
# of false proof the ledger exists to prevent.
|
||||||
|
rm -f "${JUNIT_OUT}"
|
||||||
|
# Strip --record from the argv handed to the child; everything else passes through.
|
||||||
|
CHILD_ARGS=()
|
||||||
|
for a in ${ORIG_ARGS[@]+"${ORIG_ARGS[@]}"}; do
|
||||||
|
[ "$a" = "--record" ] || CHILD_ARGS+=("$a")
|
||||||
|
done
|
||||||
|
log "RECORD: capturing this run to runs/${RUN_ID}/ (run.log + report.junit.xml)"
|
||||||
|
set +e
|
||||||
|
CONF_RECORD_CHILD=1 "${BASH_SOURCE[0]}" ${CHILD_ARGS[@]+"${CHILD_ARGS[@]}"} 2>&1 \
|
||||||
|
| tee "${RUN_DIR}/run.log"
|
||||||
|
RUN_RC=${PIPESTATUS[0]}
|
||||||
|
set -e
|
||||||
|
if [ -f "${JUNIT_OUT}" ]; then
|
||||||
|
cp "${JUNIT_OUT}" "${RUN_DIR}/report.junit.xml"
|
||||||
|
else
|
||||||
|
log "RECORD: no ${JUNIT_OUT} was produced — the ledger step will SKIP (never green)."
|
||||||
|
fi
|
||||||
|
# Explicit, separate step: read the JUnit + the log and append one row per testcase.
|
||||||
|
# rc 3 = inputs absent (nothing recorded, not green); it must not mask the run's own rc.
|
||||||
|
# CONF_LEDGER exists so the harness's own tests can exercise this path without writing
|
||||||
|
# to the real ledger; operators should never set it.
|
||||||
|
"${PYTHON}" "${CONF_ROOT}/lib/record_run.py" \
|
||||||
|
--run-dir "${RUN_DIR}" --env "${CONF_ENV:-dev}" \
|
||||||
|
--ledger "${CONF_LEDGER:-${CONF_ROOT}/runs/ledger.jsonl}" || true
|
||||||
|
log "RECORD: run rc=${RUN_RC}. Commit conformance/runs/${RUN_ID}/ WITH its run.log and"
|
||||||
|
log "RECORD: conformance/runs/ledger.jsonl — an uncommitted ledger proves nothing."
|
||||||
|
exit "${RUN_RC}"
|
||||||
|
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() {
|
||||||
local flags=(--allow-gap "${DOCUMENTED_GAPS}")
|
# Pass --allow-gap only when there IS an exemption: an empty DOCUMENTED_GAPS means no
|
||||||
|
# requirement is exempt, and handing the flag an empty string would be a silent no-op
|
||||||
|
# that reads like an exemption in the CLI log.
|
||||||
|
local flags=()
|
||||||
|
[ -n "${DOCUMENTED_GAPS}" ] && flags+=(--allow-gap "${DOCUMENTED_GAPS}")
|
||||||
[ "${STRICT_COVERAGE}" = 1 ] && flags+=(--strict)
|
[ "${STRICT_COVERAGE}" = 1 ] && flags+=(--strict)
|
||||||
# A tagged subset run is also the merge gate: the touched requirements MUST be covered.
|
# A tagged subset run is also the merge gate: the touched requirements MUST be covered.
|
||||||
[ -n "${REQS}" ] && flags+=(--require "${REQS}")
|
[ -n "${REQS}" ] && flags+=(--require "${REQS}")
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<testsuites name="chainsaw-report" tests="1" failures="0" errors="0" time="0.000000"><testsuite name="/work/conformance/tests/l3-fault-injection/node-loss" tests="1" failures="0" errors="0" id="0" time="">
|
||||||
|
<testcase name="node-loss" classname="" time="131.090121" />
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
[1m[run.sh][0m Coverage report first (always)
|
||||||
|
[coverage] 42/51 requirements have a scaffolded test (82.4%); 9 gap(s), 0 safety-critical gap(s).
|
||||||
|
[coverage] wrote /work/conformance/_artifacts/coverage/coverage.md and /work/conformance/_artifacts/coverage/coverage.json
|
||||||
|
[1m[run.sh][0m No L0/L1 offline checks match the selection (layer=l3 reqs='R-STATE-02').
|
||||||
|
[1m[run.sh][0m LIGHT pass: no tests selected.
|
||||||
|
[1m[run.sh][0m HEAVY pass: 1 test(s) SERIAL (parallel=1) — node-loss
|
||||||
|
Version: 0.2.12
|
||||||
|
Loading config (/work/conformance/config/chainsaw-config.yaml)...
|
||||||
|
- Using test file: chainsaw-test
|
||||||
|
- TestDirs [/work/conformance/tests/l3-fault-injection/node-loss]
|
||||||
|
- SkipDelete false
|
||||||
|
- FailFast false
|
||||||
|
- ReportFormat 'JUNIT-TEST'
|
||||||
|
- ReportName 'chainsaw-report-heavy'
|
||||||
|
- ReportPath '/work/conformance/_artifacts'
|
||||||
|
- Namespace ''
|
||||||
|
- FullName false
|
||||||
|
- IncludeTestRegex ''
|
||||||
|
- ExcludeTestRegex ''
|
||||||
|
- ApplyTimeout 1m30s
|
||||||
|
- AssertTimeout 3m0s
|
||||||
|
- CleanupTimeout 2m0s
|
||||||
|
- DeleteTimeout 2m0s
|
||||||
|
- ErrorTimeout 1m0s
|
||||||
|
- ExecTimeout 3m0s
|
||||||
|
- DeletionPropagationPolicy Background
|
||||||
|
- Parallel 1
|
||||||
|
- Template true
|
||||||
|
- NoCluster false
|
||||||
|
- PauseOnFailure false
|
||||||
|
Loading tests...
|
||||||
|
- node-loss (/work/conformance/tests/l3-fault-injection/node-loss)
|
||||||
|
Loading values...
|
||||||
|
Running tests...
|
||||||
|
=== RUN chainsaw
|
||||||
|
=== PAUSE chainsaw
|
||||||
|
=== CONT chainsaw
|
||||||
|
=== RUN chainsaw/node-loss
|
||||||
|
=== PAUSE chainsaw/node-loss
|
||||||
|
=== CONT chainsaw/node-loss
|
||||||
|
l.go:52: | 23:51:18 | [32;1mnode-loss[0;22m | [32;1m@chainsaw [0;22m | [32;1mCREATE[0;22m | [32;1mOK[0;22m | v1/Namespace @ chainsaw-fond-marlin
|
||||||
|
l.go:52: | 23:51:18 | [36;1mnode-loss[0;22m | [36;1mvolume-stays-available-across-node-loss[0;22m | [36;1mTRY[0;22m | [36;1mBEGIN[0;22m |
|
||||||
|
l.go:52: | 23:51:18 | [36;1mnode-loss[0;22m | [36;1mvolume-stays-available-across-node-loss[0;22m | [36;1mCMD[0;22m | [36;1mRUN[0;22m |
|
||||||
|
=== COMMAND
|
||||||
|
/usr/bin/sh -c set -euo pipefail
|
||||||
|
NS="$NAMESPACE"
|
||||||
|
|
||||||
|
# -- Gate 1: skip cleanly (green) if the longhorn tier is not present yet. --
|
||||||
|
if ! kubectl get storageclass longhorn >/dev/null 2>&1; then
|
||||||
|
echo "SKIP: 'longhorn' StorageClass absent — node-loss not applicable on this cluster yet"
|
||||||
|
echo NODE_LOSS_SKIPPED_OK
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- Gate 2: a 3-replica volume + single-node-loss headroom needs >= 3 Ready nodes. --
|
||||||
|
READY="$(kubectl get nodes \
|
||||||
|
-o jsonpath='{range .items[*]}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}' \
|
||||||
|
| grep -c '^True$' || true)"
|
||||||
|
echo "ready nodes: ${READY}"
|
||||||
|
if [ "${READY:-0}" -lt 3 ]; then
|
||||||
|
echo "SKIP: only ${READY:-0} Ready node(s) (<3) — node-loss resilience not meaningful yet"
|
||||||
|
echo NODE_LOSS_SKIPPED_OK
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
CANARY="node-loss-canary-$(date +%s%N)"
|
||||||
|
|
||||||
|
# -- GIVEN: a Longhorn PVC (platform default SC = 3 replicas across nodes) + a
|
||||||
|
# writer pod that writes the canary and flushes it to the replicated volume. --
|
||||||
|
kubectl -n "$NS" apply -f - <<EOF
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: survivor
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
storageClassName: longhorn
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
apply_pod() {
|
||||||
|
kubectl -n "$NS" apply -f - <<EOF
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: $1
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
image: busybox:1.36.1
|
||||||
|
command: ["sh", "-c", "sleep 3600"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: survivor
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
apply_pod writer
|
||||||
|
kubectl -n "$NS" wait --for=condition=Ready pod/writer --timeout=240s
|
||||||
|
printf '%s' "$CANARY" | kubectl -n "$NS" exec -i writer -- sh -c 'cat > /data/canary && sync'
|
||||||
|
echo "wrote canary: $(kubectl -n "$NS" exec writer -- cat /data/canary)"
|
||||||
|
|
||||||
|
# The victim node is the one the attached volume currently lives on.
|
||||||
|
VICTIM="$(kubectl -n "$NS" get pod writer -o jsonpath='{.spec.nodeName}')"
|
||||||
|
[ -n "$VICTIM" ] || { echo "FAIL: could not determine writer's node" >&2; exit 1; }
|
||||||
|
echo "victim node (currently hosts the attached volume): $VICTIM"
|
||||||
|
|
||||||
|
# Best-effort precondition log: if the Longhorn Volume CR is readable, show the
|
||||||
|
# volume is genuinely 3-replica/healthy before we take a node. Non-fatal (the
|
||||||
|
# runner is least-privilege and may not have longhorn.io read rights) — the
|
||||||
|
# data-availability assertion below is the authoritative proof, not this.
|
||||||
|
PVNAME="$(kubectl -n "$NS" get pvc survivor -o jsonpath='{.spec.volumeName}' 2>/dev/null || true)"
|
||||||
|
if [ -n "$PVNAME" ]; then
|
||||||
|
kubectl -n longhorn-system get volumes.longhorn.io "$PVNAME" \
|
||||||
|
-o jsonpath='pre-fault robustness={.status.robustness} replicas={.spec.numberOfReplicas}{"\n"}' \
|
||||||
|
2>/dev/null || echo "(Longhorn Volume CR not readable — continuing; data read is the proof)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# -- WHEN: FAULT INJECTION — remove the victim node FROM SERVICE. --
|
||||||
|
# Label it with THIS test's unique namespace so `finally` can always find and
|
||||||
|
# uncordon exactly this node; then cordon it; then evict ONLY our own writer
|
||||||
|
# pod, forcing the volume to detach from the victim. (No `drain` — unrelated
|
||||||
|
# pods on the node are never touched.)
|
||||||
|
kubectl label node "$VICTIM" "conformance.home.k3s/cordoned-by=$NS" --overwrite
|
||||||
|
kubectl cordon "$VICTIM"
|
||||||
|
echo "cordoned + labelled victim node: $VICTIM"
|
||||||
|
kubectl -n "$NS" delete pod writer --wait=true --timeout=240s
|
||||||
|
echo "evicted writer off the victim node; volume detached from $VICTIM"
|
||||||
|
|
||||||
|
# -- THEN: a fresh workload re-binds the SAME volume. The victim is unschedulable,
|
||||||
|
# so the scheduler MUST place the reader on a SURVIVING node; Longhorn re-attaches
|
||||||
|
# the volume there from its replicas on the other nodes. --
|
||||||
|
apply_pod reader
|
||||||
|
kubectl -n "$NS" wait --for=condition=Ready pod/reader --timeout=360s
|
||||||
|
READER_NODE="$(kubectl -n "$NS" get pod reader -o jsonpath='{.spec.nodeName}')"
|
||||||
|
echo "reader scheduled on surviving node: $READER_NODE (victim was $VICTIM)"
|
||||||
|
if [ "$READER_NODE" = "$VICTIM" ]; then
|
||||||
|
echo "FAIL: reader landed back on the cordoned victim node — node was not removed from service" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
GOT="$(kubectl -n "$NS" exec reader -- cat /data/canary)"
|
||||||
|
echo "recovered='$GOT' expected='$CANARY'"
|
||||||
|
[ "$GOT" = "$CANARY" ] || { echo "FAIL: data not available after node loss" >&2; exit 1; }
|
||||||
|
echo "volume available + data intact from a surviving node"
|
||||||
|
|
||||||
|
# Best-effort reconvergence log: if the Longhorn Volume CR is readable, wait
|
||||||
|
# (bounded) for robustness to settle back to available (healthy|degraded — both
|
||||||
|
# serve I/O; a healthy 3-replica volume rebuilds a replica once headroom exists).
|
||||||
|
# Non-fatal for the same least-privilege reason as the pre-fault log.
|
||||||
|
if [ -n "$PVNAME" ]; then
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
ROB="$(kubectl -n longhorn-system get volumes.longhorn.io "$PVNAME" \
|
||||||
|
-o jsonpath='{.status.robustness}' 2>/dev/null || true)"
|
||||||
|
[ -z "$ROB" ] && { echo "(Longhorn Volume CR not readable — skipping reconvergence log)"; break; }
|
||||||
|
echo "post-fault robustness[$i]=$ROB"
|
||||||
|
case "$ROB" in healthy|degraded) break ;; esac
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo NODE_LOSS_VOLUME_AVAILABLE_OK
|
||||||
|
l.go:52: | 23:52:42 | [36;1mnode-loss[0;22m | [36;1mvolume-stays-available-across-node-loss[0;22m | [36;1mSCRIPT[0;22m | [36;1mLOG[0;22m |
|
||||||
|
=== STDOUT
|
||||||
|
ready nodes: 3
|
||||||
|
persistentvolumeclaim/survivor created
|
||||||
|
pod/writer created
|
||||||
|
pod/writer condition met
|
||||||
|
wrote canary: node-loss-canary-1788306678898809444
|
||||||
|
victim node (currently hosts the attached volume): k3s-dev-2
|
||||||
|
pre-fault robustness=healthy replicas=3
|
||||||
|
node/k3s-dev-2 labeled
|
||||||
|
node/k3s-dev-2 cordoned
|
||||||
|
cordoned + labelled victim node: k3s-dev-2
|
||||||
|
pod "writer" deleted from chainsaw-fond-marlin namespace
|
||||||
|
evicted writer off the victim node; volume detached from k3s-dev-2
|
||||||
|
pod/reader created
|
||||||
|
pod/reader condition met
|
||||||
|
reader scheduled on surviving node: k3s-dev-1 (victim was k3s-dev-2)
|
||||||
|
recovered='node-loss-canary-1788306678898809444' expected='node-loss-canary-1788306678898809444'
|
||||||
|
volume available + data intact from a surviving node
|
||||||
|
post-fault robustness[1]=healthy
|
||||||
|
NODE_LOSS_VOLUME_AVAILABLE_OK
|
||||||
|
l.go:52: | 23:52:42 | [32;1mnode-loss[0;22m | [32;1mvolume-stays-available-across-node-loss[0;22m | [32;1mSCRIPT[0;22m | [32;1mDONE[0;22m |
|
||||||
|
l.go:52: | 23:52:42 | [36;1mnode-loss[0;22m | [36;1mvolume-stays-available-across-node-loss[0;22m | [36;1mTRY[0;22m | [36;1mEND[0;22m |
|
||||||
|
l.go:52: | 23:52:42 | [36;1mnode-loss[0;22m | [36;1mvolume-stays-available-across-node-loss[0;22m | [36;1mFINALLY[0;22m | [36;1mBEGIN[0;22m |
|
||||||
|
l.go:52: | 23:52:42 | [36;1mnode-loss[0;22m | [36;1mvolume-stays-available-across-node-loss[0;22m | [36;1mCMD[0;22m | [36;1mRUN[0;22m |
|
||||||
|
=== COMMAND
|
||||||
|
/usr/bin/sh -c set -uo pipefail
|
||||||
|
for n in $(kubectl get nodes -l "conformance.home.k3s/cordoned-by=$NAMESPACE" -o name 2>/dev/null); do
|
||||||
|
kubectl uncordon "${n#node/}" || true
|
||||||
|
kubectl label "$n" "conformance.home.k3s/cordoned-by-" || true
|
||||||
|
echo "restored node to service: ${n#node/}"
|
||||||
|
done
|
||||||
|
l.go:52: | 23:52:43 | [36;1mnode-loss[0;22m | [36;1mvolume-stays-available-across-node-loss[0;22m | [36;1mSCRIPT[0;22m | [36;1mLOG[0;22m |
|
||||||
|
=== STDOUT
|
||||||
|
node/k3s-dev-2 uncordoned
|
||||||
|
node/k3s-dev-2 unlabeled
|
||||||
|
restored node to service: k3s-dev-2
|
||||||
|
l.go:52: | 23:52:43 | [32;1mnode-loss[0;22m | [32;1mvolume-stays-available-across-node-loss[0;22m | [32;1mSCRIPT[0;22m | [32;1mDONE[0;22m |
|
||||||
|
l.go:52: | 23:52:43 | [36;1mnode-loss[0;22m | [36;1mvolume-stays-available-across-node-loss[0;22m | [36;1mCMD[0;22m | [36;1mRUN[0;22m |
|
||||||
|
=== COMMAND
|
||||||
|
/usr/bin/sh -c kubectl -n "$NAMESPACE" delete pod writer reader --ignore-not-found --wait=false || true
|
||||||
|
kubectl -n "$NAMESPACE" delete pvc survivor --ignore-not-found --wait=false || true
|
||||||
|
echo "cleanup best-effort; chainsaw namespace teardown finalizes"
|
||||||
|
l.go:52: | 23:52:43 | [36;1mnode-loss[0;22m | [36;1mvolume-stays-available-across-node-loss[0;22m | [36;1mSCRIPT[0;22m | [36;1mLOG[0;22m |
|
||||||
|
=== STDOUT
|
||||||
|
pod "reader" deleted from chainsaw-fond-marlin namespace
|
||||||
|
persistentvolumeclaim "survivor" deleted from chainsaw-fond-marlin namespace
|
||||||
|
cleanup best-effort; chainsaw namespace teardown finalizes
|
||||||
|
l.go:52: | 23:52:43 | [32;1mnode-loss[0;22m | [32;1mvolume-stays-available-across-node-loss[0;22m | [32;1mSCRIPT[0;22m | [32;1mDONE[0;22m |
|
||||||
|
l.go:52: | 23:52:43 | [36;1mnode-loss[0;22m | [36;1mvolume-stays-available-across-node-loss[0;22m | [36;1mFINALLY[0;22m | [36;1mEND[0;22m |
|
||||||
|
l.go:52: | 23:52:43 | [36;1mnode-loss[0;22m | [36;1m@chainsaw [0;22m | [36;1mCLEANUP[0;22m | [36;1mBEGIN[0;22m |
|
||||||
|
l.go:52: | 23:52:43 | [32;1mnode-loss[0;22m | [32;1m@chainsaw [0;22m | [32;1mDELETE[0;22m | [32;1mOK[0;22m | v1/Namespace @ chainsaw-fond-marlin
|
||||||
|
l.go:52: | 23:53:29 | [36;1mnode-loss[0;22m | [36;1m@chainsaw [0;22m | [36;1mCLEANUP[0;22m | [36;1mEND[0;22m |
|
||||||
|
--- PASS: chainsaw (0.00s)
|
||||||
|
--- PASS: chainsaw/node-loss (131.09s)
|
||||||
|
PASS
|
||||||
|
Tests Summary...
|
||||||
|
- Passed tests 1
|
||||||
|
- Failed tests 0
|
||||||
|
- Skipped tests 0
|
||||||
|
Done.
|
||||||
|
merge_junit: merged 1 report(s) -> /work/conformance/_artifacts/report.junit.xml (tests=1 failures=0 errors=0 skipped=0)
|
||||||
|
[1m[run.sh][0m Done. JUnit: /work/conformance/_artifacts/report.junit.xml | Coverage: /work/conformance/_artifacts/coverage/coverage.md
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<testsuites name="chainsaw-report" tests="1" failures="0" errors="0" time="0.000000"><testsuite name="/work/conformance/tests/l3-fault-injection/dr-restore" tests="1" failures="0" errors="0" id="0" time="">
|
||||||
|
<testcase name="dr-restore" classname="" time="149.053701" />
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
[1m[run.sh][0m Coverage report first (always)
|
||||||
|
[coverage] 42/51 requirements have a scaffolded test (82.4%); 9 gap(s), 0 safety-critical gap(s).
|
||||||
|
[coverage] wrote /work/conformance/_artifacts/coverage/coverage.md and /work/conformance/_artifacts/coverage/coverage.json
|
||||||
|
[1m[run.sh][0m No L0/L1 offline checks match the selection (layer=l3 reqs='R-STATE-03').
|
||||||
|
[1m[run.sh][0m LIGHT pass: no tests selected.
|
||||||
|
[1m[run.sh][0m HEAVY pass: 1 test(s) SERIAL (parallel=1) — dr-restore
|
||||||
|
Version: 0.2.12
|
||||||
|
Loading config (/work/conformance/config/chainsaw-config.yaml)...
|
||||||
|
- Using test file: chainsaw-test
|
||||||
|
- TestDirs [/work/conformance/tests/l3-fault-injection/dr-restore]
|
||||||
|
- SkipDelete false
|
||||||
|
- FailFast false
|
||||||
|
- ReportFormat 'JUNIT-TEST'
|
||||||
|
- ReportName 'chainsaw-report-heavy'
|
||||||
|
- ReportPath '/work/conformance/_artifacts'
|
||||||
|
- Namespace ''
|
||||||
|
- FullName false
|
||||||
|
- IncludeTestRegex ''
|
||||||
|
- ExcludeTestRegex ''
|
||||||
|
- ApplyTimeout 1m30s
|
||||||
|
- AssertTimeout 3m0s
|
||||||
|
- CleanupTimeout 2m0s
|
||||||
|
- DeleteTimeout 2m0s
|
||||||
|
- ErrorTimeout 1m0s
|
||||||
|
- ExecTimeout 3m0s
|
||||||
|
- DeletionPropagationPolicy Background
|
||||||
|
- Parallel 1
|
||||||
|
- Template true
|
||||||
|
- NoCluster false
|
||||||
|
- PauseOnFailure false
|
||||||
|
Loading tests...
|
||||||
|
- dr-restore (/work/conformance/tests/l3-fault-injection/dr-restore)
|
||||||
|
Loading values...
|
||||||
|
Running tests...
|
||||||
|
=== RUN chainsaw
|
||||||
|
=== PAUSE chainsaw
|
||||||
|
=== CONT chainsaw
|
||||||
|
=== RUN chainsaw/dr-restore
|
||||||
|
=== PAUSE chainsaw/dr-restore
|
||||||
|
=== CONT chainsaw/dr-restore
|
||||||
|
l.go:52: | 00:19:07 | [32;1mdr-restore[0;22m | [32;1m@chainsaw [0;22m | [32;1mCREATE[0;22m | [32;1mOK[0;22m | v1/Namespace @ chainsaw-cunning-hen
|
||||||
|
l.go:52: | 00:19:07 | [36;1mdr-restore[0;22m | [36;1massert-backup-target-is-off-cluster [0;22m | [36;1mTRY[0;22m | [36;1mBEGIN[0;22m |
|
||||||
|
l.go:52: | 00:19:07 | [36;1mdr-restore[0;22m | [36;1massert-backup-target-is-off-cluster [0;22m | [36;1mCMD[0;22m | [36;1mRUN[0;22m |
|
||||||
|
=== COMMAND
|
||||||
|
/usr/bin/sh -c set -eu
|
||||||
|
|
||||||
|
# ---- helpers -------------------------------------------------------
|
||||||
|
# Classify a kubectl failure: an RBAC denial is a HARNESS DEFECT and must
|
||||||
|
# fail loudly; an absent API is a genuine precondition gap and may skip.
|
||||||
|
die_or_skip() {
|
||||||
|
case "$2" in
|
||||||
|
*orbidden*|*Unauthorized*|*cannot\ list*|*cannot\ get*)
|
||||||
|
echo "FAIL: conformance runner lacks RBAC for $1 -- fix conformance/incluster/rbac.yaml" >&2
|
||||||
|
echo "$2" >&2
|
||||||
|
exit 1 ;;
|
||||||
|
*)
|
||||||
|
echo "SKIP: $1 not available on this cluster -- $2"
|
||||||
|
echo DR_RESTORE_SKIPPED_OK
|
||||||
|
exit 0 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
ip2int() {
|
||||||
|
_q="$(echo "$1" | tr '.' ' ')"
|
||||||
|
set -- $_q
|
||||||
|
[ $# -eq 4 ] || { echo -1; return 0; }
|
||||||
|
echo $(( ($1 * 16777216) + ($2 * 65536) + ($3 * 256) + $4 ))
|
||||||
|
}
|
||||||
|
|
||||||
|
# in_cidr <ip> <a.b.c.d/len> -> 0 when the ip is inside the prefix
|
||||||
|
in_cidr() {
|
||||||
|
case "$2" in *"/"*) ;; *) return 1 ;; esac
|
||||||
|
_net="${2%/*}"; _len="${2#*/}"
|
||||||
|
case "$_len" in ''|*[!0-9]*) return 1 ;; esac
|
||||||
|
[ "$_len" -ge 0 ] && [ "$_len" -le 32 ] || return 1
|
||||||
|
_i="$(ip2int "$1")"; _n="$(ip2int "$_net")"
|
||||||
|
[ "$_i" -ge 0 ] && [ "$_n" -ge 0 ] || return 1
|
||||||
|
[ "$_len" -eq 0 ] && return 0
|
||||||
|
_d=1; _s=0
|
||||||
|
while [ "$_s" -lt $((32 - _len)) ]; do _d=$((_d * 2)); _s=$((_s + 1)); done
|
||||||
|
[ $(( _i / _d )) -eq $(( _n / _d )) ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---- Gate 1: the replicated storage tier the DR-critical data lives on --
|
||||||
|
if ! kubectl get storageclass longhorn >/dev/null 2>&1; then
|
||||||
|
echo "SKIP: 'longhorn' StorageClass absent -- no DR-critical data tier on this cluster yet"
|
||||||
|
echo DR_RESTORE_SKIPPED_OK
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Gate 2: the Velero API + a configured BackupStorageLocation --------
|
||||||
|
BSL_LINES="$(kubectl get backupstoragelocations.velero.io -A \
|
||||||
|
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{" "}{.status.phase}{" "}{.spec.provider}{" "}{.spec.default}{" "}{.spec.config.s3Url}{" "}{.spec.objectStorage.bucket}{"\n"}{end}' \
|
||||||
|
2>&1)" || die_or_skip "velero.io/backupstoragelocations" "$BSL_LINES"
|
||||||
|
if [ -z "$(echo "$BSL_LINES" | tr -d '[:space:]')" ]; then
|
||||||
|
echo "SKIP: Velero API present but NO BackupStorageLocation is configured -- no off-cluster backup target yet"
|
||||||
|
echo DR_RESTORE_SKIPPED_OK
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "backup storage locations:"
|
||||||
|
echo "$BSL_LINES"
|
||||||
|
|
||||||
|
VNS="$(echo "$BSL_LINES" | head -1 | cut -d' ' -f1)"
|
||||||
|
|
||||||
|
# ---- Gate 3: Velero server + node-agent (kopia FSB) actually ready -------
|
||||||
|
# File System Backup is the volume-DATA path (platform/velero/helmrelease.yaml:
|
||||||
|
# deployNodeAgent=true, uploaderType=kopia, snapshotsEnabled=false). Without a
|
||||||
|
# ready node-agent no volume data can be captured, so the drill is not runnable.
|
||||||
|
VREADY=0; NAREADY=0
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
VREADY="$(kubectl -n "$VNS" get deploy velero -o jsonpath='{.status.readyReplicas}' 2>/dev/null || true)"
|
||||||
|
NAREADY="$(kubectl -n "$VNS" get daemonset node-agent -o jsonpath='{.status.numberReady}' 2>/dev/null || true)"
|
||||||
|
echo "poll $i: velero readyReplicas=${VREADY:-0} node-agent numberReady=${NAREADY:-0}"
|
||||||
|
if [ "${VREADY:-0}" -ge 1 ] && [ "${NAREADY:-0}" -ge 1 ]; then break; fi
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
if [ "${VREADY:-0}" -lt 1 ] || [ "${NAREADY:-0}" -lt 1 ]; then
|
||||||
|
echo "SKIP: Velero server and/or its node-agent DaemonSet are not ready in ns '$VNS' -- backup/restore path not available"
|
||||||
|
echo DR_RESTORE_SKIPPED_OK
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---- Pick the BSL this drill will use: prefer the default, and it MUST be
|
||||||
|
# Available. A CONFIGURED-BUT-UNAVAILABLE target is NOT a precondition
|
||||||
|
# gap -- it is a live R-STATE-03 defect, so it FAILS (after a bounded
|
||||||
|
# wait for first-validation on a cold start), it does not skip. --------
|
||||||
|
BSL_NS=""; BSL_NAME=""; BSL_URL=""; BSL_BUCKET=""
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
BSL_LINES="$(kubectl get backupstoragelocations.velero.io -A \
|
||||||
|
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{" "}{.status.phase}{" "}{.spec.provider}{" "}{.spec.default}{" "}{.spec.config.s3Url}{" "}{.spec.objectStorage.bucket}{"\n"}{end}' 2>/dev/null || true)"
|
||||||
|
CAND="$(echo "$BSL_LINES" | grep ' Available ' | grep ' true ' | head -1 || true)"
|
||||||
|
[ -n "$CAND" ] || CAND="$(echo "$BSL_LINES" | grep ' Available ' | head -1 || true)"
|
||||||
|
if [ -n "$CAND" ]; then
|
||||||
|
BSL_NS="$(echo "$CAND" | cut -d' ' -f1)"
|
||||||
|
BSL_NAME="$(echo "$CAND" | cut -d' ' -f2)"
|
||||||
|
BSL_URL="$(echo "$CAND" | cut -d' ' -f6)"
|
||||||
|
BSL_BUCKET="$(echo "$CAND" | cut -d' ' -f7)"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "poll $i: no Available BackupStorageLocation yet"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
if [ -z "$BSL_NAME" ]; then
|
||||||
|
echo "FAIL: a BackupStorageLocation is configured but NONE reached phase 'Available'." >&2
|
||||||
|
echo " The off-cluster backup target is unreachable -- R-STATE-03 is BROKEN, not merely untested." >&2
|
||||||
|
echo "$BSL_LINES" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "using BSL ${BSL_NS}/${BSL_NAME} bucket=${BSL_BUCKET} endpoint=${BSL_URL}"
|
||||||
|
|
||||||
|
# ---- OFF-CLUSTER PROOF (1): an endpoint we can evaluate ------------------
|
||||||
|
if [ -z "$BSL_URL" ]; then
|
||||||
|
echo "FAIL: BackupStorageLocation ${BSL_NS}/${BSL_NAME} exposes no spec.config.s3Url;" >&2
|
||||||
|
echo " the OFF-CLUSTER property of the backup target cannot be established, so this" >&2
|
||||||
|
echo " test refuses to claim it. (Extend this check for a non-S3 provider.)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
HOST="${BSL_URL#*://}" # strip scheme
|
||||||
|
HOST="${HOST%%/*}" # strip path
|
||||||
|
HOST="${HOST%%:*}" # strip port
|
||||||
|
echo "backup endpoint host: $HOST"
|
||||||
|
|
||||||
|
case "$HOST" in
|
||||||
|
localhost|*.svc|*.svc.cluster.local|*.cluster.local)
|
||||||
|
echo "FAIL: backup endpoint '$HOST' is a CLUSTER-INTERNAL name -- the backup target is IN the cluster." >&2
|
||||||
|
echo " An in-cluster backup store cannot satisfy R-STATE-03 (off-cluster backup)." >&2
|
||||||
|
exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Resolve to an IPv4 address. If it cannot be resolved we CANNOT establish the
|
||||||
|
# off-cluster property -- that is a failure, never a silent pass.
|
||||||
|
case "$HOST" in
|
||||||
|
*[!0-9.]*)
|
||||||
|
# Endpoint given as a name. Try the resolvers that may exist in the
|
||||||
|
# runner image, in order; if none is available or none resolves, the
|
||||||
|
# test FAILS below rather than guessing.
|
||||||
|
EPIP="$(getent ahostsv4 "$HOST" 2>/dev/null | head -1 | cut -d' ' -f1 || true)"
|
||||||
|
[ -n "$EPIP" ] || EPIP="$(getent hosts "$HOST" 2>/dev/null | head -1 | cut -d' ' -f1 || true)"
|
||||||
|
[ -n "$EPIP" ] || EPIP="$(nslookup "$HOST" 2>/dev/null | grep -A2 '^Name:' | grep '^Address' | head -1 | sed 's/.*: *//' || true)" ;;
|
||||||
|
*) EPIP="$HOST" ;;
|
||||||
|
esac
|
||||||
|
if [ -z "$EPIP" ]; then
|
||||||
|
echo "FAIL: could not resolve backup endpoint host '$HOST' to an IPv4 address;" >&2
|
||||||
|
echo " the OFF-CLUSTER property cannot be asserted, so this test does not pass." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "backup endpoint resolves to: $EPIP"
|
||||||
|
|
||||||
|
# ---- OFF-CLUSTER PROOF (2): the endpoint is outside every cluster address --
|
||||||
|
case "$EPIP" in 127.*|0.0.0.0) echo "FAIL: backup endpoint $EPIP is loopback -- not off-cluster" >&2; exit 1 ;; esac
|
||||||
|
|
||||||
|
NODE_IPS="$(kubectl get nodes -o jsonpath='{range .items[*]}{range .status.addresses[*]}{.address}{"\n"}{end}{end}' 2>/dev/null || true)"
|
||||||
|
for a in $NODE_IPS; do
|
||||||
|
if [ "$a" = "$EPIP" ]; then
|
||||||
|
echo "FAIL: backup endpoint $EPIP is a NODE address of this cluster -- the backup is not off-cluster." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "endpoint is not any node address (checked $(echo "$NODE_IPS" | grep -c . || true) node addresses)"
|
||||||
|
|
||||||
|
SVC_IPS="$(kubectl get svc -A -o jsonpath='{range .items[*]}{.spec.clusterIP}{"\n"}{end}' 2>/dev/null || true)"
|
||||||
|
for a in $SVC_IPS; do
|
||||||
|
if [ "$a" = "$EPIP" ]; then
|
||||||
|
echo "FAIL: backup endpoint $EPIP is a Service ClusterIP -- the backup target lives INSIDE the cluster." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "endpoint is not any Service ClusterIP (checked $(echo "$SVC_IPS" | grep -c . || true))"
|
||||||
|
|
||||||
|
POD_IPS="$(kubectl get pods -A -o jsonpath='{range .items[*]}{.status.podIP}{"\n"}{end}' 2>/dev/null || true)"
|
||||||
|
for a in $POD_IPS; do
|
||||||
|
if [ "$a" = "$EPIP" ]; then
|
||||||
|
echo "FAIL: backup endpoint $EPIP is a POD IP -- the backup target is an in-cluster pod." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "endpoint is not any Pod IP (checked $(echo "$POD_IPS" | grep -c . || true))"
|
||||||
|
|
||||||
|
POD_CIDRS="$(kubectl get nodes -o jsonpath='{range .items[*]}{.spec.podCIDR}{"\n"}{range .spec.podCIDRs[*]}{@}{"\n"}{end}{end}' 2>/dev/null || true)"
|
||||||
|
for c in $POD_CIDRS; do
|
||||||
|
case "$c" in *:*) continue ;; esac # IPv6 prefixes are out of scope here
|
||||||
|
if in_cidr "$EPIP" "$c"; then
|
||||||
|
echo "FAIL: backup endpoint $EPIP falls inside node podCIDR $c -- it is in the cluster pod network." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "endpoint is outside every node podCIDR (checked: $(echo "$POD_CIDRS" | tr '\n' ' '))"
|
||||||
|
|
||||||
|
# Best-effort (needs networking.k8s.io/ServiceCIDR, which the least-privilege
|
||||||
|
# runner may not read): the checks above are the authoritative ones.
|
||||||
|
SVC_CIDRS="$(kubectl get servicecidrs.networking.k8s.io -o jsonpath='{range .items[*]}{range .spec.cidrs[*]}{@}{"\n"}{end}{end}' 2>/dev/null || true)"
|
||||||
|
if [ -n "$SVC_CIDRS" ]; then
|
||||||
|
for c in $SVC_CIDRS; do
|
||||||
|
case "$c" in *:*) continue ;; esac
|
||||||
|
if in_cidr "$EPIP" "$c"; then
|
||||||
|
echo "FAIL: backup endpoint $EPIP falls inside the Service CIDR $c -- it is in the cluster." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "endpoint is outside every ServiceCIDR ($(echo "$SVC_CIDRS" | tr '\n' ' '))"
|
||||||
|
else
|
||||||
|
echo "(ServiceCIDR objects unreadable -- skipping that best-effort check; ClusterIP/PodIP/podCIDR checks stand)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OFF_CLUSTER_TARGET_OK bsl=${BSL_NS}/${BSL_NAME} endpoint=${EPIP} bucket=${BSL_BUCKET}"
|
||||||
|
l.go:52: | 00:19:11 | [36;1mdr-restore[0;22m | [36;1massert-backup-target-is-off-cluster [0;22m | [36;1mSCRIPT[0;22m | [36;1mLOG[0;22m |
|
||||||
|
=== STDOUT
|
||||||
|
backup storage locations:
|
||||||
|
velero default Available aws true http://192.168.1.9:9000 velero
|
||||||
|
poll 1: velero readyReplicas=1 node-agent numberReady=3
|
||||||
|
using BSL velero/default bucket=velero endpoint=http://192.168.1.9:9000
|
||||||
|
backup endpoint host: 192.168.1.9
|
||||||
|
backup endpoint resolves to: 192.168.1.9
|
||||||
|
endpoint is not any node address (checked 6 node addresses)
|
||||||
|
endpoint is not any Service ClusterIP (checked 81)
|
||||||
|
endpoint is not any Pod IP (checked 186)
|
||||||
|
endpoint is outside every node podCIDR (checked: 10.42.0.0/24 10.42.0.0/24 10.42.1.0/24 10.42.1.0/24 10.42.2.0/24 10.42.2.0/24 )
|
||||||
|
endpoint is outside every ServiceCIDR (10.43.0.0/16 )
|
||||||
|
OFF_CLUSTER_TARGET_OK bsl=velero/default endpoint=192.168.1.9 bucket=velero
|
||||||
|
l.go:52: | 00:19:11 | [32;1mdr-restore[0;22m | [32;1massert-backup-target-is-off-cluster [0;22m | [32;1mSCRIPT[0;22m | [32;1mDONE[0;22m |
|
||||||
|
l.go:52: | 00:19:11 | [36;1mdr-restore[0;22m | [36;1massert-backup-target-is-off-cluster [0;22m | [36;1mTRY[0;22m | [36;1mEND[0;22m |
|
||||||
|
l.go:52: | 00:19:11 | [36;1mdr-restore[0;22m | [36;1massert-backups-are-scheduled [0;22m | [36;1mTRY[0;22m | [36;1mBEGIN[0;22m |
|
||||||
|
l.go:52: | 00:19:11 | [36;1mdr-restore[0;22m | [36;1massert-backups-are-scheduled [0;22m | [36;1mCMD[0;22m | [36;1mRUN[0;22m |
|
||||||
|
=== COMMAND
|
||||||
|
/usr/bin/sh -c set -eu
|
||||||
|
SCHED="$(kubectl get schedules.velero.io -A \
|
||||||
|
-o jsonpath='{range .items[*]}{.metadata.namespace}{"/"}{.metadata.name}{" phase="}{.status.phase}{" cron="}{.spec.schedule}{" lastBackup="}{.status.lastBackup}{"\n"}{end}' 2>/dev/null || true)"
|
||||||
|
if [ -z "$(echo "$SCHED" | tr -d '[:space:]')" ]; then
|
||||||
|
# Distinguish "Velero absent" (skip, in lock-step with step 1) from
|
||||||
|
# "Velero present but nothing scheduled" (a real R-STATE-03 defect).
|
||||||
|
if kubectl get backupstoragelocations.velero.io -A >/dev/null 2>&1; then
|
||||||
|
if [ -z "$(kubectl get backupstoragelocations.velero.io -A -o name 2>/dev/null || true)" ]; then
|
||||||
|
echo "SKIP: no BackupStorageLocation configured -- scheduling not applicable yet"
|
||||||
|
echo DR_SCHEDULE_SKIPPED_OK
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "FAIL: Velero is configured with a backup target but declares NO Schedule --" >&2
|
||||||
|
echo " R-STATE-03 requires SCHEDULED off-cluster backups, not manual ones." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SKIP: Velero API absent -- backup scheduling not applicable on this cluster yet"
|
||||||
|
echo DR_SCHEDULE_SKIPPED_OK
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "velero schedules:"
|
||||||
|
echo "$SCHED"
|
||||||
|
# A Schedule that exists but is paused/disabled backs nothing up.
|
||||||
|
if echo "$SCHED" | grep -q 'phase=Enabled'; then
|
||||||
|
echo BACKUPS_ARE_SCHEDULED_OK
|
||||||
|
else
|
||||||
|
echo "FAIL: no Velero Schedule is in phase 'Enabled' -- scheduled off-cluster backups are not running." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
l.go:52: | 00:19:11 | [36;1mdr-restore[0;22m | [36;1massert-backups-are-scheduled [0;22m | [36;1mSCRIPT[0;22m | [36;1mLOG[0;22m |
|
||||||
|
=== STDOUT
|
||||||
|
velero schedules:
|
||||||
|
velero/velero-daily phase=Enabled cron=0 3 * * * lastBackup=2026-09-01T03:00:27Z
|
||||||
|
velero/velero-daily-resources-only phase=Enabled cron=0 3 * * * lastBackup=2026-09-01T03:00:27Z
|
||||||
|
BACKUPS_ARE_SCHEDULED_OK
|
||||||
|
l.go:52: | 00:19:11 | [32;1mdr-restore[0;22m | [32;1massert-backups-are-scheduled [0;22m | [32;1mSCRIPT[0;22m | [32;1mDONE[0;22m |
|
||||||
|
l.go:52: | 00:19:11 | [36;1mdr-restore[0;22m | [36;1massert-backups-are-scheduled [0;22m | [36;1mTRY[0;22m | [36;1mEND[0;22m |
|
||||||
|
l.go:52: | 00:19:11 | [36;1mdr-restore[0;22m | [36;1mdata-restores-from-the-off-cluster-backup[0;22m | [36;1mTRY[0;22m | [36;1mBEGIN[0;22m |
|
||||||
|
l.go:52: | 00:19:11 | [36;1mdr-restore[0;22m | [36;1mdata-restores-from-the-off-cluster-backup[0;22m | [36;1mCMD[0;22m | [36;1mRUN[0;22m |
|
||||||
|
=== COMMAND
|
||||||
|
/usr/bin/sh -c set -eu
|
||||||
|
NS="$NAMESPACE"
|
||||||
|
SRC="${NS}-dr-src" # scratch source namespace (destroyed by design)
|
||||||
|
DST="${NS}-dr-dst" # clean restore target (must not pre-exist)
|
||||||
|
BK="${NS}-dr-backup"
|
||||||
|
RS="${NS}-dr-restore"
|
||||||
|
OWNER_LABEL="conformance.home.k3s/created-by=${NS}"
|
||||||
|
|
||||||
|
# ---- Re-derive the preconditions (steps do not share shell state). Every
|
||||||
|
# skip below mirrors step 1 exactly, so the whole test skips or runs
|
||||||
|
# as a unit -- it can never half-run. -----------------------------
|
||||||
|
if ! kubectl get storageclass longhorn >/dev/null 2>&1; then
|
||||||
|
echo "SKIP: 'longhorn' StorageClass absent"; echo DR_RESTORE_SKIPPED_OK; exit 0
|
||||||
|
fi
|
||||||
|
BSL_LINES="$(kubectl get backupstoragelocations.velero.io -A \
|
||||||
|
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{" "}{.status.phase}{" "}{.spec.default}{"\n"}{end}' 2>/dev/null || true)"
|
||||||
|
CAND="$(echo "$BSL_LINES" | grep ' Available ' | grep ' true$' | head -1 || true)"
|
||||||
|
[ -n "$CAND" ] || CAND="$(echo "$BSL_LINES" | grep ' Available ' | head -1 || true)"
|
||||||
|
if [ -z "$CAND" ]; then
|
||||||
|
echo "SKIP: no Available BackupStorageLocation (see the off-cluster step for the authoritative verdict)"
|
||||||
|
echo DR_RESTORE_SKIPPED_OK; exit 0
|
||||||
|
fi
|
||||||
|
VNS="$(echo "$CAND" | cut -d' ' -f1)"
|
||||||
|
BSL_NAME="$(echo "$CAND" | cut -d' ' -f2)"
|
||||||
|
if [ "$(kubectl -n "$VNS" get daemonset node-agent -o jsonpath='{.status.numberReady}' 2>/dev/null || echo 0)" -lt 1 ]; then
|
||||||
|
echo "SKIP: Velero node-agent (kopia FSB) not ready -- volume data cannot be backed up"
|
||||||
|
echo DR_RESTORE_SKIPPED_OK; exit 0
|
||||||
|
fi
|
||||||
|
echo "velero ns=$VNS bsl=$BSL_NAME src=$SRC dst=$DST"
|
||||||
|
|
||||||
|
# A clean restore target means PREVIOUSLY NONEXISTENT, and the source name must
|
||||||
|
# be free. If either is lying around from an aborted run, remove it before we
|
||||||
|
# claim "clean" -- and fail loudly if it will not go, because a half-deleted
|
||||||
|
# namespace would silently invalidate the destroy proof below.
|
||||||
|
kubectl delete namespace "$DST" "$SRC" --ignore-not-found --wait=true --timeout=300s
|
||||||
|
|
||||||
|
# ============================ GIVEN ==============================
|
||||||
|
# A DR-critical stateful workload with known data on the replicated tier.
|
||||||
|
CANARY="dr-canary-$(date +%s%N)"
|
||||||
|
kubectl create namespace "$SRC"
|
||||||
|
kubectl label namespace "$SRC" "$OWNER_LABEL" --overwrite
|
||||||
|
|
||||||
|
kubectl -n "$SRC" apply -f - <<EOF
|
||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: dr-data
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/created-by: ${NS}
|
||||||
|
spec:
|
||||||
|
accessModes: [ReadWriteOnce]
|
||||||
|
storageClassName: longhorn
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
|
EOF
|
||||||
|
|
||||||
|
kubectl -n "$SRC" apply -f - <<EOF
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: dr-workload
|
||||||
|
labels:
|
||||||
|
app: dr-workload
|
||||||
|
conformance.home.k3s/created-by: ${NS}
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: app
|
||||||
|
image: busybox:1.36.1
|
||||||
|
command: ["sh", "-c", "sleep 7200"]
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: dr-data
|
||||||
|
EOF
|
||||||
|
|
||||||
|
kubectl -n "$SRC" wait --for=condition=Ready pod/dr-workload --timeout=300s
|
||||||
|
printf '%s' "$CANARY" | kubectl -n "$SRC" exec -i dr-workload -- sh -c 'cat > /data/canary && sync'
|
||||||
|
echo "seeded canary: $(kubectl -n "$SRC" exec dr-workload -- cat /data/canary)"
|
||||||
|
|
||||||
|
ORIG_PV="$(kubectl -n "$SRC" get pvc dr-data -o jsonpath='{.spec.volumeName}')"
|
||||||
|
[ -n "$ORIG_PV" ] || { echo "FAIL: source PVC never bound to a PV" >&2; exit 1; }
|
||||||
|
echo "original in-cluster PersistentVolume: $ORIG_PV"
|
||||||
|
|
||||||
|
# ======================= WHEN (part 1): back up off-cluster =======
|
||||||
|
# Created as a Velero Backup CR -- exactly what `velero backup create` does.
|
||||||
|
# defaultVolumesToFsBackup:true selects the kopia File System Backup path, so
|
||||||
|
# the VOLUME DATA (not just the resource JSON) is uploaded to the object store.
|
||||||
|
# ttl is short: if the finally-step DeleteBackupRequest is ever lost, Velero's
|
||||||
|
# own GC still removes this drill's objects from the bucket.
|
||||||
|
kubectl apply -f - <<EOF
|
||||||
|
apiVersion: velero.io/v1
|
||||||
|
kind: Backup
|
||||||
|
metadata:
|
||||||
|
name: ${BK}
|
||||||
|
namespace: ${VNS}
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/created-by: ${NS}
|
||||||
|
spec:
|
||||||
|
includedNamespaces: [${SRC}]
|
||||||
|
storageLocation: ${BSL_NAME}
|
||||||
|
snapshotVolumes: false
|
||||||
|
defaultVolumesToFsBackup: true
|
||||||
|
ttl: 2h0m0s
|
||||||
|
EOF
|
||||||
|
|
||||||
|
BPHASE=""
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
BPHASE="$(kubectl -n "$VNS" get backups.velero.io "$BK" -o jsonpath='{.status.phase}' 2>/dev/null || true)"
|
||||||
|
echo "poll $i: backup phase=${BPHASE:-<pending>}"
|
||||||
|
case "${BPHASE:-}" in
|
||||||
|
Completed|Failed|PartiallyFailed|FailedValidation) break ;;
|
||||||
|
esac
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
if [ "${BPHASE:-}" != "Completed" ]; then
|
||||||
|
echo "FAIL: off-cluster backup did not complete (phase=${BPHASE:-<timeout>})" >&2
|
||||||
|
kubectl -n "$VNS" get backups.velero.io "$BK" -o yaml >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "backup $BK Completed to ${BSL_NAME}"
|
||||||
|
|
||||||
|
# ---- OFF-CLUSTER PROOF (3): the VOLUME DATA really landed in the remote
|
||||||
|
# kopia repository -- a PodVolumeBackup Completed with a snapshot ID.
|
||||||
|
PVB="$(kubectl -n "$VNS" get podvolumebackups.velero.io \
|
||||||
|
-l velero.io/backup-name="$BK" \
|
||||||
|
-o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.phase}{" "}{.status.snapshotID}{"\n"}{end}' 2>/dev/null || true)"
|
||||||
|
echo "pod volume backups:"
|
||||||
|
echo "$PVB"
|
||||||
|
if [ -z "$(echo "$PVB" | tr -d '[:space:]')" ]; then
|
||||||
|
echo "FAIL: the backup captured NO pod volume data (no PodVolumeBackup)." >&2
|
||||||
|
echo " Resource-only backups cannot restore DR-critical DATA." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# A line is "<name> <phase> <snapshotID>"; require Completed AND a
|
||||||
|
# non-empty snapshot ID (an empty ID means nothing reached the repository).
|
||||||
|
GOOD_PVB="$(echo "$PVB" | grep -cE ' Completed [^[:space:]]+$' || true)"
|
||||||
|
if [ "${GOOD_PVB:-0}" -lt 1 ]; then
|
||||||
|
echo "FAIL: no PodVolumeBackup reached phase Completed with a snapshot ID --" >&2
|
||||||
|
echo " the volume data did not reach the off-cluster repository." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "volume data uploaded off-cluster (PodVolumeBackup Completed with a kopia snapshotID)"
|
||||||
|
|
||||||
|
# ============ WHEN (part 2): FAULT INJECTION -- annihilate the source ====
|
||||||
|
# Destroy EVERY in-cluster copy: the pod, the PVC, the namespace, and the
|
||||||
|
# underlying PersistentVolume (and with it the Longhorn replicas). After this
|
||||||
|
# there is no in-cluster source the restore could quietly read from.
|
||||||
|
kubectl delete namespace "$SRC" --wait=true --timeout=420s
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if ! kubectl get namespace "$SRC" >/dev/null 2>&1; then break; fi
|
||||||
|
echo "poll $i: waiting for namespace $SRC to disappear"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
if kubectl get namespace "$SRC" >/dev/null 2>&1; then
|
||||||
|
echo "FAIL: source namespace $SRC did not terminate; the destroy step is not convincing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "source namespace destroyed"
|
||||||
|
|
||||||
|
# The longhorn StorageClass reclaims Delete, so the PV should already be gone;
|
||||||
|
# force it if a Retain policy (or a stuck finalizer) left it behind, because a
|
||||||
|
# surviving PV would make the restore prove nothing about off-cluster recovery.
|
||||||
|
kubectl delete pv "$ORIG_PV" --ignore-not-found --wait=false || true
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if ! kubectl get pv "$ORIG_PV" >/dev/null 2>&1; then break; fi
|
||||||
|
echo "poll $i: waiting for PersistentVolume $ORIG_PV to disappear"
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
if kubectl get pv "$ORIG_PV" >/dev/null 2>&1; then
|
||||||
|
echo "FAIL: the original PersistentVolume $ORIG_PV SURVIVED the destroy step." >&2
|
||||||
|
echo " An in-cluster copy of the data still exists, so a successful restore" >&2
|
||||||
|
echo " would NOT prove recovery from the off-cluster backup. Refusing to pass." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "original PersistentVolume $ORIG_PV is GONE -- no in-cluster copy of the data remains"
|
||||||
|
|
||||||
|
# Best-effort corroboration (the least-privilege runner may not read longhorn.io):
|
||||||
|
# the Longhorn Volume CR and its replicas should be gone too.
|
||||||
|
if kubectl -n longhorn-system get volumes.longhorn.io "$ORIG_PV" >/dev/null 2>&1; then
|
||||||
|
echo "FAIL: Longhorn Volume $ORIG_PV still exists after PV deletion -- replicas survive in-cluster" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "(longhorn Volume CR $ORIG_PV not present -- either deleted or not readable by this identity)"
|
||||||
|
|
||||||
|
# ============================ RESTORE ============================
|
||||||
|
# Into a CLEAN, previously-nonexistent namespace (Velero creates it), which is
|
||||||
|
# the catalog's "restored into a clean state".
|
||||||
|
kubectl apply -f - <<EOF
|
||||||
|
apiVersion: velero.io/v1
|
||||||
|
kind: Restore
|
||||||
|
metadata:
|
||||||
|
name: ${RS}
|
||||||
|
namespace: ${VNS}
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/created-by: ${NS}
|
||||||
|
spec:
|
||||||
|
backupName: ${BK}
|
||||||
|
includedNamespaces: [${SRC}]
|
||||||
|
namespaceMapping:
|
||||||
|
${SRC}: ${DST}
|
||||||
|
existingResourcePolicy: none
|
||||||
|
EOF
|
||||||
|
|
||||||
|
RPHASE=""
|
||||||
|
for i in $(seq 1 90); do
|
||||||
|
RPHASE="$(kubectl -n "$VNS" get restores.velero.io "$RS" -o jsonpath='{.status.phase}' 2>/dev/null || true)"
|
||||||
|
echo "poll $i: restore phase=${RPHASE:-<pending>}"
|
||||||
|
case "${RPHASE:-}" in
|
||||||
|
Completed|Failed|PartiallyFailed|FailedValidation) break ;;
|
||||||
|
esac
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
if [ "${RPHASE:-}" != "Completed" ]; then
|
||||||
|
echo "FAIL: restore did not complete (phase=${RPHASE:-<timeout>})" >&2
|
||||||
|
kubectl -n "$VNS" get restores.velero.io "$RS" -o yaml >&2 || true
|
||||||
|
kubectl -n "$VNS" get podvolumerestores.velero.io -l velero.io/restore-name="$RS" -o wide >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "restore $RS Completed into $DST"
|
||||||
|
|
||||||
|
# ============================= THEN ==============================
|
||||||
|
# The service returns WITH ITS DATA. With kopia FSB the restored pod carries a
|
||||||
|
# `restore-wait` init container that blocks until the node-agent has written the
|
||||||
|
# volume contents back, so Ready here already implies the data landed.
|
||||||
|
if ! kubectl -n "$DST" wait --for=condition=Ready pod/dr-workload --timeout=600s; then
|
||||||
|
echo "FAIL: restored workload never became Ready in the clean namespace $DST" >&2
|
||||||
|
kubectl -n "$DST" describe pod dr-workload >&2 || true
|
||||||
|
kubectl -n "$VNS" get podvolumerestores.velero.io -l velero.io/restore-name="$RS" -o wide >&2 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
NEW_PV="$(kubectl -n "$DST" get pvc dr-data -o jsonpath='{.spec.volumeName}' 2>/dev/null || true)"
|
||||||
|
echo "restored PVC bound to PersistentVolume: ${NEW_PV:-<none>}"
|
||||||
|
[ -n "$NEW_PV" ] || { echo "FAIL: restored PVC did not bind a PV" >&2; exit 1; }
|
||||||
|
if [ "$NEW_PV" = "$ORIG_PV" ]; then
|
||||||
|
echo "FAIL: the restored PVC rebound the ORIGINAL PersistentVolume ($ORIG_PV)." >&2
|
||||||
|
echo " That is an in-cluster survival, not an off-cluster restore." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "restored onto a NEW volume ($NEW_PV != $ORIG_PV) -- the data came from outside the cluster"
|
||||||
|
|
||||||
|
GOT="$(kubectl -n "$DST" exec dr-workload -- cat /data/canary)"
|
||||||
|
echo "recovered='$GOT' expected='$CANARY'"
|
||||||
|
if [ "$GOT" != "$CANARY" ]; then
|
||||||
|
echo "FAIL: restored data does not match what was backed up -- DR restore lost/corrupted data" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK
|
||||||
|
l.go:52: | 00:21:16 | [36;1mdr-restore[0;22m | [36;1mdata-restores-from-the-off-cluster-backup[0;22m | [36;1mSCRIPT[0;22m | [36;1mLOG[0;22m |
|
||||||
|
=== STDOUT
|
||||||
|
velero ns=velero bsl=default src=chainsaw-cunning-hen-dr-src dst=chainsaw-cunning-hen-dr-dst
|
||||||
|
namespace/chainsaw-cunning-hen-dr-src created
|
||||||
|
namespace/chainsaw-cunning-hen-dr-src labeled
|
||||||
|
persistentvolumeclaim/dr-data created
|
||||||
|
pod/dr-workload created
|
||||||
|
pod/dr-workload condition met
|
||||||
|
seeded canary: dr-canary-1788308352223263220
|
||||||
|
original in-cluster PersistentVolume: pvc-b9523c15-770e-4fc5-8130-ca2e9d9da745
|
||||||
|
backup.velero.io/chainsaw-cunning-hen-dr-backup created
|
||||||
|
poll 1: backup phase=InProgress
|
||||||
|
poll 2: backup phase=InProgress
|
||||||
|
poll 3: backup phase=Completed
|
||||||
|
backup chainsaw-cunning-hen-dr-backup Completed to default
|
||||||
|
pod volume backups:
|
||||||
|
chainsaw-cunning-hen-dr-backup-l95lw Completed 47fa2737115e17142fb41857cef4e779
|
||||||
|
volume data uploaded off-cluster (PodVolumeBackup Completed with a kopia snapshotID)
|
||||||
|
namespace "chainsaw-cunning-hen-dr-src" deleted
|
||||||
|
source namespace destroyed
|
||||||
|
original PersistentVolume pvc-b9523c15-770e-4fc5-8130-ca2e9d9da745 is GONE -- no in-cluster copy of the data remains
|
||||||
|
(longhorn Volume CR pvc-b9523c15-770e-4fc5-8130-ca2e9d9da745 not present -- either deleted or not readable by this identity)
|
||||||
|
restore.velero.io/chainsaw-cunning-hen-dr-restore created
|
||||||
|
poll 1: restore phase=InProgress
|
||||||
|
poll 2: restore phase=InProgress
|
||||||
|
poll 3: restore phase=InProgress
|
||||||
|
poll 4: restore phase=Completed
|
||||||
|
restore chainsaw-cunning-hen-dr-restore Completed into chainsaw-cunning-hen-dr-dst
|
||||||
|
pod/dr-workload condition met
|
||||||
|
restored PVC bound to PersistentVolume: pvc-63c47973-9c7f-4423-a27e-caa008026e49
|
||||||
|
restored onto a NEW volume (pvc-63c47973-9c7f-4423-a27e-caa008026e49 != pvc-b9523c15-770e-4fc5-8130-ca2e9d9da745) -- the data came from outside the cluster
|
||||||
|
recovered='dr-canary-1788308352223263220' expected='dr-canary-1788308352223263220'
|
||||||
|
DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK
|
||||||
|
=== STDERR
|
||||||
|
Defaulted container "app" out of: app, restore-wait (init)
|
||||||
|
l.go:52: | 00:21:16 | [32;1mdr-restore[0;22m | [32;1mdata-restores-from-the-off-cluster-backup[0;22m | [32;1mSCRIPT[0;22m | [32;1mDONE[0;22m |
|
||||||
|
l.go:52: | 00:21:16 | [36;1mdr-restore[0;22m | [36;1mdata-restores-from-the-off-cluster-backup[0;22m | [36;1mTRY[0;22m | [36;1mEND[0;22m |
|
||||||
|
l.go:52: | 00:21:16 | [36;1mdr-restore[0;22m | [36;1mdata-restores-from-the-off-cluster-backup[0;22m | [36;1mFINALLY[0;22m | [36;1mBEGIN[0;22m |
|
||||||
|
l.go:52: | 00:21:16 | [36;1mdr-restore[0;22m | [36;1mdata-restores-from-the-off-cluster-backup[0;22m | [36;1mCMD[0;22m | [36;1mRUN[0;22m |
|
||||||
|
=== COMMAND
|
||||||
|
/usr/bin/sh -c set -u
|
||||||
|
NS="$NAMESPACE"
|
||||||
|
SRC="${NS}-dr-src"; DST="${NS}-dr-dst"; BK="${NS}-dr-backup"; RS="${NS}-dr-restore"
|
||||||
|
|
||||||
|
VNS="$(kubectl get backupstoragelocations.velero.io -A -o jsonpath='{.items[0].metadata.namespace}' 2>/dev/null || true)"
|
||||||
|
|
||||||
|
# Velero objects first: a DeleteBackupRequest makes Velero remove the backup
|
||||||
|
# DATA from the off-cluster bucket, not just the CR (self-cleaning contract).
|
||||||
|
if [ -n "$VNS" ]; then
|
||||||
|
kubectl -n "$VNS" delete restores.velero.io "$RS" --ignore-not-found --wait=false || true
|
||||||
|
if kubectl -n "$VNS" get backups.velero.io "$BK" >/dev/null 2>&1; then
|
||||||
|
kubectl apply -f - <<EOF || true
|
||||||
|
apiVersion: velero.io/v1
|
||||||
|
kind: DeleteBackupRequest
|
||||||
|
metadata:
|
||||||
|
name: ${BK}-delete
|
||||||
|
namespace: ${VNS}
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/created-by: ${NS}
|
||||||
|
spec:
|
||||||
|
backupName: ${BK}
|
||||||
|
EOF
|
||||||
|
for i in $(seq 1 18); do
|
||||||
|
kubectl -n "$VNS" get backups.velero.io "$BK" >/dev/null 2>&1 || break
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
# Backstop if Velero could not process the request (the 2h ttl also GCs it).
|
||||||
|
kubectl -n "$VNS" delete backups.velero.io "$BK" --ignore-not-found --wait=false || true
|
||||||
|
kubectl -n "$VNS" delete deletebackuprequests.velero.io "${BK}-delete" --ignore-not-found --wait=false || true
|
||||||
|
fi
|
||||||
|
echo "velero objects for $NS cleaned up (ns $VNS)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Scratch namespaces (both, in case the run aborted at any point).
|
||||||
|
for n in "$DST" "$SRC"; do
|
||||||
|
kubectl delete namespace "$n" --ignore-not-found --wait=false || true
|
||||||
|
done
|
||||||
|
# Anything left labelled as ours (defensive; the two names above are the only
|
||||||
|
# namespaces this test ever creates).
|
||||||
|
kubectl delete namespace -l "conformance.home.k3s/created-by=${NS}" --ignore-not-found --wait=false || true
|
||||||
|
|
||||||
|
# Released PVs from either namespace's PVCs would otherwise leak storage.
|
||||||
|
for pv in $(kubectl get pv -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.spec.claimRef.namespace}{"\n"}{end}' 2>/dev/null | grep -E " (${SRC}|${DST})$" | cut -d' ' -f1 || true); do
|
||||||
|
kubectl delete pv "$pv" --ignore-not-found --wait=false || true
|
||||||
|
echo "released leftover PersistentVolume: $pv"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "dr-restore teardown complete (best-effort; every object was created by this test)"
|
||||||
|
l.go:52: | 00:21:28 | [36;1mdr-restore[0;22m | [36;1mdata-restores-from-the-off-cluster-backup[0;22m | [36;1mSCRIPT[0;22m | [36;1mLOG[0;22m |
|
||||||
|
=== STDOUT
|
||||||
|
restore.velero.io "chainsaw-cunning-hen-dr-restore" deleted from velero namespace
|
||||||
|
deletebackuprequest.velero.io/chainsaw-cunning-hen-dr-backup-delete created
|
||||||
|
velero objects for chainsaw-cunning-hen cleaned up (ns velero)
|
||||||
|
namespace "chainsaw-cunning-hen-dr-dst" deleted
|
||||||
|
namespace "chainsaw-cunning-hen-dr-dst" deleted
|
||||||
|
persistentvolume "pvc-63c47973-9c7f-4423-a27e-caa008026e49" deleted
|
||||||
|
released leftover PersistentVolume: pvc-63c47973-9c7f-4423-a27e-caa008026e49
|
||||||
|
dr-restore teardown complete (best-effort; every object was created by this test)
|
||||||
|
l.go:52: | 00:21:28 | [32;1mdr-restore[0;22m | [32;1mdata-restores-from-the-off-cluster-backup[0;22m | [32;1mSCRIPT[0;22m | [32;1mDONE[0;22m |
|
||||||
|
l.go:52: | 00:21:28 | [36;1mdr-restore[0;22m | [36;1mdata-restores-from-the-off-cluster-backup[0;22m | [36;1mFINALLY[0;22m | [36;1mEND[0;22m |
|
||||||
|
l.go:52: | 00:21:28 | [36;1mdr-restore[0;22m | [36;1m@chainsaw [0;22m | [36;1mCLEANUP[0;22m | [36;1mBEGIN[0;22m |
|
||||||
|
l.go:52: | 00:21:28 | [32;1mdr-restore[0;22m | [32;1m@chainsaw [0;22m | [32;1mDELETE[0;22m | [32;1mOK[0;22m | v1/Namespace @ chainsaw-cunning-hen
|
||||||
|
l.go:52: | 00:21:36 | [36;1mdr-restore[0;22m | [36;1m@chainsaw [0;22m | [36;1mCLEANUP[0;22m | [36;1mEND[0;22m |
|
||||||
|
--- PASS: chainsaw (0.00s)
|
||||||
|
--- PASS: chainsaw/dr-restore (149.05s)
|
||||||
|
PASS
|
||||||
|
Tests Summary...
|
||||||
|
- Passed tests 1
|
||||||
|
- Failed tests 0
|
||||||
|
- Skipped tests 0
|
||||||
|
Done.
|
||||||
|
merge_junit: merged 1 report(s) -> /work/conformance/_artifacts/report.junit.xml (tests=1 failures=0 errors=0 skipped=0)
|
||||||
|
[1m[run.sh][0m Done. JUnit: /work/conformance/_artifacts/report.junit.xml | Coverage: /work/conformance/_artifacts/coverage/coverage.md
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"chainsaw_version": "v0.2.12", "env": "dev", "git_sha": "57d4df8c4f992732a49f3e2daddcace866778ab0", "junit_result": "passed", "kube_version": "v1.36.4+k3s1", "layer": "L3", "note": "run log OUTPUT carries every declared pass token (NODE_LOSS_VOLUME_AVAILABLE_OK) and no skip token [log scoped to this testcase; tokens read from real-output sections only]", "proof": "proven", "requirements": ["R-STATE-02"], "run_id": "20260901T235117Z-e9a4b66", "sentinel": "NODE_LOSS_VOLUME_AVAILABLE_OK", "test": "node-loss", "ts_utc": "2026-09-01T23:51:17Z"}
|
||||||
|
{"chainsaw_version": "v0.2.12", "env": "dev", "git_sha": "fdc3265585f4b775c0a6ae3fb33c837b0c643655", "junit_result": "passed", "kube_version": "v1.36.4+k3s1", "layer": "L3", "note": "run log OUTPUT carries every declared pass token (OFF_CLUSTER_TARGET_OK, BACKUPS_ARE_SCHEDULED_OK, DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK) and no skip token [log scoped to this testcase; tokens read from real-output sections only]", "proof": "proven", "requirements": ["R-STATE-03"], "run_id": "20260902T001907Z-fdc3265", "sentinel": "DR_RESTORED_FROM_OFF_CLUSTER_BACKUP_OK", "test": "dr-restore", "ts_utc": "2026-09-02T00:19:07Z"}
|
||||||
@@ -93,9 +93,52 @@ and coverage extraction exact and robust.
|
|||||||
break the thing and assert fail-closed / no-data-loss, with `finally` restoring state.
|
break the thing and assert fail-closed / no-data-loss, with `finally` restoring state.
|
||||||
- **Never weaken a test to go green (R-TEST-06).** If the spec can't be met, escalate to
|
- **Never weaken a test to go green (R-TEST-06).** If the spec can't be met, escalate to
|
||||||
the PM — do not relax the assertion or the requirement.
|
the PM — do not relax the assertion or the requirement.
|
||||||
|
- **Never write a per-environment value into a test (R-ENV-02).** No hostname suffix, no
|
||||||
|
Gateway VIP, no LB-pool range. One authored suite must run green against dev *and* prod,
|
||||||
|
so a test **asks the cluster it is pointed at** what it is. The mechanism is a chainsaw
|
||||||
|
binding over the same ConfigMap Flux substitutes from:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
spec:
|
||||||
|
bindings:
|
||||||
|
- name: baseDomain
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.BASE_DOMAIN)
|
||||||
|
```
|
||||||
|
|
||||||
|
Declare it at **test level** (step `outputs`/`bindings` are step-scoped), then consume it:
|
||||||
|
`($baseDomain)` for a pod `env:` value, `(join('.', ['myhost', $baseDomain]))` where a
|
||||||
|
full hostname is needed — including inside a `fixtures/*.yaml` applied with `apply: file:`,
|
||||||
|
which chainsaw templates with the same bindings. Prefer reading the *actual* observed value
|
||||||
|
over restating a range (e.g. assert the DNS answer equals `GATEWAY_VIP`, or equals the LB IP
|
||||||
|
the Service was assigned) — it is both stricter and environment-independent. Never take the
|
||||||
|
environment from a CLI flag or an exported variable: a flag someone forgets is how a suite
|
||||||
|
ends up silently asserting the other cluster's expectations. L1 goldens are the one
|
||||||
|
exception: they are rendering fixtures and carry the literal `${BASE_DOMAIN}` token.
|
||||||
|
|
||||||
|
### How a scaffold declares itself (and how `status.py` tells one from a verifier)
|
||||||
|
|
||||||
Scaffolds in this tree carry `TODO(T-x.y)` markers where a fixture/assertion lands as a
|
Scaffolds in this tree carry `TODO(T-x.y)` markers where a fixture/assertion lands as a
|
||||||
later roadmap phase delivers the capability under test. `lib/status.py` treats such a
|
later roadmap phase delivers the capability under test. `lib/status.py` classifies each
|
||||||
marker — in the `chainsaw-test.yaml`, in `render-check.sh`, or in `check.sh` — as
|
test EXECUTABLE or SCAFFOLD from the test's own files — `chainsaw-test.yaml`,
|
||||||
outranking any `conformance.home.k3s/executable: "true"` annotation, so a placeholder body
|
`render-check.sh`, `check.sh` — and either signal below **outranks**
|
||||||
can never be reported as an executable test.
|
`conformance.home.k3s/executable: "true"`, so a placeholder body can never be reported as
|
||||||
|
an executable test. Declare a scaffold in **both** positions:
|
||||||
|
|
||||||
|
1. **`# STATUS: scaffold — <what is missing>`** in the file's header comment block. The
|
||||||
|
declared state must be the first token after `STATUS:` (executable tests say
|
||||||
|
`# STATUS: EXECUTABLE` / `IMPLEMENTED` / `RUNNABLE`).
|
||||||
|
2. **A `TODO`/`FIXME` marker carrying a message, in the body** — the file from its first
|
||||||
|
non-comment line onward. `echo "TODO(T-4.2): inject a fault to fire a rule"`,
|
||||||
|
or an inline comment on a missing fixture:
|
||||||
|
`file: fixtures/smtp-sink.yaml # TODO(T-4.1): hermetic SMTP capture sink`.
|
||||||
|
|
||||||
|
What is **not** a scaffold marker, and why the distinction is load-bearing: a marker
|
||||||
|
mentioned in the **header prose** (the Given/When/Then and the mandatory "what this does
|
||||||
|
NOT prove" list), and a marker that is the **entire content of a quoted literal** — a
|
||||||
|
placeholder *detector*'s own vocabulary, e.g. `SEED = ["TBD", "TODO", "FIXME", ...]` in
|
||||||
|
`l0-static/placeholder-guard-coupling/check.sh`. A whole-file `"TODO" in text` test used to
|
||||||
|
classify that 32-assertion check as a scaffold and, through it, report the safety-critical
|
||||||
|
R-STATE-03 as `exec=1 scaffold=1`. If your check must hold a marker as data, keep each
|
||||||
|
idiom a **standalone quoted literal in a list**; a marker with text attached (a regex
|
||||||
|
alternation, several idioms in one string) still classifies as a scaffold, because
|
||||||
|
ambiguity here only ever downgrades.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — the five auditability proofs can be produced on demand (R-SEC-03) [EXECUTABLE]
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
# Spec: docs/00 R-SEC-03 ("All the above properties MUST be demonstrable/auditable (an
|
||||||
|
# operator can prove isolation, egress path, cert validity, and backup integrity)"),
|
||||||
|
# made concrete by docs/07 §"Auditability requirements (R-SEC-03) — what an operator
|
||||||
|
# must be able to prove": Isolation (denied-probe test), Egress path (egress IP
|
||||||
|
# check), Kill-switch (fault-injection test), TLS (cert status + external check),
|
||||||
|
# Backups (DR drill) — "These proofs are part of the platform acceptance suite, not
|
||||||
|
# one-time checks", with the acceptance gate "The five auditability proofs above can
|
||||||
|
# be produced on demand."
|
||||||
|
#
|
||||||
|
# HOW THIS AVOIDS BEING A TAUTOLOGY (the whole design question for a meta-requirement)
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
# "A test exists for R-NET-01, therefore isolation is auditable" would pass in any suite that
|
||||||
|
# compiles and would keep passing while every proof rotted into a scaffold. Four distinct
|
||||||
|
# failure modes are asserted instead, each by DRIVING the real machinery:
|
||||||
|
# (a) each of the five proofs has an EXECUTABLE test — not a placeholder scaffold — at the layer
|
||||||
|
# the spec's own words require (fault injection / a DR drill => L3), taken from
|
||||||
|
# lib/status.py's own verdict so this check cannot disagree with the status report;
|
||||||
|
# (b) the proofs are PRODUCIBLE by the documented command: `run.sh --requirements <the five>`
|
||||||
|
# must resolve to real test dirs AND pass the merge gate's coverage assertion;
|
||||||
|
# (c) each Chainsaw-layer proof declares a PASS SENTINEL that lib/record_run.py can
|
||||||
|
# attribute — a proof that passes but records nothing leaves the operator with a green
|
||||||
|
# terminal and no evidence;
|
||||||
|
# (d) the evidence chain REFUSES FORGERIES: this check mounts two of its own against the
|
||||||
|
# real tools — a green JUnit for a real proof test whose log holds only that test's SKIP
|
||||||
|
# token, and a ledger row claiming `proven` at the repository's ROOT commit — and
|
||||||
|
# requires record_run.py to report not-proven and status.py to report `stale-proof`. It
|
||||||
|
# also drives `record_run.py --selftest` and `status.py --selftest`, which NOTHING in
|
||||||
|
# ci/ or run.sh ran before this test existed.
|
||||||
|
# The five proofs are not hand-listed where the spec can be read: when the read-only spec repo
|
||||||
|
# is mounted, docs/07's bullet labels are parsed and must match this check's mapping exactly,
|
||||||
|
# so a sixth proof added to the spec fails here instead of being silently unverified.
|
||||||
|
#
|
||||||
|
# WHY L0: every clause is a property of the repository and its own tooling, needs no cluster,
|
||||||
|
# and belongs in the merge gate — an audit chain is worth having only if it is intact BEFORE
|
||||||
|
# anyone needs it.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it (plus the read-only spec repo when mounted),
|
||||||
|
# WHEN lib/status.py, lib/common.sh's requirement resolver, run.sh's coverage gate,
|
||||||
|
# lib/record_run.py's sentinel parser and both evidence tools' self-tests are DRIVEN,
|
||||||
|
# and two synthetic forgeries are presented to them,
|
||||||
|
# THEN each of docs/07's five proofs maps to an executable test at the required layer, the
|
||||||
|
# documented one-command invocation selects and asserts them, each is capturable as a
|
||||||
|
# ledger row, and neither a self-skip nor a stale row can be recorded as proof.
|
||||||
|
#
|
||||||
|
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (git tree + the harness's
|
||||||
|
# own stdlib tooling; no cluster, no network); conformance/run.sh --layer l0 and ci/l0.sh run
|
||||||
|
# it directly, and this Chainsaw Test wraps the SAME script.
|
||||||
|
#
|
||||||
|
# WHAT THIS TEST FIXED WHILE BEING WRITTEN (recorded, not hidden): three of the tests that ARE
|
||||||
|
# the automated half of docs/07's isolation and TLS proofs — l2-behavioral/default-deny,
|
||||||
|
# l2-behavioral/trusted-tls and l2-behavioral/gateway-ingress-reachable — declared NO pass
|
||||||
|
# sentinel, so lib/record_run.py could never record them as proven and those two audit proofs
|
||||||
|
# were structurally unrecordable. Each now ends with a proof-of-pass sentinel step. The
|
||||||
|
# assertion here was NOT relaxed to accommodate them (R-TEST-06).
|
||||||
|
#
|
||||||
|
# KNOWN, DELIBERATE GAPS (enumerated in check.sh's header; this test claims none of them):
|
||||||
|
# * That the five proofs PASS. This asserts they are present, strong, runnable by one
|
||||||
|
# command, capturable, and that the chain refuses forgeries — not that isolation holds
|
||||||
|
# today (R-NET-01's test) or that the restore works (R-STATE-03's).
|
||||||
|
# * HUBBLE. docs/07 names Hubble flow logs as part of the isolation proof; no test in this
|
||||||
|
# suite queries Hubble. Only the denied-probe half is automated — a real, named gap.
|
||||||
|
# * LEDGER COMPLETENESS OVER TIME. Nothing offline can prove a row was never removed; that
|
||||||
|
# would need signed history.
|
||||||
|
# * R-SEC-03's wording is broader than docs/07's five bullets. Suite-wide coverage of every
|
||||||
|
# requirement is R-TEST-01/04's claim (tests/meta/harness-selfcheck) and is deliberately
|
||||||
|
# not re-asserted here.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: auditability-proofs-producible
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-SEC-03: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-SEC-03"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Given the tracked tree and the harness's own tooling, when status.py, the requirement
|
||||||
|
resolver, run.sh's coverage gate, record_run.py's sentinel parser and both evidence
|
||||||
|
tools' self-tests are driven and two synthetic forgeries are presented to them, then each
|
||||||
|
of docs/07's five auditability proofs has an executable test at the layer the spec
|
||||||
|
requires, is selectable and asserted by the documented one-command invocation, is
|
||||||
|
capturable as a ledger row, and neither a self-skip nor a stale proof can be recorded as
|
||||||
|
evidence.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: then-each-of-the-five-proofs-is-producible-and-the-evidence-chain-refuses-forgeries
|
||||||
|
description: >-
|
||||||
|
Run the offline assertion script over the tracked tree (same script ci/l0.sh and
|
||||||
|
conformance/run.sh --layer l0 execute).
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
|
# ---------------------------------------------------------------------------------------------
|
||||||
|
# NOTE ON WORDING (deliberate, do not "improve"): this file and check.sh say "placeholder
|
||||||
|
# scaffold" rather than the four-letter marker lib/status.py looks for, because that detector is
|
||||||
|
# a bare substring test over the whole file. Writing the marker in PROSE — even to say a proof
|
||||||
|
# must NOT be one — makes status.py classify THIS test as a scaffold, so the R-SEC-03 test would
|
||||||
|
# report itself as unexecutable. Observed and worked around here; the detector's crudeness is
|
||||||
|
# reported to the PM rather than loosened, since loosening a fake-green guard to suit one file is
|
||||||
|
# the wrong trade (R-TEST-06).
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — auditability-proofs-producible
|
||||||
|
# Serves: R-SEC-03 ("All the above properties MUST be demonstrable/auditable (an operator can
|
||||||
|
# prove isolation, egress path, cert validity, and backup integrity)."
|
||||||
|
# — spec docs/00-vision-and-scope.md).
|
||||||
|
#
|
||||||
|
# The spec makes this meta-requirement concrete. docs/07-security-and-secrets.md
|
||||||
|
# §"Auditability requirements (R-SEC-03) — what an operator must be able to prove":
|
||||||
|
# - **Isolation**: "service X cannot reach service Y" — via Hubble flow logs + a
|
||||||
|
# denied-probe test.
|
||||||
|
# - **Egress path**: "service X's public IP is the VPN's" — via an egress IP check.
|
||||||
|
# - **Kill-switch**: "with VPN down, X leaks nothing" — via a fault-injection test.
|
||||||
|
# - **TLS**: "every endpoint serves a valid, non-self-signed, unexpired cert" — via cert
|
||||||
|
# status + external check.
|
||||||
|
# - **Backups**: "irreplaceable data is restorable" — via a DR drill.
|
||||||
|
# "These proofs are part of the platform acceptance suite, not one-time checks."
|
||||||
|
# and its acceptance gate: "The five auditability proofs above can be produced on demand."
|
||||||
|
#
|
||||||
|
# HOW A META-REQUIREMENT IS TESTED WITHOUT BECOMING A TAUTOLOGY
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# The trap is obvious and this check is built to avoid it: "a test exists for R-NET-01,
|
||||||
|
# therefore isolation is auditable" is true of any suite that compiles, proves nothing, and
|
||||||
|
# would keep passing while every proof rotted into a scaffold. Four separate failure modes
|
||||||
|
# have to be caught for "an operator can PROVE it" to mean anything, and each one is asserted
|
||||||
|
# by DRIVING the real machinery rather than by reading files:
|
||||||
|
#
|
||||||
|
# (a) THE PROOF EXISTS, AT THE RIGHT STRENGTH. Each of the five named proofs maps to a
|
||||||
|
# requirement that must have an EXECUTABLE verifying test — not a placeholder scaffold — and,
|
||||||
|
# where the spec's own words name fault injection ("with VPN down"; "a DR drill"), an
|
||||||
|
# executable L3 test specifically. Executable-vs-scaffold is not re-derived here: this
|
||||||
|
# check RUNS lib/status.py and reads its verdict, so it cannot disagree with the
|
||||||
|
# repository's own status report.
|
||||||
|
# (b) THE PROOF IS PRODUCIBLE ON DEMAND, BY THE DOCUMENTED COMMAND. `run.sh
|
||||||
|
# --requirements <the five IDs>` must resolve to a non-empty test set and must ASSERT
|
||||||
|
# their coverage (that flag is the merge gate's own path: it passes --require to
|
||||||
|
# coverage.py). This check drives run.sh itself, so if the resolver, the labels or the
|
||||||
|
# gate wiring break, R-SEC-03 fails.
|
||||||
|
# (c) THE PROOF IS CAPTURABLE. A test that passes but records nothing leaves an operator
|
||||||
|
# with a green terminal and no evidence. For each Chainsaw-layer proof, the test must
|
||||||
|
# declare a PASS SENTINEL that lib/record_run.py can actually attribute — computed with
|
||||||
|
# record_run.py's OWN parser, so deleting a test's sentinel makes this check fail
|
||||||
|
# instead of silently making the requirement unprovable.
|
||||||
|
# (d) THE EVIDENCE CANNOT BE FORGED, AND IS NOT HONOURED WHEN STALE. This is the clause
|
||||||
|
# that makes the check bite. Two INDEPENDENT forgery attempts are mounted against the
|
||||||
|
# real tools, in a throwaway directory:
|
||||||
|
# * a Chainsaw run in which the JUnit says a real proof test PASSED while the run log
|
||||||
|
# carries only that test's SKIP token — the exact shape of a self-skipping L2/L3
|
||||||
|
# test. record_run.py must refuse to call it `proven`.
|
||||||
|
# * a ledger row claiming `proven` at a commit that PREDATES the requirement's
|
||||||
|
# implementing code — status.py must degrade it to `stale-proof`, never green.
|
||||||
|
# Both tools' own built-in self-tests (`--selftest`) are also driven, because nothing
|
||||||
|
# in ci/ or run.sh runs them today: the guards that keep a skip from becoming a proof
|
||||||
|
# were themselves unexercised in the merge gate until this check.
|
||||||
|
#
|
||||||
|
# The FIVE PROOFS ARE NOT HAND-LISTED where the spec can be read: when the read-only spec
|
||||||
|
# repo is mounted, the bullet labels are parsed out of docs/07 and must match this check's
|
||||||
|
# mapping EXACTLY. Add a sixth proof to the spec and this check fails as unmapped rather
|
||||||
|
# than quietly ignoring it.
|
||||||
|
#
|
||||||
|
# WHY L0: every clause above is a property of the repository and its own tooling, provable
|
||||||
|
# offline, and it belongs in the merge gate — the point of an audit chain is that it is intact
|
||||||
|
# BEFORE anyone needs it. Nothing here needs a cluster, and nothing here would be more true
|
||||||
|
# with one.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this check as claiming them):
|
||||||
|
# - THAT THE FIVE PROOFS PASS. This asserts the proofs are present, strong, runnable by one
|
||||||
|
# documented command, capturable as evidence, and that the evidence chain refuses
|
||||||
|
# forgeries. Whether isolation actually holds today is R-NET-01's test; whether the DR
|
||||||
|
# restore works is R-STATE-03's. R-SEC-03 is about the operator's ABILITY to produce the
|
||||||
|
# proof, which is what is asserted.
|
||||||
|
# - HUBBLE. docs/07 names "Hubble flow logs" as part of the isolation proof. No test in this
|
||||||
|
# suite queries Hubble, and this check does not pretend otherwise: it asserts the
|
||||||
|
# denied-probe half, which is the half that is automated. The Hubble half remains an
|
||||||
|
# operator-interactive capability — a real, named gap in R-SEC-03's coverage.
|
||||||
|
# - THE OPERATOR-FACING NARRATIVE. That a human can find and read the evidence (the status
|
||||||
|
# report's prose, the ledger's provenance fields) is a usability property, not a testable
|
||||||
|
# one.
|
||||||
|
# - LEDGER COMPLETENESS OVER TIME. The ledger is append-only by convention and by review;
|
||||||
|
# nothing here can prove a row was never removed from it (that would need signed history).
|
||||||
|
# - THAT THE PROOFS COVER "ALL THE ABOVE PROPERTIES". R-SEC-03's own wording is broader than
|
||||||
|
# the five bullets docs/07 enumerates. The five are what the spec made concrete and are
|
||||||
|
# what is asserted; suite-wide coverage of every requirement is R-TEST-01/04's claim,
|
||||||
|
# tested by tests/meta/harness-selfcheck, and is deliberately NOT re-asserted here.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
: "${SPEC_REPO:=${ROOT}/../home-network-k3s}"
|
||||||
|
export SPEC_REPO
|
||||||
|
|
||||||
|
cd "${ROOT}"
|
||||||
|
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
CONF = ROOT / "conformance"
|
||||||
|
LIB = CONF / "lib"
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
NOTES: list[str] = []
|
||||||
|
CHECKS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def note(msg):
|
||||||
|
NOTES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def die():
|
||||||
|
print(f"CHECK FAIL (auditability-proofs-producible): {len(FAILURES)} of {CHECKS} "
|
||||||
|
"assertions failed", file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd, **kw) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(cmd, capture_output=True, text=True, cwd=kw.pop("cwd", CONF), **kw)
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================= THE FIVE PROOFS docs/07 ENUMERATES
|
||||||
|
# key = the bullet label docs/07 uses (verified against the spec below when it is mounted)
|
||||||
|
# reqs = the requirement(s) whose verifying test IS that proof
|
||||||
|
# layer= the WEAKEST layer the spec's own words allow. Where docs/07 says "a fault-injection
|
||||||
|
# test" / "a DR drill", an L2 happy-path test does not discharge the proof.
|
||||||
|
PROOFS = {
|
||||||
|
"Isolation": {"reqs": ["R-NET-01"], "layers": {"L2", "L3"},
|
||||||
|
"why": '"service X cannot reach service Y" — via Hubble flow logs + a '
|
||||||
|
"denied-probe test"},
|
||||||
|
"Egress path": {"reqs": ["R-NET-05"], "layers": {"L2", "L3"},
|
||||||
|
"why": '"service X\'s public IP is the VPN\'s" — via an egress IP check'},
|
||||||
|
"Kill-switch": {"reqs": ["R-NET-06"], "layers": {"L3"},
|
||||||
|
"why": '"with VPN down, X leaks nothing" — via a fault-injection test'},
|
||||||
|
"TLS": {"reqs": ["R-TLS-01"], "layers": {"L2", "L3"},
|
||||||
|
"why": '"every endpoint serves a valid, non-self-signed, unexpired cert" '
|
||||||
|
"— via cert status + external check"},
|
||||||
|
"Backups": {"reqs": ["R-STATE-03"], "layers": {"L3"},
|
||||||
|
"why": '"irreplaceable data is restorable" — via a DR drill'},
|
||||||
|
}
|
||||||
|
PROOF_REQS = sorted({r for p in PROOFS.values() for r in p["reqs"]})
|
||||||
|
|
||||||
|
# ---- SELF-POLICING: the proof list must still be the SPEC's proof list ---------------
|
||||||
|
spec = Path(os.environ.get("SPEC_REPO", ""))
|
||||||
|
sec_doc = spec / "docs" / "07-security-and-secrets.md"
|
||||||
|
if sec_doc.is_file():
|
||||||
|
text = sec_doc.read_text()
|
||||||
|
m = re.search(r"^##\s+Auditability requirements \(R-SEC-03\).*?$(.*?)^##\s", text,
|
||||||
|
re.M | re.S)
|
||||||
|
check(m is not None,
|
||||||
|
"docs/07 no longer carries an '## Auditability requirements (R-SEC-03)' section — "
|
||||||
|
"the proof list this check is coupled to has moved or been removed, so the mapping "
|
||||||
|
"below can no longer be trusted (R-TEST-06: follow the spec, never the other way)")
|
||||||
|
if m:
|
||||||
|
labels = re.findall(r"^\s*-\s+\*\*([^*]+)\*\*:", m.group(1), re.M)
|
||||||
|
check(set(labels) == set(PROOFS),
|
||||||
|
f"docs/07 enumerates the auditability proofs {sorted(labels)} but this check "
|
||||||
|
f"maps {sorted(PROOFS)} — every proof the spec names must map to a verifying "
|
||||||
|
"test, and a proof this check does not know about would be silently unverified")
|
||||||
|
check(len(labels) == 5,
|
||||||
|
f"docs/07 enumerates {len(labels)} auditability proof(s); its own acceptance "
|
||||||
|
"gate says 'The five auditability proofs above can be produced on demand' — the "
|
||||||
|
"prose and the list have drifted apart")
|
||||||
|
else:
|
||||||
|
note(f"the read-only spec repo is not mounted at {spec} — the proof list could not be "
|
||||||
|
"re-derived from docs/07, so the mapping below was used as written. Every other "
|
||||||
|
"assertion still ran. (ci/l0.sh mounts the spec; a bare local run may not.)")
|
||||||
|
|
||||||
|
# ============================================ (a) EACH PROOF EXISTS, AT THE RIGHT STRENGTH
|
||||||
|
# Driven through lib/status.py so this check and the repository's own status report cannot
|
||||||
|
# disagree about what is executable and what is a scaffold.
|
||||||
|
tmp = Path(tempfile.mkdtemp(prefix="r-sec-03-"))
|
||||||
|
try:
|
||||||
|
out = tmp / "status"
|
||||||
|
st = run([sys.executable, str(LIB / "status.py"), "--repo", str(ROOT),
|
||||||
|
"--registry", str(CONF / "requirements" / "registry.yaml"),
|
||||||
|
"--tests", str(CONF / "tests"), "--out", str(out)])
|
||||||
|
check(st.returncode == 0,
|
||||||
|
f"lib/status.py exited {st.returncode} — the report an operator would use to find "
|
||||||
|
f"the audit evidence does not generate: {(st.stderr or st.stdout).strip()[:400]}")
|
||||||
|
status = {}
|
||||||
|
sj = out / "status.json"
|
||||||
|
if sj.is_file():
|
||||||
|
status = json.loads(sj.read_text())
|
||||||
|
check(bool(status.get("by_requirement")),
|
||||||
|
"lib/status.py produced no per-requirement report — the audit posture of each "
|
||||||
|
"requirement is not derivable, so R-SEC-03 cannot be assessed at all")
|
||||||
|
by_req = status.get("by_requirement", {})
|
||||||
|
|
||||||
|
for label, spec_proof in sorted(PROOFS.items()):
|
||||||
|
for rid in spec_proof["reqs"]:
|
||||||
|
rec = by_req.get(rid)
|
||||||
|
check(rec is not None,
|
||||||
|
f"proof '{label}' maps to {rid}, which the status report does not know — "
|
||||||
|
"either the registry lost the requirement or this mapping is stale")
|
||||||
|
if not rec:
|
||||||
|
continue
|
||||||
|
tests = (rec.get("verification") or {}).get("tests") or []
|
||||||
|
executable = [t for t in tests if t.get("state", "").startswith("executable")]
|
||||||
|
check(bool(executable),
|
||||||
|
f"proof '{label}' ({rid}) has no EXECUTABLE verifying test — its tests are "
|
||||||
|
f"{[t.get('name') + ':' + t.get('state', '?') for t in tests]}. docs/07: "
|
||||||
|
f"{spec_proof['why']}. A placeholder scaffold cannot be produced on demand, so the "
|
||||||
|
"operator cannot prove this property")
|
||||||
|
at_layer = [t for t in executable if t.get("layer") in spec_proof["layers"]]
|
||||||
|
check(bool(at_layer),
|
||||||
|
f"proof '{label}' ({rid}) has no executable test at layer(s) "
|
||||||
|
f"{sorted(spec_proof['layers'])} — it has "
|
||||||
|
f"{[t.get('name') + '@' + str(t.get('layer')) for t in executable]}. "
|
||||||
|
f"docs/07 requires this proof to be produced {spec_proof['why']}, and a "
|
||||||
|
"weaker layer does not discharge it (a rendering check is not a drill)")
|
||||||
|
|
||||||
|
# ==================================== (b) PRODUCIBLE ON DEMAND, BY THE DOCUMENTED COMMAND
|
||||||
|
# The resolver: --requirements must translate the five R-IDs into real test dirs.
|
||||||
|
resolver = run(["bash", "-c",
|
||||||
|
f'export CONF_ROOT="{CONF}"; . "{LIB}/common.sh"; '
|
||||||
|
f'conf_dirs_for_requirements "{" ".join(PROOF_REQS)}"'])
|
||||||
|
dirs = [l for l in resolver.stdout.splitlines() if l.strip()]
|
||||||
|
check(resolver.returncode == 0 and len(dirs) >= len(PROOF_REQS),
|
||||||
|
f"`run.sh --requirements {','.join(PROOF_REQS)}` resolves to {len(dirs)} test dir(s) "
|
||||||
|
f"for {len(PROOF_REQS)} proof requirement(s) — the single command docs/07's "
|
||||||
|
"acceptance gate depends on ('produced on demand') cannot select the proofs")
|
||||||
|
|
||||||
|
# The gate: the same flag ASSERTS coverage (run.sh passes --require to coverage.py), so a
|
||||||
|
# zero exit here is the repository's own statement that all five proofs are covered.
|
||||||
|
gate = run(["./run.sh", "--coverage-only", "--requirements", ",".join(PROOF_REQS)])
|
||||||
|
check(gate.returncode == 0,
|
||||||
|
f"`./run.sh --coverage-only --requirements {','.join(PROOF_REQS)}` exited "
|
||||||
|
f"{gate.returncode} — the merge gate's own coverage assertion rejects the audit "
|
||||||
|
f"proof set: {(gate.stderr or gate.stdout).strip()[:400]}")
|
||||||
|
|
||||||
|
# ================================================ (c) THE PROOF IS CAPTURABLE AS EVIDENCE
|
||||||
|
# Use record_run.py's OWN sentinel parser: a Chainsaw-layer proof with no declared pass
|
||||||
|
# token can pass forever and never be recorded as proof of anything.
|
||||||
|
sys.path.insert(0, str(LIB))
|
||||||
|
os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1")
|
||||||
|
sys.dont_write_bytecode = True
|
||||||
|
import record_run as rr # noqa: E402
|
||||||
|
|
||||||
|
smap = rr.sentinel_map(CONF / "tests")
|
||||||
|
check(len(smap) >= 10,
|
||||||
|
f"record_run.py's sentinel map contains {len(smap)} test(s) — the parser lost its "
|
||||||
|
"grip on the test tree, so the assertions below would assert nothing")
|
||||||
|
for label, spec_proof in sorted(PROOFS.items()):
|
||||||
|
for rid in spec_proof["reqs"]:
|
||||||
|
rec = by_req.get(rid) or {}
|
||||||
|
tests = (rec.get("verification") or {}).get("tests") or []
|
||||||
|
names = [t.get("name") for t in tests
|
||||||
|
if t.get("state", "").startswith("executable")
|
||||||
|
and t.get("layer") in spec_proof["layers"]]
|
||||||
|
for name in names:
|
||||||
|
ent = smap.get(name)
|
||||||
|
check(ent is not None,
|
||||||
|
f"proof '{label}': record_run.py does not know the test {name!r} — its "
|
||||||
|
"result could never be attributed to a ledger row")
|
||||||
|
if not ent:
|
||||||
|
continue
|
||||||
|
if ent.get("layer") in rr.SENTINEL_EXEMPT_LAYERS:
|
||||||
|
continue # offline layers are truthful by JUnit alone
|
||||||
|
check(bool(ent.get("pass")),
|
||||||
|
f"proof '{label}': the Chainsaw test {name!r} declares NO pass sentinel, "
|
||||||
|
"so lib/record_run.py can never record it as proven. It would go green "
|
||||||
|
"in the terminal and leave the operator with no evidence — exactly the "
|
||||||
|
"gap the ledger exists to close (docs/07: the proofs are 'part of the "
|
||||||
|
"platform acceptance suite, not one-time checks')")
|
||||||
|
check(bool(ent.get("terminal_pass")),
|
||||||
|
f"proof '{label}': the test {name!r} has no TERMINAL pass token, so "
|
||||||
|
"record_run.py cannot decide its verdict")
|
||||||
|
|
||||||
|
# =============================== (d) THE EVIDENCE CANNOT BE FORGED, NOR HONOURED WHEN STALE
|
||||||
|
# Both tools' own guards, which NOTHING in ci/ or run.sh drives today.
|
||||||
|
for tool in ("record_run.py", "status.py"):
|
||||||
|
r = run([sys.executable, str(LIB / tool), "--selftest"])
|
||||||
|
check(r.returncode == 0,
|
||||||
|
f"`lib/{tool} --selftest` exited {r.returncode} — the guards that keep a "
|
||||||
|
"self-skipped or stale run from being recorded as proof are broken, so nothing "
|
||||||
|
f"in the evidence chain can be trusted: {(r.stderr or r.stdout).strip()[:500]}")
|
||||||
|
|
||||||
|
# ---- FORGERY 1: a green JUnit for a REAL proof test whose log holds only a SKIP token.
|
||||||
|
# This is the precise shape of a self-skipping L2/L3 test, and it must NOT become proof.
|
||||||
|
forge_target, forge_skip = None, None
|
||||||
|
for label, spec_proof in sorted(PROOFS.items()):
|
||||||
|
for rid in spec_proof["reqs"]:
|
||||||
|
for name, ent in smap.items():
|
||||||
|
if rid in (ent.get("requirements") or []) and ent.get("skip") \
|
||||||
|
and ent.get("layer") not in rr.SENTINEL_EXEMPT_LAYERS:
|
||||||
|
forge_target, forge_skip = name, ent["skip"][0]
|
||||||
|
break
|
||||||
|
if forge_target:
|
||||||
|
break
|
||||||
|
if forge_target:
|
||||||
|
break
|
||||||
|
check(forge_target is not None,
|
||||||
|
"no Chainsaw-layer proof test declares a skip token, so the forgery attempt below "
|
||||||
|
"could not be mounted. That is not a pass: it means this check could not verify "
|
||||||
|
"that a self-skip is refused as proof, which is the whole point of the ledger")
|
||||||
|
if forge_target:
|
||||||
|
run_dir = tmp / "20260101T000000Z-forged"
|
||||||
|
run_dir.mkdir(parents=True)
|
||||||
|
(run_dir / "report.junit.xml").write_text(
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||||
|
'<testsuites><testsuite name="chainsaw" tests="1" failures="0" errors="0">'
|
||||||
|
f'<testcase name="{forge_target}" classname="forged"/>'
|
||||||
|
"</testsuite></testsuites>\n")
|
||||||
|
(run_dir / "run.log").write_text(
|
||||||
|
f"=== RUN chainsaw/{forge_target}\n"
|
||||||
|
f" === STDOUT\n {forge_skip}\n")
|
||||||
|
forged = run([sys.executable, str(LIB / "record_run.py"), "--run-dir", str(run_dir),
|
||||||
|
"--dry-run", "--ledger", str(tmp / "never-written.jsonl"),
|
||||||
|
"--tests", str(CONF / "tests"), "--repo", str(ROOT)])
|
||||||
|
verdicts = re.findall(rf"^\[record\]\s+\S+\s+(\S+)\s+{re.escape(forge_target)}\b",
|
||||||
|
forged.stdout, re.M)
|
||||||
|
check(verdicts and all(v != "proven" for v in verdicts),
|
||||||
|
f"lib/record_run.py recorded a FORGED run as {verdicts!r} for {forge_target!r}: "
|
||||||
|
"a JUnit saying `passed` with nothing in the log but that test's own SKIP token "
|
||||||
|
f"({forge_skip}). A self-skip must never become proof — with this hole every "
|
||||||
|
"audit proof in docs/07 could be produced by a cluster that skipped it")
|
||||||
|
check(not (tmp / "never-written.jsonl").exists(),
|
||||||
|
"record_run.py --dry-run WROTE to the ledger — a tool that records while "
|
||||||
|
"claiming not to cannot be trusted to record only real runs")
|
||||||
|
|
||||||
|
# ---- FORGERY 2: a ledger row claiming `proven` at a commit that predates the code.
|
||||||
|
# status.py must degrade it to stale-proof (not green), or an old green run would keep
|
||||||
|
# vouching for code it never executed.
|
||||||
|
root_sha = run(["git", "-C", str(ROOT), "rev-list", "--max-parents=0", "HEAD"])
|
||||||
|
old_sha = (root_sha.stdout.strip().splitlines() or [""])[0]
|
||||||
|
check(len(old_sha) == 40,
|
||||||
|
"the repository's root commit could not be resolved, so the stale-proof guard could "
|
||||||
|
"not be exercised (that is a gap in this check, not a pass)")
|
||||||
|
if len(old_sha) == 40 and forge_target:
|
||||||
|
stale_ledger = tmp / "stale.jsonl"
|
||||||
|
rid = sorted(smap[forge_target].get("requirements") or ["R-NET-06"])[0]
|
||||||
|
stale_ledger.write_text(json.dumps({
|
||||||
|
"ts_utc": "2026-01-01T00:00:00Z", "git_sha": old_sha, "env": "dev",
|
||||||
|
"chainsaw_version": "v0.0.0", "kube_version": "unknown",
|
||||||
|
"layer": smap[forge_target].get("layer", "L3"), "test": forge_target,
|
||||||
|
"requirements": [rid], "junit_result": "passed",
|
||||||
|
"sentinel": (smap[forge_target].get("terminal_pass") or "X"),
|
||||||
|
"proof": "proven", "run_id": "20260101T000000Z-forged",
|
||||||
|
"note": "synthetic row written by the R-SEC-03 conformance check",
|
||||||
|
}) + "\n")
|
||||||
|
out2 = tmp / "status-stale"
|
||||||
|
st2 = run([sys.executable, str(LIB / "status.py"), "--repo", str(ROOT),
|
||||||
|
"--registry", str(CONF / "requirements" / "registry.yaml"),
|
||||||
|
"--tests", str(CONF / "tests"), "--out", str(out2),
|
||||||
|
"--ledger", str(stale_ledger)])
|
||||||
|
check(st2.returncode == 0,
|
||||||
|
f"lib/status.py failed against a synthetic ledger: "
|
||||||
|
f"{(st2.stderr or st2.stdout).strip()[:300]}")
|
||||||
|
s2 = json.loads((out2 / "status.json").read_text()) if (out2 / "status.json").is_file() else {}
|
||||||
|
rec2 = (s2.get("by_requirement") or {}).get(rid) or {}
|
||||||
|
per = {p.get("test"): p.get("state") for p in ((rec2.get("proof") or {}).get("per_test") or [])}
|
||||||
|
check(per.get(forge_target) == "stale-proof",
|
||||||
|
f"a ledger row claiming `proven` for {forge_target!r} at the repository's ROOT "
|
||||||
|
f"commit was reported as {per.get(forge_target)!r} instead of 'stale-proof'. A "
|
||||||
|
"proof from a run that predates the code it vouches for is not evidence, and "
|
||||||
|
"honouring it would let one old green run certify every later change")
|
||||||
|
check((rec2.get("proof") or {}).get("state") != "proven",
|
||||||
|
f"{rid} is reported PROVEN on the strength of a row from the root commit — the "
|
||||||
|
"freshness guard is not holding")
|
||||||
|
|
||||||
|
# ---- the ledger itself is machine-readable, and no row proves a test that is gone ----
|
||||||
|
ledger = CONF / "runs" / "ledger.jsonl"
|
||||||
|
if ledger.is_file():
|
||||||
|
rows, bad = [], []
|
||||||
|
for i, line in enumerate(ledger.read_text().splitlines(), 1):
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rows.append(json.loads(line))
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
bad.append(i)
|
||||||
|
check(not bad,
|
||||||
|
f"conformance/runs/ledger.jsonl has unparsable line(s) {bad} — the audit record "
|
||||||
|
"is not machine-readable, so no proof can be derived from it")
|
||||||
|
for r in rows:
|
||||||
|
for key in ("ts_utc", "git_sha", "test", "requirements", "junit_result", "proof",
|
||||||
|
"run_id"):
|
||||||
|
check(key in r,
|
||||||
|
f"a ledger row for {r.get('test', '?')!r} lacks {key!r} — a row without "
|
||||||
|
"provenance cannot be audited")
|
||||||
|
if r.get("proof") == "proven":
|
||||||
|
check(bool(r.get("sentinel")),
|
||||||
|
f"a ledger row claims `proven` for {r.get('test')!r} with NO sentinel — "
|
||||||
|
"the sentinel is the only thing separating a real pass from a self-skip")
|
||||||
|
check(r.get("test") in smap,
|
||||||
|
f"a ledger row claims `proven` for {r.get('test')!r}, which is not a "
|
||||||
|
"test in the suite any more — a proof of something that no longer exists")
|
||||||
|
check("-dirty" not in str(r.get("git_sha", "")),
|
||||||
|
f"a ledger row claims `proven` for {r.get('test')!r} from a DIRTY tree "
|
||||||
|
f"({r.get('git_sha')}) — what ran is not what is committed")
|
||||||
|
else:
|
||||||
|
note("conformance/runs/ledger.jsonl does not exist yet — the ledger-shape assertions "
|
||||||
|
"were skipped; every other assertion ran, and the forgery probes above use "
|
||||||
|
"synthetic ledgers, not this file")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- verdict
|
||||||
|
# NO-OP GUARD: a check that quietly asserts nothing is worse than no check.
|
||||||
|
check(CHECKS >= 30,
|
||||||
|
f"only {CHECKS} assertions ran — the check degenerated to a near-no-op and its pass "
|
||||||
|
"would be meaningless")
|
||||||
|
|
||||||
|
if FAILURES:
|
||||||
|
die()
|
||||||
|
for n in NOTES:
|
||||||
|
print(f" note: {n}")
|
||||||
|
print(f"check OK: auditability-proofs-producible — {CHECKS} assertions; the five docs/07 "
|
||||||
|
f"proofs ({', '.join(sorted(PROOFS))}) each map to an executable test at the layer the "
|
||||||
|
f"spec's own wording requires, `run.sh --requirements {','.join(PROOF_REQS)}` resolves "
|
||||||
|
"and asserts them, each Chainsaw-layer proof declares a sentinel record_run.py can "
|
||||||
|
"attribute, both evidence tools' self-tests pass, and two independent forgeries — a "
|
||||||
|
"self-skip presented as a pass, and a `proven` row from the root commit — were REFUSED. "
|
||||||
|
"R-SEC-03's producibility half only: whether each proof PASSES is its own "
|
||||||
|
"requirement's test, and the Hubble half of the isolation proof is automated nowhere "
|
||||||
|
"(see the header's gap list).")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — dev/test nodes are provisioned as code, end to end (R-ENV-01) [EXECUTABLE]
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
# Spec: docs/00 R-ENV-01 ("dev/test MUST be provisioned as local KVMs via IaC:
|
||||||
|
# created, baselined, cluster-joined, and managed as code"); the four stages
|
||||||
|
# are enumerated in docs/02 §"Node lifecycle stages".
|
||||||
|
#
|
||||||
|
# WHY L0: R-ENV-01 is a requirement about HOW the dev nodes came to exist, and a live
|
||||||
|
# Node object cannot answer it — a node built by OpenTofu and one built by hand in a
|
||||||
|
# hypervisor UI are indistinguishable through the Kubernetes API. What is provable, with
|
||||||
|
# no cluster, is that a COMPLETE and EXECUTABLE provisioning path exists for all four
|
||||||
|
# stages and covers EVERY dev node. For the Ansible half that is proven by DRIVING the
|
||||||
|
# real toolchain offline (`--syntax-check`, `--list-hosts` — neither opens a connection
|
||||||
|
# to any node), not by reading files: a chain that does not resolve fails the check.
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC: no hostname, address, prefix or node count appears in the test.
|
||||||
|
# The dev group names are read from the playbooks' own default host patterns, the node
|
||||||
|
# names are generated from the IaC module's prefix/count variables, and the two sets are
|
||||||
|
# compared against each other — so the assertion survives a rename or a resize.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it,
|
||||||
|
# WHEN the provisioning chain is parsed and (where offline execution is possible) driven,
|
||||||
|
# THEN the IaC module creates the dev nodes on a LOCAL KVM hypervisor (allowlisted
|
||||||
|
# provider — a cloud provider fails), from a for_each map rather than a count index,
|
||||||
|
# each with CPU/RAM/OS disk/dedicated Longhorn data disk/NIC/cloud-init seed; the
|
||||||
|
# node set it creates is EXACTLY the set Ansible manages; the baseline stage applies
|
||||||
|
# the package floor, kernel modules, sysctls, data-disk mount, swap-off and iscsid
|
||||||
|
# declaratively; one k3s role founds the cluster with --cluster-init and joins the
|
||||||
|
# rest with --server while disabling flannel/kube-proxy/traefik/servicelb at
|
||||||
|
# install; and site.yml's chain (baseline -> k3s -> Cilium -> verify -> Flux)
|
||||||
|
# resolves and targets every dev node at every stage.
|
||||||
|
#
|
||||||
|
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (git, PyYAML, and —
|
||||||
|
# where present — ansible-playbook in no-connection modes; no cluster, no network);
|
||||||
|
# conformance/run.sh --layer l0 and ci/l0.sh run it directly, and this Chainsaw Test
|
||||||
|
# wraps the SAME script.
|
||||||
|
#
|
||||||
|
# KNOWN, DELIBERATE GAPS (enumerated in check.sh's header; this test claims none of them):
|
||||||
|
# * That the RUNNING dev VMs were created by this module — needs the OpenTofu state or
|
||||||
|
# hypervisor access, neither available offline.
|
||||||
|
# * `tofu validate` — needs `tofu init` (provider registry), i.e. network. The module is
|
||||||
|
# parsed, not evaluated; a network-allowed CI stage would close this.
|
||||||
|
# * "Never hand-edited on the box" (docs/02 stage 4's second half) — a live drift check.
|
||||||
|
# * REBUILD/re-converge (docs/02's own proof line) — exercised at L3 by
|
||||||
|
# tests/l3-fault-injection/node-loss (R-STATE-02).
|
||||||
|
# * docs/02 prose puts the baseline in cloud-init; the implementation moved it into the
|
||||||
|
# Ansible node_baseline role. R-ENV-01 requires the node to be "baselined ... as code",
|
||||||
|
# not that cloud-init be the tool, so the check asserts the STAGE and RECORDS the
|
||||||
|
# divergence rather than hiding it.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: dev-nodes-provisioned-as-code
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-ENV-01: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-ENV-01"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Given the tracked tree, when the dev provisioning chain is parsed and driven
|
||||||
|
offline, then every dev node is created by local-KVM IaC, baselined, cluster-joined
|
||||||
|
and handed to Flux by one code path that covers all of them — with no node outside
|
||||||
|
it and no stage missing.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: then-every-dev-node-is-created-baselined-joined-and-managed-as-code
|
||||||
|
description: >-
|
||||||
|
Run the offline assertion script over the tracked tree (same script ci/l0.sh and
|
||||||
|
conformance/run.sh --layer l0 execute).
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — dev-nodes-provisioned-as-code
|
||||||
|
# Serves: R-ENV-01 ("dev/test MUST be provisioned as local KVMs via IaC: created,
|
||||||
|
# baselined, cluster-joined, and managed as code." — spec
|
||||||
|
# docs/00-vision-and-scope.md; the four stages are enumerated in
|
||||||
|
# docs/02-environments-and-provisioning.md §"Node lifecycle stages
|
||||||
|
# (R-ENV-01 — 'created, baselined, cluster-joined, managed as code')":
|
||||||
|
# 1. Create — OpenTofu instantiates the VM
|
||||||
|
# 2. Baseline — OS baseline and prerequisites
|
||||||
|
# 3. Cluster-join — Ansible installs/joins k3s with Cilium as CNI; disables
|
||||||
|
# k3s bundled flannel/traefik/servicelb/kube-proxy
|
||||||
|
# 4. Manage — GitOps-managed by Flux; node config changes are made via IaC
|
||||||
|
# and re-applied, never hand-edited on the box.)
|
||||||
|
#
|
||||||
|
# WHY L0 IS THE RIGHT LAYER, AND WHAT IT MEANS TO TEST THIS BEHAVIOURALLY
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# R-ENV-01 is a requirement about HOW the dev nodes come to exist. The live cluster
|
||||||
|
# cannot answer it: a Kubernetes node object looks identical whether it was built by
|
||||||
|
# OpenTofu or by hand in a hypervisor UI (there is no API field for "was I created by
|
||||||
|
# IaC"). What CAN be proven, with no cluster, is that a COMPLETE, EXECUTABLE
|
||||||
|
# provisioning path exists for all four stages and covers every dev node — and, for the
|
||||||
|
# Ansible half, that is proven by RUNNING the real toolchain in a no-connection mode
|
||||||
|
# (`--syntax-check`, `--list-hosts`) rather than by reading the files, so a chain that
|
||||||
|
# does not actually resolve fails here. The IaC half is asserted by parsing the module:
|
||||||
|
# `tofu init/validate` needs the provider registry, and L0 is offline by contract.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it,
|
||||||
|
# WHEN the provisioning chain is parsed and (where offline execution is possible)
|
||||||
|
# actually driven,
|
||||||
|
# THEN CREATE: the IaC module targets a LOCAL KVM hypervisor (every declared provider
|
||||||
|
# is a KVM-family hypervisor provider — no cloud provider may appear),
|
||||||
|
# instantiates its nodes from a for_each map (never a count index, so one
|
||||||
|
# node can be replaced without disturbing its siblings), and gives each
|
||||||
|
# node CPU, RAM, an OS disk, a DEDICATED Longhorn data disk, a NIC and a
|
||||||
|
# cloud-init seed carrying the login user + SSH key;
|
||||||
|
# AND the node set it creates is EXACTLY the dev node set Ansible manages —
|
||||||
|
# names derived from the module's own prefix/count, so no node can be
|
||||||
|
# hand-added to the inventory or hand-created in the hypervisor;
|
||||||
|
# BASELINE: a baseline stage exists as declarative Ansible (package floor, kernel
|
||||||
|
# modules persisted AND loaded, sysctls, the Longhorn data disk formatted
|
||||||
|
# and mounted, swap off, iscsid enabled), driven by variables with
|
||||||
|
# defaults rather than per-host special cases;
|
||||||
|
# JOIN: one role installs k3s for both roles of every node — `--cluster-init`
|
||||||
|
# on the primary, `--server` join on the secondaries — with flannel,
|
||||||
|
# kube-proxy, traefik and servicelb disabled AT INSTALL, and Cilium
|
||||||
|
# installed as the CNI afterwards;
|
||||||
|
# MANAGE: site.yml chains baseline -> k3s -> Cilium -> verify -> Flux bootstrap;
|
||||||
|
# `ansible-playbook --syntax-check` on that chain SUCCEEDS; and
|
||||||
|
# `--list-hosts` shows every one of those stages targeting EVERY dev
|
||||||
|
# node, so no node is outside the code path.
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC: no hostname, address, prefix or node count is written down here.
|
||||||
|
# The dev group names come from the playbooks' own default host patterns, the node names
|
||||||
|
# from the IaC module's prefix/count variables, and the two sets are compared to each
|
||||||
|
# other. This check therefore keeps working when the estate is renamed or resized.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this test as claiming them):
|
||||||
|
# - That the RUNNING dev VMs were in fact created by this module. Nothing offline can:
|
||||||
|
# it would need the OpenTofu state (a remote R2 backend + credentials) or hypervisor
|
||||||
|
# access. `tofu plan` against the live estate is the operator's own check.
|
||||||
|
# - That the module APPLIES cleanly. `tofu validate` needs `tofu init`, which downloads
|
||||||
|
# the provider from the registry; L0 is offline by contract, so the module is parsed,
|
||||||
|
# not evaluated. ci/lint.sh does not cover .tf either — a gap worth closing in CI
|
||||||
|
# with a network-allowed stage, not here.
|
||||||
|
# - "Never hand-edited on the box" (stage 4's second half) — a drift check against the
|
||||||
|
# live nodes, which is an operator/Ansible-check-mode exercise, not a repo property.
|
||||||
|
# - REBUILD/re-converge ("destroy a dev node VM and rebuild it", docs/02's own proof
|
||||||
|
# line) — that is exercised at L3 by tests/l3-fault-injection/node-loss (R-STATE-02).
|
||||||
|
# - That the baseline actually ran on a node (cluster/host state), and that the
|
||||||
|
# hypervisor is on the operator's own LAN rather than remote — "local" is asserted as
|
||||||
|
# "a KVM hypervisor provider driving a self-hosted endpoint variable", not as a
|
||||||
|
# network topology fact.
|
||||||
|
# - DEVIATION FROM docs/02 PROSE, ASSERTED DELIBERATELY: docs/02 describes stage 2 as
|
||||||
|
# cloud-init applying the OS baseline. The implementation moved the rich baseline into
|
||||||
|
# the Ansible `node_baseline` role (cloud-init keeps hostname/user/SSH/network),
|
||||||
|
# because Proxmox provisioning is API-only and a custom cloud-init snippet needs
|
||||||
|
# SSH/SFTP to the hypervisor. R-ENV-01's own wording requires the node to be
|
||||||
|
# "baselined ... as code", not that cloud-init be the tool, so this check asserts the
|
||||||
|
# STAGE, wherever it lives, and records the divergence rather than hiding it.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
cd "${ROOT}"
|
||||||
|
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||||
|
file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
NOTES: list[str] = []
|
||||||
|
CHECKS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def note(msg):
|
||||||
|
NOTES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# A LOCAL DEPENDENCY the repo deliberately does not carry — the vault passphrase (kept
|
||||||
|
# outside the repo, R-SEC-01) or the Galaxy collections (gitignored; installed from
|
||||||
|
# collections/requirements.yml). Their absence is an ENVIRONMENT gap, not a repo defect,
|
||||||
|
# and is the ONLY tolerated reason the driven sub-assertions may not run. Deliberately
|
||||||
|
# narrow: every other non-zero exit is a real failure. The parse assertions always run.
|
||||||
|
ENV_ONLY_FAILURE = re.compile(
|
||||||
|
r"vault password|vault-id|Attempting to decrypt"
|
||||||
|
r"|No module named 'ansible_collections|couldn't resolve module/action", re.I)
|
||||||
|
|
||||||
|
TOFU = ROOT / "infra" / "tofu"
|
||||||
|
ANS = ROOT / "infra" / "ansible"
|
||||||
|
check(TOFU.is_dir(), "infra/tofu/ is missing — the CREATE stage has no IaC at all (R-ENV-01)")
|
||||||
|
check(ANS.is_dir(), "infra/ansible/ is missing — the BASELINE/JOIN/MANAGE stages have no code")
|
||||||
|
if not TOFU.is_dir() or not ANS.is_dir():
|
||||||
|
print(f"CHECK FAIL (dev-nodes-provisioned-as-code): {len(FAILURES)} of {CHECKS} failed",
|
||||||
|
file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
TF = "\n".join((p.read_text(errors="replace") for p in sorted(TOFU.glob("*.tf"))))
|
||||||
|
check(len(TF) > 500, "infra/tofu/*.tf is empty or a stub — nothing creates the dev nodes")
|
||||||
|
|
||||||
|
# ============================================================ STAGE 1 — CREATE (IaC)
|
||||||
|
# A LOCAL KVM hypervisor, by allowlist: any provider outside this set fails, so moving
|
||||||
|
# dev onto a cloud (or anything that is not a KVM hypervisor the operator runs) is caught
|
||||||
|
# here rather than being noticed later. Each entry is justified, not just listed:
|
||||||
|
# bpg/proxmox Proxmox VE — a KVM/QEMU hypervisor the operator hosts.
|
||||||
|
# dmacvicar/libvirt libvirt/KVM directly (the pre-Proxmox substrate).
|
||||||
|
KVM_PROVIDERS = {"bpg/proxmox", "dmacvicar/libvirt"}
|
||||||
|
providers = dict(re.findall(r'source\s*=\s*"([^"]+)"', TF) and
|
||||||
|
[(m.group(1), m.group(2)) for m in
|
||||||
|
re.finditer(r'(\w[\w-]*)\s*=\s*\{[^}]*?source\s*=\s*"([^"]+)"', TF, re.S)])
|
||||||
|
check(len(providers) > 0,
|
||||||
|
"infra/tofu declares no required_providers — the substrate is unpinned, so 'via IaC' "
|
||||||
|
"cannot be checked at all")
|
||||||
|
for alias, source in sorted(providers.items()):
|
||||||
|
check(source.lower() in KVM_PROVIDERS,
|
||||||
|
f"infra/tofu declares provider {alias}={source!r}, which is not a local KVM hypervisor "
|
||||||
|
f"provider (allowed: {sorted(KVM_PROVIDERS)}) — R-ENV-01 requires dev/test to be LOCAL KVMs")
|
||||||
|
|
||||||
|
# The VM resource: for_each-keyed, and carrying the hardware + seed docs/02 enumerates.
|
||||||
|
vm_blocks = re.findall(r'resource\s+"([\w]*(?:_vm|_domain))"\s+"(\w+)"\s*\{(.*?)\n\}',
|
||||||
|
TF, re.S)
|
||||||
|
check(len(vm_blocks) >= 1,
|
||||||
|
"no VM/domain resource found in infra/tofu — nothing in IaC creates a dev node")
|
||||||
|
for rtype, rname, body in vm_blocks:
|
||||||
|
check("for_each" in body,
|
||||||
|
f'resource "{rtype}" "{rname}" is not for_each-keyed. A count index makes single-node '
|
||||||
|
"replace unsafe (a -target'ed destroy cascaded to sibling nodes — DECISIONS-LOG F1), so "
|
||||||
|
"'created ... as code' would not survive a rebuild")
|
||||||
|
check(not re.search(r"^\s*count\s*=", body, re.M),
|
||||||
|
f'resource "{rtype}" "{rname}" uses a positional count index')
|
||||||
|
for want, why in (
|
||||||
|
(r"\bcpu\b", "CPU"),
|
||||||
|
(r"\bmemory\b", "RAM"),
|
||||||
|
(r"network_device|network_interface", "a NIC"),
|
||||||
|
(r"initialization|cloud_init|cloudinit", "a cloud-init seed"),
|
||||||
|
):
|
||||||
|
check(re.search(want, body) is not None,
|
||||||
|
f'resource "{rtype}" "{rname}" declares no {why} — docs/02 stage 1 requires CPU, RAM, '
|
||||||
|
"disks, NICs and a cloud-init seed to come from IaC")
|
||||||
|
disks = len(re.findall(r"^\s*disk\s*\{", body, re.M))
|
||||||
|
check(disks >= 2,
|
||||||
|
f'resource "{rtype}" "{rname}" declares {disks} disk block(s); docs/02 requires the OS '
|
||||||
|
"disk PLUS a dedicated data disk for Longhorn")
|
||||||
|
check(re.search(r"user_account|ssh_authorized_key|ssh_keys", body) is not None,
|
||||||
|
f'resource "{rtype}" "{rname}" seeds no login user / SSH key — the node could not be '
|
||||||
|
"reached by the BASELINE stage without a hand step")
|
||||||
|
|
||||||
|
# Node identity comes from variables, and those variables define the whole node set.
|
||||||
|
def tf_default(var: str):
|
||||||
|
m = re.search(r'variable\s+"' + re.escape(var) + r'"\s*\{(.*?)\n\}', TF, re.S)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
d = re.search(r'default\s*=\s*(.+)', m.group(1))
|
||||||
|
return d.group(1).strip() if d else None
|
||||||
|
|
||||||
|
|
||||||
|
prefix_raw = tf_default("node_name_prefix")
|
||||||
|
count_raw = tf_default("node_count")
|
||||||
|
ips_raw = tf_default("node_ips")
|
||||||
|
check(prefix_raw is not None and count_raw is not None,
|
||||||
|
"infra/tofu has no node_name_prefix/node_count variables — the node set is not derived "
|
||||||
|
"from code, so the created nodes cannot be compared with the managed ones")
|
||||||
|
prefix = (prefix_raw or "").strip('"')
|
||||||
|
try:
|
||||||
|
node_count = int((count_raw or "0").strip())
|
||||||
|
except ValueError:
|
||||||
|
node_count = 0
|
||||||
|
check(node_count >= 1, f"node_count default is {count_raw!r} — no node would be created")
|
||||||
|
if ips_raw is not None:
|
||||||
|
n_ips = len(re.findall(r'"[^"]+"', ips_raw))
|
||||||
|
check(n_ips >= node_count,
|
||||||
|
f"node_ips default has {n_ips} address(es) for node_count={node_count} — the module "
|
||||||
|
"cannot address every node it creates")
|
||||||
|
check((TOFU / "outputs.tf").is_file() and
|
||||||
|
re.search(r'output\s+"(ansible_inventory|nodes)"', (TOFU / "outputs.tf").read_text()) is not None,
|
||||||
|
"infra/tofu/outputs.tf surfaces no node inventory output — the JOIN stage would have to be "
|
||||||
|
"told the node facts by hand instead of consuming them from IaC")
|
||||||
|
|
||||||
|
# ------------------------------------------------- the dev node set, derived twice over
|
||||||
|
# Group names are taken from the PLAYBOOKS' own default host patterns, so nothing here
|
||||||
|
# restates the estate's naming.
|
||||||
|
PLAYBOOKS = ANS / "playbooks"
|
||||||
|
|
||||||
|
|
||||||
|
def default_patterns(playbook: str) -> list[str]:
|
||||||
|
p = PLAYBOOKS / playbook
|
||||||
|
if not p.is_file():
|
||||||
|
return []
|
||||||
|
return re.findall(r"default\('([^']+)'\)", p.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
dev_groups = set(default_patterns("node-baseline.yml") + default_patterns("k3s.yml"))
|
||||||
|
check(len(dev_groups) > 0,
|
||||||
|
"no playbook declares a default host pattern — the dev target is not expressed in code")
|
||||||
|
|
||||||
|
INV = ANS / "inventory" / "hosts.ini"
|
||||||
|
check(INV.is_file(), "infra/ansible/inventory/hosts.ini is missing — no node is managed as code")
|
||||||
|
ini_groups: dict[str, list[str]] = {}
|
||||||
|
children: dict[str, list[str]] = {}
|
||||||
|
cur = None
|
||||||
|
for line in (INV.read_text().splitlines() if INV.is_file() else []):
|
||||||
|
s = line.strip()
|
||||||
|
if not s or s.startswith("#"):
|
||||||
|
continue
|
||||||
|
m = re.match(r"^\[([^\]]+)\]$", s)
|
||||||
|
if m:
|
||||||
|
cur = m.group(1)
|
||||||
|
(children if cur.endswith(":children") else ini_groups).setdefault(
|
||||||
|
cur.split(":")[0] if cur.endswith(":children") else cur, [])
|
||||||
|
continue
|
||||||
|
if cur is None or cur.endswith(":vars"):
|
||||||
|
continue
|
||||||
|
host = s.split()[0]
|
||||||
|
if cur.endswith(":children"):
|
||||||
|
children.setdefault(cur.split(":")[0], []).append(host)
|
||||||
|
else:
|
||||||
|
ini_groups.setdefault(cur, []).append(host)
|
||||||
|
|
||||||
|
|
||||||
|
def hosts_in(group: str, seen=None) -> set[str]:
|
||||||
|
seen = seen or set()
|
||||||
|
if group in seen:
|
||||||
|
return set()
|
||||||
|
seen.add(group)
|
||||||
|
out = set(ini_groups.get(group, []))
|
||||||
|
for child in children.get(group, []):
|
||||||
|
out |= hosts_in(child, seen)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
dev_hosts: set[str] = set()
|
||||||
|
for g in sorted(dev_groups):
|
||||||
|
dev_hosts |= hosts_in(g)
|
||||||
|
check(len(dev_hosts) > 0,
|
||||||
|
f"the dev groups {sorted(dev_groups)} resolve to NO hosts in hosts.ini — nothing is managed")
|
||||||
|
expected = {f"{prefix}{i + 1}" for i in range(node_count)}
|
||||||
|
check(dev_hosts == expected,
|
||||||
|
f"the dev nodes Ansible manages ({sorted(dev_hosts)}) are not exactly the nodes the IaC "
|
||||||
|
f"module creates ({sorted(expected)}) — a node outside IaC is a hand-built node (R-ENV-01)")
|
||||||
|
|
||||||
|
# ============================================================ STAGE 2 — BASELINE (code)
|
||||||
|
BASE_ROLE = ANS / "roles" / "node_baseline"
|
||||||
|
check(BASE_ROLE.is_dir(),
|
||||||
|
"no node_baseline role — the BASELINE stage has no code (docs/02 stage 2)")
|
||||||
|
base_tasks = "\n".join(p.read_text(errors="replace")
|
||||||
|
for p in sorted((BASE_ROLE / "tasks").glob("*.yml"))) if BASE_ROLE.is_dir() else ""
|
||||||
|
base_defaults = "\n".join(p.read_text(errors="replace")
|
||||||
|
for p in sorted((BASE_ROLE / "defaults").glob("*.yml"))) if BASE_ROLE.is_dir() else ""
|
||||||
|
# Each item docs/02 names for stage 2, matched on the ANSIBLE MODULE that would do it —
|
||||||
|
# a declarative, idempotent task, not a shell one-liner.
|
||||||
|
for pattern, what in (
|
||||||
|
(r"ansible\.builtin\.apt:", "a package baseline (apt module)"),
|
||||||
|
(r"modprobe:", "kernel modules loaded (modprobe module)"),
|
||||||
|
(r"modules-load|/etc/modules", "kernel modules PERSISTED across reboot"),
|
||||||
|
(r"sysctl", "sysctl tuning"),
|
||||||
|
(r"filesystem:|mkfs", "a filesystem on the Longhorn data disk"),
|
||||||
|
(r"mount:|ansible\.posix\.mount", "the Longhorn data disk MOUNTED"),
|
||||||
|
(r"swap", "swap disabled"),
|
||||||
|
(r"iscsid", "iscsid enabled (Longhorn prerequisite)"),
|
||||||
|
):
|
||||||
|
check(re.search(pattern, base_tasks) is not None,
|
||||||
|
f"node_baseline does not declare {what} — docs/02 stage 2 requires the OS baseline and "
|
||||||
|
"k3s/Longhorn/KubeVirt prerequisites to be applied as code")
|
||||||
|
check(re.search(r"^node_baseline_packages:", base_defaults, re.M) is not None and
|
||||||
|
re.search(r"^node_baseline_kernel_modules:", base_defaults, re.M) is not None,
|
||||||
|
"node_baseline's package/module sets are not variables with defaults — a per-host special "
|
||||||
|
"case is not 'managed as code' (and could not be scoped per environment, R-ENV-02)")
|
||||||
|
|
||||||
|
# ========================================================== STAGE 3 — CLUSTER-JOIN (k3s)
|
||||||
|
K3S_ROLE = ANS / "roles" / "k3s_server"
|
||||||
|
k3s_text = "\n".join(p.read_text(errors="replace")
|
||||||
|
for p in sorted((K3S_ROLE).rglob("*.yml"))) if K3S_ROLE.is_dir() else ""
|
||||||
|
check(K3S_ROLE.is_dir() and len(k3s_text) > 200,
|
||||||
|
"no k3s_server role — nothing joins the nodes into a cluster (docs/02 stage 3)")
|
||||||
|
check("--cluster-init" in k3s_text,
|
||||||
|
"the k3s role never passes --cluster-init — the HA embedded-etcd cluster is never founded")
|
||||||
|
check(re.search(r"--server\s+https", k3s_text) is not None,
|
||||||
|
"the k3s role never joins secondaries with --server — nodes would not form one cluster")
|
||||||
|
check(re.search(r"K3S_TOKEN|--token", k3s_text) is not None,
|
||||||
|
"the k3s role never supplies a join token — the join could only be completed by hand")
|
||||||
|
# The bundled components docs/02 requires to be disabled AT INSTALL, each checked by the
|
||||||
|
# flag that actually disables it.
|
||||||
|
for pattern, what in (
|
||||||
|
(r"--flannel-backend=none", "flannel (Cilium is the CNI)"),
|
||||||
|
(r"--disable-kube-proxy", "kube-proxy (Cilium kube-proxy replacement)"),
|
||||||
|
(r"traefik", "traefik (Gateway API via Cilium)"),
|
||||||
|
(r"servicelb", "servicelb"),
|
||||||
|
):
|
||||||
|
check(re.search(pattern, k3s_text) is not None,
|
||||||
|
f"the k3s install does not disable {what} — docs/02 stage 3 requires it disabled at "
|
||||||
|
"install, or the bundled component fights the platform's own")
|
||||||
|
check((ANS / "roles" / "cilium").is_dir(),
|
||||||
|
"no cilium role — stage 3 requires Cilium to be installed as the CNI at bootstrap")
|
||||||
|
check((ANS / "roles" / "flux_bootstrap").is_dir(),
|
||||||
|
"no flux_bootstrap role — stage 4's hand-off to GitOps has no code")
|
||||||
|
|
||||||
|
# ============================================== STAGE 4 — MANAGE + the chain is REAL
|
||||||
|
SITE = PLAYBOOKS / "site.yml"
|
||||||
|
check(SITE.is_file(), "no playbooks/site.yml — the provisioning chain is not expressed as one path")
|
||||||
|
site_txt = SITE.read_text() if SITE.is_file() else ""
|
||||||
|
order = [m for m in re.findall(r"import_playbook:\s*(\S+)", site_txt)]
|
||||||
|
check(order[:1] == ["node-baseline.yml"],
|
||||||
|
f"site.yml imports {order} — the BASELINE stage must come first (docs/02 sequencing)")
|
||||||
|
for want in ("k3s.yml", "cilium.yml", "flux-bootstrap.yml"):
|
||||||
|
check(want in order, f"site.yml does not import {want} — a lifecycle stage is missing from the chain")
|
||||||
|
if "k3s.yml" in order and "cilium.yml" in order:
|
||||||
|
check(order.index("k3s.yml") < order.index("cilium.yml"),
|
||||||
|
"site.yml installs Cilium before k3s — the CNI cannot precede the cluster")
|
||||||
|
|
||||||
|
# --- DRIVE THE REAL TOOLCHAIN, OFFLINE ------------------------------------------------
|
||||||
|
# --syntax-check and --list-hosts open NO connection to any node: they resolve the
|
||||||
|
# playbook, roles, collections and inventory only. Running them is the difference between
|
||||||
|
# "the chain is written down" and "the chain resolves".
|
||||||
|
if shutil.which("ansible-playbook") is None:
|
||||||
|
note("ansible-playbook is not installed here (e.g. the in-cluster chainsaw image), so the "
|
||||||
|
"chain was PARSED but not DRIVEN; the merge gate's container has it and does drive it")
|
||||||
|
else:
|
||||||
|
# cwd=infra/ansible so the tracked ansible.cfg (roles_path, collections_path, inventory)
|
||||||
|
# is the one in force — the same resolution a real run gets.
|
||||||
|
def ansible(*args) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(["ansible-playbook", *args], cwd=ANS,
|
||||||
|
capture_output=True, text=True)
|
||||||
|
|
||||||
|
syn = ansible("--syntax-check", "playbooks/site.yml")
|
||||||
|
syn_out = (syn.stderr or "") + (syn.stdout or "")
|
||||||
|
lh = None
|
||||||
|
if syn.returncode != 0 and ENV_ONLY_FAILURE.search(syn_out):
|
||||||
|
# NARROW, SELF-POLICING SKIP — see ENV_ONLY_FAILURE above.
|
||||||
|
note("ansible-playbook lacks a LOCAL dependency kept outside the repo by design (the vault "
|
||||||
|
"passphrase and/or the gitignored Galaxy collections), so --syntax-check/--list-hosts "
|
||||||
|
"were not driven; every parse assertion above still ran")
|
||||||
|
else:
|
||||||
|
check(syn.returncode == 0,
|
||||||
|
"`ansible-playbook --syntax-check playbooks/site.yml` FAILED — the provisioning chain "
|
||||||
|
f"does not even load: {syn_out.strip()[:400]}")
|
||||||
|
lh = ansible("--list-hosts", "playbooks/site.yml")
|
||||||
|
check(lh.returncode == 0,
|
||||||
|
"`ansible-playbook --list-hosts playbooks/site.yml` FAILED — the chain does not "
|
||||||
|
f"resolve against the tracked inventory: "
|
||||||
|
f"{((lh.stderr or '') + (lh.stdout or '')).strip()[:400]}")
|
||||||
|
if lh is not None and lh.returncode == 0:
|
||||||
|
# Parse plays -> hosts. NEVER `ansible-inventory --list`: it prints DECRYPTED vault values.
|
||||||
|
plays: list[tuple[str, set[str]]] = []
|
||||||
|
name, hosts, in_hosts = None, set(), False
|
||||||
|
for line in lh.stdout.splitlines():
|
||||||
|
m = re.match(r"\s*play #\d+ \([^)]*\):\s*(.*?)\s*TAGS:", line)
|
||||||
|
if m:
|
||||||
|
if name is not None:
|
||||||
|
plays.append((name, hosts))
|
||||||
|
name, hosts, in_hosts = m.group(1), set(), False
|
||||||
|
continue
|
||||||
|
if re.match(r"\s*hosts \(\d+\):", line):
|
||||||
|
in_hosts = True
|
||||||
|
continue
|
||||||
|
if in_hosts and line.strip() and not line.strip().startswith("pattern:"):
|
||||||
|
hosts.add(line.strip())
|
||||||
|
if name is not None:
|
||||||
|
plays.append((name, hosts))
|
||||||
|
check(len(plays) >= 4,
|
||||||
|
f"--list-hosts resolved {len(plays)} play(s) — the four-stage chain did not expand")
|
||||||
|
# Every lifecycle stage must cover EVERY dev node. A stage that reaches two of three
|
||||||
|
# nodes means the third was finished by hand.
|
||||||
|
STAGES = {
|
||||||
|
"baseline": r"baseline",
|
||||||
|
"cluster-join": r"^k3s (primary|secondar)",
|
||||||
|
"cni": r"cilium",
|
||||||
|
"manage (flux hand-off)": r"flux",
|
||||||
|
}
|
||||||
|
for stage, rx in STAGES.items():
|
||||||
|
covered: set[str] = set()
|
||||||
|
matched = 0
|
||||||
|
for pname, phosts in plays:
|
||||||
|
if re.search(rx, pname, re.I):
|
||||||
|
matched += 1
|
||||||
|
covered |= phosts
|
||||||
|
check(matched >= 1, f"no play in site.yml implements the {stage} stage")
|
||||||
|
check(covered == dev_hosts,
|
||||||
|
f"the {stage} stage targets {sorted(covered)}, not every dev node "
|
||||||
|
f"{sorted(dev_hosts)} — a node outside the code path is a hand-provisioned "
|
||||||
|
"node (R-ENV-01)")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- verdict
|
||||||
|
# NO-OP GUARD: a check that quietly asserts nothing is worse than no check.
|
||||||
|
check(CHECKS >= 30,
|
||||||
|
f"only {CHECKS} assertions ran — the check degenerated to a near-no-op and its pass "
|
||||||
|
"would be meaningless")
|
||||||
|
|
||||||
|
if FAILURES:
|
||||||
|
print(f"CHECK FAIL (dev-nodes-provisioned-as-code): {len(FAILURES)} of {CHECKS} assertions failed",
|
||||||
|
file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
for n in NOTES:
|
||||||
|
print(f" note: {n}")
|
||||||
|
print(f"check OK: dev-nodes-provisioned-as-code — {CHECKS} assertions; "
|
||||||
|
f"{len(dev_hosts)} dev node(s) {sorted(dev_hosts)} created by IaC ({sorted(providers.values())}), "
|
||||||
|
f"baselined, cluster-joined and Flux-managed by the same chain. R-ENV-01's authored-path "
|
||||||
|
"clause only — the live VMs' provenance, `tofu validate`, and rebuild/re-converge are NOT "
|
||||||
|
"proven here (see the header's gap list; rebuild is L3 node-loss).")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — two egress paths, and the rule set that selects between them (R-NET-04) [EXECUTABLE]
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
# Spec: docs/00 R-NET-04 ("The platform MUST support **multiple egress paths** selected by
|
||||||
|
# routing rules ... realized as two paths — the local `internet` path and the
|
||||||
|
# in-cluster `vpn` path — not multi-ISP routing"), elaborated by docs/03 §"Egress
|
||||||
|
# routing: two paths, one physical WAN": "the `internet` path is simply the default
|
||||||
|
# route out"; "**Default is the WAN; the VPN is strictly opt-in.** Every pod — and
|
||||||
|
# the nodes themselves — egress the local/private WAN by default and **never** the
|
||||||
|
# VPN. Only services labeled `egress-class=vpn` are steered to the tunnel, and the
|
||||||
|
# kill-switch blackholes **only** that class."
|
||||||
|
#
|
||||||
|
# WHAT THIS ADDS OVER R-NET-05/07 (already covered, and NOT re-tagged here)
|
||||||
|
# ------------------------------------------------------------------------
|
||||||
|
# R-NET-05/07 are about a SERVICE — it declares a class, enforcement is outside it, the class
|
||||||
|
# flips via GitOps — and are proven by tests/l1-rendering/egress-class-render and
|
||||||
|
# tests/l2-behavioral/egress-class. R-NET-04 is about the PLATFORM's PATH SET and the RULES
|
||||||
|
# that select among them, which nothing else asserts:
|
||||||
|
# * exactly TWO paths, the same closed set in the schema, the admission policy and the
|
||||||
|
# renderer (a third value would be a path nothing routes; "multi-ISP" is out of scope);
|
||||||
|
# * selection is by a ROUTING RULE and the rule set is TOTAL AND DISJOINT — exactly one
|
||||||
|
# Cilium steering rule, selecting only `egress-class=vpn`, and on the gateway node one
|
||||||
|
# `ip rule` scoped to the tunnel address. NOTHING selects the `internet` class, because
|
||||||
|
# absence of a matching rule IS the default path;
|
||||||
|
# * the two paths COEXIST per DESTINATION: private space is excluded from the tunnel, so a
|
||||||
|
# vpn-class pod's cluster/LAN traffic still takes the local path;
|
||||||
|
# * the vpn path never becomes the DEFAULT — its default route lives in a dedicated routing
|
||||||
|
# table, `route-noexec` is set and a pushed `redirect-gateway` is ignored;
|
||||||
|
# * the kill-switch blackholes ONLY that class — the nft chain's policy is accept and every
|
||||||
|
# DROP is scoped to the vpn class's own source set/fwmark, so `internet` fails OPEN.
|
||||||
|
# (R-NET-06's opposite claim, that `vpn` fails CLOSED, is L3's vpn-killswitch.)
|
||||||
|
#
|
||||||
|
# WHY L0: every clause above is a statement about ALL rules — including rules that must be
|
||||||
|
# ABSENT (nothing selects the default path; no unscoped rule; no unqualified drop; no default
|
||||||
|
# route in the main table). A cluster can show which path two pods took; it cannot show that
|
||||||
|
# no third rule exists or that the vocabulary is closed.
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC: no address appears. The private ranges the tunnel must exclude are
|
||||||
|
# checked as CONTAINMENT of the addresses each environment's own Flux overlay declares (pod
|
||||||
|
# CIDR, Gateway VIP, LB pool, API host), so a new environment on a different LAN is checked
|
||||||
|
# against its own values.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it,
|
||||||
|
# WHEN the egress vocabulary, every CiliumEgressGatewayPolicy, the gateway node's policy
|
||||||
|
# routing / OpenVPN / nftables templates, and every environment's overlay addresses are
|
||||||
|
# parsed and cross-checked,
|
||||||
|
# THEN there are exactly two paths with `internet` as the default; exactly one steering rule
|
||||||
|
# selects exactly the vpn class by label; the gateway is selected by node label with a
|
||||||
|
# named egress interface; all RFC1918 space and every address any environment assigns
|
||||||
|
# itself is excluded from the tunnel; the tunnel's default route and its fail-closed
|
||||||
|
# blackhole live only in a dedicated routing table behind a scoped `ip rule`; OpenVPN
|
||||||
|
# cannot take the main table; and no nftables DROP is unscoped.
|
||||||
|
#
|
||||||
|
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (git tree + PyYAML; no
|
||||||
|
# cluster, no network); conformance/run.sh --layer l0 and ci/l0.sh run it directly, and this
|
||||||
|
# Chainsaw Test wraps the SAME script.
|
||||||
|
#
|
||||||
|
# KNOWN, DELIBERATE GAPS (enumerated in check.sh's header; this test claims none of them):
|
||||||
|
# * BEHAVIOUR. That an unlabelled pod really takes the WAN path is asserted at L2 by
|
||||||
|
# tests/l2-behavioral/egress-default-path (same requirement label); the two labelled
|
||||||
|
# classes are R-NET-05's L2 test; the kill-switch firing is R-NET-06's L3 test.
|
||||||
|
# * The RUNNING gateway node's routing tables / nftables ruleset. This reads the templates
|
||||||
|
# that generate them; a hand-added rule on the box is invisible here.
|
||||||
|
# * PHYSICAL MULTI-WAN's absence is NOT asserted — it is out of scope per OQ-1 and no
|
||||||
|
# honest repository signal distinguishes "one WAN, correctly" from "one WAN, by default".
|
||||||
|
# * IPv6 egress.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: egress-paths-selected-by-rules
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-NET-04: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-NET-04"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Given the tracked tree, when the egress vocabulary, every steering rule and the gateway
|
||||||
|
node's policy-routing/OpenVPN/nftables templates are parsed, then the platform offers
|
||||||
|
exactly two egress paths with the WAN as the default, exactly one rule selects exactly
|
||||||
|
the vpn class, local destinations stay on the local path in every environment, the
|
||||||
|
tunnel never becomes the default route, and nothing unscoped can blackhole the default
|
||||||
|
path.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: then-two-paths-exist-and-the-rules-select-between-them-totally-and-disjointly
|
||||||
|
description: >-
|
||||||
|
Run the offline assertion script over the tracked tree (same script ci/l0.sh and
|
||||||
|
conformance/run.sh --layer l0 execute).
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,521 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — egress-paths-selected-by-rules
|
||||||
|
# Serves: R-NET-04 ("The platform MUST support **multiple egress paths** selected by routing
|
||||||
|
# rules. Per OQ-1 the environment has **one physical commercial WAN and no
|
||||||
|
# multi-ISP plans**, so this is realized as two paths — the local `internet` path and
|
||||||
|
# the in-cluster `vpn` path — not multi-ISP routing. Physical multi-WAN (multiple ISP
|
||||||
|
# uplinks) is **out of scope** unless a future ADR reopens it."
|
||||||
|
# — spec docs/00-vision-and-scope.md).
|
||||||
|
#
|
||||||
|
# The elaborating spec text this check quotes and asserts against — docs/03
|
||||||
|
# §"Egress routing: two paths, one physical WAN (R-NET-04/05 — resolved by OQ-1)":
|
||||||
|
# | `internet` | Out the single commercial WAN | Normal pod egress via node -> SOHO edge
|
||||||
|
# router -> internet |
|
||||||
|
# | `vpn` | Routed through the in-cluster VPN tunnel | Steered to the shared VPN
|
||||||
|
# egress gateway (ADR-0013) |
|
||||||
|
# "**Physical multi-WAN (multi-ISP) is out of scope.** With a single WAN there is nothing
|
||||||
|
# to policy-route across; the `internet` path is simply the default route out."
|
||||||
|
# "**Default is the WAN; the VPN is strictly opt-in.** Every pod — and the nodes
|
||||||
|
# themselves — egress the local/private WAN by default and **never** the VPN. Only
|
||||||
|
# services labeled `egress-class=vpn` are steered to the tunnel, and the kill-switch
|
||||||
|
# blackholes **only** that class."
|
||||||
|
# "It is **split-tunnel**: it creates `tun0` but does **not** set the node's default
|
||||||
|
# route (`route-noexec` + policy routing), so the node's own traffic stays on the WAN —
|
||||||
|
# only what Cilium points at `tun0` uses the VPN."
|
||||||
|
#
|
||||||
|
# WHAT R-NET-04 ADDS OVER R-NET-05/07 (which are ALREADY covered) — READ THIS FIRST
|
||||||
|
# ---------------------------------------------------------------------------------
|
||||||
|
# R-NET-05/07 are about a SERVICE: it can declare its class, enforcement happens outside it,
|
||||||
|
# and the class can be flipped via GitOps. Those are proven by
|
||||||
|
# tests/l1-rendering/egress-class-render (the render) and tests/l2-behavioral/egress-class
|
||||||
|
# (two labelled pods observe two different public IPs, and a relabel re-routes the same pod).
|
||||||
|
# Re-tagging either of those with R-NET-04 would be a duplicate wearing a new label.
|
||||||
|
#
|
||||||
|
# R-NET-04 is about the PLATFORM's PATH SET and the RULES THAT SELECT AMONG THEM. Its
|
||||||
|
# distinctive content — none of which any existing test asserts — is:
|
||||||
|
#
|
||||||
|
# (1) THERE ARE EXACTLY TWO PATHS, and the same two everywhere. The class vocabulary is
|
||||||
|
# one closed set of two values agreed by the schema, the admission policy and the
|
||||||
|
# renderer. A third value appearing in one of them but not the others would be a path
|
||||||
|
# nothing routes (or a path nothing validates); "multi-ISP" reappearing as a third
|
||||||
|
# class would contradict OQ-1's resolution.
|
||||||
|
# (2) SELECTION IS BY A ROUTING RULE, and the rule set is TOTAL AND DISJOINT. The `vpn`
|
||||||
|
# path is entered only via rules that select the vpn class — a Cilium
|
||||||
|
# CiliumEgressGatewayPolicy pod-selector, and on the gateway node an `ip rule` scoped
|
||||||
|
# to the tunnel source address. Nothing selects the `internet` class, because
|
||||||
|
# "the `internet` path is simply the default route out": ABSENCE of a matching rule IS
|
||||||
|
# the default path. So an unlabelled pod, and an `internet`-class pod, take the WAN.
|
||||||
|
# (3) THE PATHS COEXIST — the choice is per-destination, not per-pod-lifetime. The vpn
|
||||||
|
# rule tunnels public destinations while EXCLUDING all private space, so a vpn-class
|
||||||
|
# pod's cluster/LAN traffic still takes the local path at the same time as its
|
||||||
|
# internet traffic takes the tunnel.
|
||||||
|
# (4) THE VPN PATH NEVER BECOMES THE DEFAULT. Split tunnel: the tunnel's default route
|
||||||
|
# lives in a DEDICATED routing table reached only through the scoped rule, the
|
||||||
|
# provider's pushed `redirect-gateway` is ignored, and `route-noexec` keeps OpenVPN
|
||||||
|
# from touching the main table. Otherwise there would be one path (the VPN), not two.
|
||||||
|
# (5) THE KILL-SWITCH BLACKHOLES ONLY THAT CLASS. The blackhole default is inside the
|
||||||
|
# dedicated table; the nftables chain's policy is ACCEPT and every DROP is qualified
|
||||||
|
# by the vpn class's own source set / fwmark. The `internet` path therefore fails
|
||||||
|
# OPEN and can never be blackholed. (R-NET-06's opposite claim — that `vpn` fails
|
||||||
|
# CLOSED — is proven at L3 by tests/l3-fault-injection/vpn-killswitch. This check
|
||||||
|
# asserts the OTHER direction, which that test does not.)
|
||||||
|
#
|
||||||
|
# WHY L0. Every clause above is a property of the RULE SET, i.e. of authored configuration:
|
||||||
|
# totality ("nothing selects the default path"), disjointness ("exactly one rule, selecting
|
||||||
|
# exactly one class"), and scoping ("no unqualified drop, no default route in the main
|
||||||
|
# table") are statements about ALL rules, including the ones that would have to be absent.
|
||||||
|
# A running cluster can show you which path two pods took; it cannot show you that no third
|
||||||
|
# rule exists, that no rule is unscoped, or that the vocabulary is closed. The behavioural
|
||||||
|
# complement — that an UNLABELLED pod really does take the WAN path — is at L2 in
|
||||||
|
# tests/l2-behavioral/egress-default-path (same requirement label).
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC: no address is written down. The private ranges the vpn rule must
|
||||||
|
# exclude are checked as CONTAINMENT of the addresses each environment's own Flux overlay
|
||||||
|
# declares (pod CIDR, Gateway VIP, LB pool, API host) — so a new environment on a different
|
||||||
|
# LAN is checked against its own addresses, and the check keeps working when they change.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this check as claiming them):
|
||||||
|
# - Behaviour. That a vpn-class pod's packets really leave via tun0, that an unlabelled
|
||||||
|
# pod's really do not, and that the tunnel is up at all. L2 covers the two labelled
|
||||||
|
# classes (tests/l2-behavioral/egress-class, R-NET-05) and the unlabelled default
|
||||||
|
# (tests/l2-behavioral/egress-default-path, R-NET-04); the kill-switch firing is L3.
|
||||||
|
# - THE RUNNING NODE's routing tables and nftables ruleset. This reads the templates that
|
||||||
|
# generate them, driven by no node. A hand-added `ip rule` on the gateway node is
|
||||||
|
# invisible here (and to every other test); drift on the box is an Ansible check-mode
|
||||||
|
# exercise for the operator.
|
||||||
|
# - PHYSICAL MULTI-WAN. Out of scope per OQ-1, so its absence is not asserted: there is no
|
||||||
|
# honest repository signal that would distinguish "one WAN, correctly" from "one WAN,
|
||||||
|
# because nobody tried" — and inventing one would be a hollow assertion.
|
||||||
|
# - IPv6. The policy's CIDRs and the routing rules are IPv4-only, matching the platform.
|
||||||
|
# If IPv6 egress is ever enabled, the `internet` path would need the same analysis and
|
||||||
|
# this check would NOT catch its absence.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
cd "${ROOT}"
|
||||||
|
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||||
|
file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
CHECKS = 0
|
||||||
|
LBL = "homelab.keithjackson.dev"
|
||||||
|
CLASS_LABEL = f"{LBL}/egress-class"
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def die():
|
||||||
|
print(f"CHECK FAIL (egress-paths-selected-by-rules): {len(FAILURES)} of {CHECKS} "
|
||||||
|
"assertions failed", file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def docs(path: Path):
|
||||||
|
"""Every YAML document in a file, skipping empties."""
|
||||||
|
try:
|
||||||
|
return [d for d in yaml.safe_load_all(path.read_text()) if isinstance(d, dict)]
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# Skip dot-DIRECTORIES (.git, .claude, .cache) while still matching dot-FILES such as
|
||||||
|
# .sops.yaml. A bare rglob from ROOT also descends into NESTED WORKTREES —
|
||||||
|
# .claude/worktrees/agent-*/ created for isolated agent runs — which made this check see
|
||||||
|
# 5 CiliumEgressGatewayPolicy objects instead of the 1 authored, failing R-NET-04 for a
|
||||||
|
# reason that has nothing to do with egress. .claude/ is gitignored (.gitignore:43), so
|
||||||
|
# nothing under it is part of the authored surface by definition.
|
||||||
|
TRACKED_YAML = sorted(p for p in ROOT.rglob("*.yaml")
|
||||||
|
if "_artifacts" not in p.parts
|
||||||
|
and not any(part.startswith(".")
|
||||||
|
for part in p.relative_to(ROOT).parent.parts))
|
||||||
|
SCHEMA = ROOT / "schemas" / "servicedefinition.schema.json"
|
||||||
|
RENDER = ROOT / "catalog" / "render.py"
|
||||||
|
VPN_ROLE = ROOT / "infra" / "ansible" / "roles" / "vpn_egress_gateway"
|
||||||
|
CLUSTERS = ROOT / "clusters"
|
||||||
|
|
||||||
|
for p in (SCHEMA, RENDER, VPN_ROLE, CLUSTERS):
|
||||||
|
check(p.exists(), f"{p.relative_to(ROOT)} is missing — the egress path model cannot be "
|
||||||
|
"read at all (R-NET-04)")
|
||||||
|
if FAILURES:
|
||||||
|
die()
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================= (1) EXACTLY TWO PATHS, AGREED
|
||||||
|
import json # noqa: E402 — only needed for the schema
|
||||||
|
|
||||||
|
schema = json.loads(SCHEMA.read_text())
|
||||||
|
egress_prop = ((schema.get("properties") or {}).get("egress") or {})
|
||||||
|
enum = egress_prop.get("enum")
|
||||||
|
check(isinstance(enum, list) and len(enum) == 2,
|
||||||
|
f"schemas/servicedefinition.schema.json declares egress enum {enum!r} — R-NET-04 is "
|
||||||
|
"realized as EXACTLY TWO paths (OQ-1: one WAN, no multi-ISP). A third value is a path "
|
||||||
|
"nothing routes; one value is not 'multiple egress paths'")
|
||||||
|
PATHS = set(enum or [])
|
||||||
|
check(PATHS == {"internet", "vpn"},
|
||||||
|
f"the declared egress paths are {sorted(PATHS)} — docs/03's table fixes them as "
|
||||||
|
"`internet` (out the single commercial WAN) and `vpn` (the in-cluster tunnel)")
|
||||||
|
check(egress_prop.get("default") == "internet",
|
||||||
|
f"the schema's default egress class is {egress_prop.get('default')!r} — docs/03: "
|
||||||
|
"'Default is the WAN; the VPN is strictly opt-in', and 'the `internet` path is simply "
|
||||||
|
"the default route out'. A service that says nothing must get the WAN")
|
||||||
|
|
||||||
|
# The admission policy must constrain the pod label to the SAME closed set, or a workload
|
||||||
|
# could carry a class the schema never allowed (and that no rule routes).
|
||||||
|
pol_files = sorted((ROOT / "platform" / "kyverno").rglob("*egress*.yaml"))
|
||||||
|
check(len(pol_files) >= 1,
|
||||||
|
"no Kyverno policy file mentions egress under platform/kyverno — nothing stops a pod "
|
||||||
|
"carrying an egress class outside the declared path set")
|
||||||
|
pol_text = "\n".join(p.read_text(errors="replace") for p in pol_files)
|
||||||
|
for cls in sorted(PATHS):
|
||||||
|
check(re.search(rf"\b{re.escape(cls)}\b", pol_text) is not None,
|
||||||
|
f"the egress admission policy never names the {cls!r} path — a class the policy "
|
||||||
|
"does not know cannot be validated against the path set")
|
||||||
|
# No tracked object may carry a class outside the closed set — with ONE self-policing
|
||||||
|
# exemption. A Kyverno policy-test fixture deliberately carries an INVALID class so the
|
||||||
|
# admission test can assert the policy REJECTS it; that pod is exempt only while a
|
||||||
|
# kyverno-test.yaml in the same tests tree still expects `result: fail` for it. Delete the
|
||||||
|
# expectation (or flip it to pass) and the exemption evaporates — never a bare path allowlist.
|
||||||
|
def rejected_by_a_policy_test(resource_name: str, fixture: Path) -> bool:
|
||||||
|
tests_root = fixture.parent
|
||||||
|
while tests_root != ROOT and tests_root.name != "tests":
|
||||||
|
tests_root = tests_root.parent
|
||||||
|
if tests_root == ROOT:
|
||||||
|
return False
|
||||||
|
for tf in sorted(tests_root.rglob("kyverno-test.yaml")):
|
||||||
|
for d in docs(tf):
|
||||||
|
for case in (d.get("results") or []):
|
||||||
|
if case.get("result") == "fail" and resource_name in (case.get("resources") or []):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
stray: list[str] = []
|
||||||
|
_STRAY_RE = re.compile(rf"{re.escape(CLASS_LABEL)}:\s*[\"']?([A-Za-z][A-Za-z0-9-]*)")
|
||||||
|
for f in TRACKED_YAML:
|
||||||
|
for d in docs(f):
|
||||||
|
labels = ((d.get("metadata") or {}).get("labels") or {})
|
||||||
|
cls = labels.get(CLASS_LABEL)
|
||||||
|
# Also catch pod-template labels (Deployments/DaemonSets/Jobs/VMs).
|
||||||
|
tmpl = (((d.get("spec") or {}).get("template") or {}).get("metadata") or {}).get("labels") or {}
|
||||||
|
for value, owner in ((cls, d), (tmpl.get(CLASS_LABEL), d)):
|
||||||
|
if value is None or value in PATHS or value == "CLASS":
|
||||||
|
continue
|
||||||
|
name = ((owner.get("metadata") or {}).get("name") or "?")
|
||||||
|
if rejected_by_a_policy_test(name, f):
|
||||||
|
continue
|
||||||
|
stray.append(f"{f.relative_to(ROOT)}:{name}={value}")
|
||||||
|
# Values written as raw text (templates, docs-as-code) are caught too.
|
||||||
|
for value in set(_STRAY_RE.findall(f.read_text(errors="replace"))):
|
||||||
|
if value in PATHS or value == "CLASS":
|
||||||
|
continue
|
||||||
|
if any(value in s for s in stray):
|
||||||
|
continue
|
||||||
|
# Raw-text hit with no parsed owner: exempt only if EVERY object in the file that
|
||||||
|
# carries this value is a policy-test fixture proven to be rejected.
|
||||||
|
owners = [((d.get("metadata") or {}).get("name") or "?") for d in docs(f)
|
||||||
|
if ((d.get("metadata") or {}).get("labels") or {}).get(CLASS_LABEL) == value]
|
||||||
|
if owners and all(rejected_by_a_policy_test(o, f) for o in owners):
|
||||||
|
continue
|
||||||
|
stray.append(f"{f.relative_to(ROOT)}:<text>={value}")
|
||||||
|
check(not stray,
|
||||||
|
f"tracked YAML assigns egress-class value(s) outside the declared path set "
|
||||||
|
f"{sorted(PATHS)}: {sorted(stray)} — a value no routing rule selects and no admission "
|
||||||
|
"policy allows is a workload on no path at all (the only exemption is a Kyverno "
|
||||||
|
"policy-test fixture that a kyverno-test.yaml still expects to FAIL)")
|
||||||
|
|
||||||
|
# The renderer must project the DECLARED class onto the pod label, unmodified: that label is
|
||||||
|
# what the routing rule selects on, so a renderer that rewrote it would break the coupling
|
||||||
|
# between the declaration and the path.
|
||||||
|
rtext = RENDER.read_text()
|
||||||
|
check(re.search(rf'f?"\{{LBL\}}/egress-class":\s*s\["egress"\]', rtext) is not None
|
||||||
|
or re.search(r'egress-class\"\]?\s*:\s*s\[\"egress\"\]', rtext) is not None,
|
||||||
|
"catalog/render.py does not project the ServiceDefinition's own `egress` value onto the "
|
||||||
|
f"{CLASS_LABEL} pod label — the declaration and the routing rule's selector would be "
|
||||||
|
"two independent facts that could disagree")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== (2) SELECTION IS BY RULE; THE RULE SET IS TOTAL & DISJOINT
|
||||||
|
policies: list[tuple[Path, dict]] = []
|
||||||
|
for p in TRACKED_YAML:
|
||||||
|
for d in docs(p):
|
||||||
|
if d.get("kind") == "CiliumEgressGatewayPolicy":
|
||||||
|
policies.append((p, d))
|
||||||
|
check(len(policies) == 1,
|
||||||
|
f"{len(policies)} CiliumEgressGatewayPolicy object(s) are authored "
|
||||||
|
f"({[str(p.relative_to(ROOT)) for p, _ in policies]}) — R-NET-04's steering must be ONE "
|
||||||
|
"rule. Two rules can overlap, and which one wins for an overlapping pod is not "
|
||||||
|
"something the repository states")
|
||||||
|
|
||||||
|
for path, pol in policies:
|
||||||
|
rel = str(path.relative_to(ROOT))
|
||||||
|
spec = pol.get("spec") or {}
|
||||||
|
selectors = spec.get("selectors") or []
|
||||||
|
check(len(selectors) == 1,
|
||||||
|
f"{rel}: the steering rule has {len(selectors)} selector(s) — every additional "
|
||||||
|
"selector widens the set of pods steered off the default path, and R-NET-04's "
|
||||||
|
"selection must be exactly 'the vpn class'")
|
||||||
|
for sel in selectors:
|
||||||
|
pod_sel = sel.get("podSelector")
|
||||||
|
check(isinstance(pod_sel, dict) and pod_sel,
|
||||||
|
f"{rel}: a selector has no podSelector — an empty selector in Cilium matches "
|
||||||
|
"EVERY pod, which would put the whole cluster on the VPN path and leave no "
|
||||||
|
"default path at all")
|
||||||
|
ml = (pod_sel or {}).get("matchLabels") or {}
|
||||||
|
me = (pod_sel or {}).get("matchExpressions") or []
|
||||||
|
check(not me,
|
||||||
|
f"{rel}: the podSelector uses matchExpressions {me!r}. The class contract is a "
|
||||||
|
"single label equality; expressions (In/NotIn/Exists) can select pods that "
|
||||||
|
"never declared a class, so the rule set would no longer be disjoint from the "
|
||||||
|
"default path")
|
||||||
|
check(set(ml) == {CLASS_LABEL},
|
||||||
|
f"{rel}: the podSelector matches on {sorted(ml)} — it must select on exactly "
|
||||||
|
f"{CLASS_LABEL}. Any other key makes path selection depend on something other "
|
||||||
|
"than the declared class (and a namespace/app key would silently exempt pods)")
|
||||||
|
check(ml.get(CLASS_LABEL) == "vpn",
|
||||||
|
f"{rel}: the steering rule selects {CLASS_LABEL}={ml.get(CLASS_LABEL)!r}. Only "
|
||||||
|
"the `vpn` class may be steered: docs/03 — 'Only services labeled "
|
||||||
|
"egress-class=vpn are steered to the tunnel'. A rule selecting `internet` would "
|
||||||
|
"route the default path through the tunnel, i.e. leave one path, not two")
|
||||||
|
|
||||||
|
# The gateway is chosen by a NODE LABEL, not a hostname: the path's endpoint is itself a
|
||||||
|
# rule, so the gateway can move (or gain a standby) with no rule rewrite.
|
||||||
|
gw = spec.get("egressGateway") or {}
|
||||||
|
node_sel = (gw.get("nodeSelector") or {}).get("matchLabels") or {}
|
||||||
|
check(bool(node_sel),
|
||||||
|
f"{rel}: the egressGateway selects no node by label — pinning the path's exit to a "
|
||||||
|
"named host means the vpn path cannot fail over (docs/03 requires two gateway "
|
||||||
|
"nodes + Egress Gateway HA)")
|
||||||
|
check("nodeName" not in gw,
|
||||||
|
f"{rel}: the egressGateway pins a nodeName — the exit node must be selected by "
|
||||||
|
"label so the path survives losing that node")
|
||||||
|
check(isinstance(gw.get("interface"), str) and gw.get("interface"),
|
||||||
|
f"{rel}: the egressGateway names no egress interface — without it the steered class "
|
||||||
|
"is not actually sent down a different path from the default")
|
||||||
|
|
||||||
|
# ------------------- (3) THE PATHS COEXIST: the choice is per DESTINATION -------------
|
||||||
|
dests = spec.get("destinationCIDRs") or []
|
||||||
|
excl = spec.get("excludedCIDRs") or []
|
||||||
|
check([str(c) for c in dests] == ["0.0.0.0/0"],
|
||||||
|
f"{rel}: destinationCIDRs is {dests!r}. The vpn path is for PUBLIC destinations "
|
||||||
|
"('Routed through the in-cluster VPN tunnel'), expressed as the whole internet "
|
||||||
|
"minus the exclusions below — narrowing it here would silently leave public "
|
||||||
|
"destinations on the WAN for a vpn-class service")
|
||||||
|
try:
|
||||||
|
excluded = [ipaddress.ip_network(str(c)) for c in excl]
|
||||||
|
except ValueError as e:
|
||||||
|
excluded = []
|
||||||
|
check(False, f"{rel}: excludedCIDRs contains an unparsable network ({e})")
|
||||||
|
check(len(excluded) >= 3,
|
||||||
|
f"{rel}: only {len(excluded)} excluded CIDR(s) — docs/03 requires intra-cluster, "
|
||||||
|
"pod->service and LAN traffic to stay on the LOCAL path, which is all of RFC1918")
|
||||||
|
|
||||||
|
def covered(net: ipaddress.IPv4Network | ipaddress.IPv6Network) -> bool:
|
||||||
|
return any(net.subnet_of(e) for e in excluded if e.version == net.version)
|
||||||
|
|
||||||
|
# RFC1918 in full — computed, not string-matched, so a typo'd mask is caught.
|
||||||
|
for rfc in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"):
|
||||||
|
net = ipaddress.ip_network(rfc)
|
||||||
|
check(covered(net),
|
||||||
|
f"{rel}: the private range {rfc} is NOT excluded from the tunnel — a vpn-class "
|
||||||
|
"pod's cluster/LAN traffic would be SNAT'd out tun0, so the two paths would not "
|
||||||
|
"coexist for the same pod and east-west traffic would break")
|
||||||
|
|
||||||
|
# Every address each ENVIRONMENT declares for itself must fall in the excluded set: that
|
||||||
|
# is what makes 'local stays local' true per environment rather than by coincidence.
|
||||||
|
def overlay_addresses() -> list[tuple[str, str]]:
|
||||||
|
found: list[tuple[str, str]] = []
|
||||||
|
base = CLUSTERS / "base" / "cluster-vars.yaml"
|
||||||
|
data = {}
|
||||||
|
if base.is_file():
|
||||||
|
for d in docs(base):
|
||||||
|
if d.get("kind") == "ConfigMap":
|
||||||
|
data = {k: str(v) for k, v in (d.get("data") or {}).items()}
|
||||||
|
for f in sorted(CLUSTERS.glob("*/kustomization.yaml")):
|
||||||
|
env = {**data}
|
||||||
|
for m in re.finditer(
|
||||||
|
r"-\s+op:\s*replace\s*\n\s*path:\s*/data/(?P<k>[A-Z0-9_]+)\s*\n"
|
||||||
|
r"\s*value:\s*(?P<v>\S.*?)\s*$", f.read_text(), re.M):
|
||||||
|
env[m.group("k")] = m.group("v").strip().strip('"').strip("'")
|
||||||
|
label = f.parent.name
|
||||||
|
for key in ("CLUSTER_POD_CIDR", "GATEWAY_VIP", "CILIUM_LB_POOL_START",
|
||||||
|
"CILIUM_LB_POOL_STOP", "K3S_API_HOST"):
|
||||||
|
v = env.get(key, "")
|
||||||
|
if v and not v.startswith("TBD"):
|
||||||
|
found.append((f"{label}/{key}", v))
|
||||||
|
return found
|
||||||
|
|
||||||
|
addrs = overlay_addresses()
|
||||||
|
check(len(addrs) >= 5,
|
||||||
|
f"only {len(addrs)} environment address(es) could be read from the Flux overlays — "
|
||||||
|
"the 'local traffic stays local' assertion would be checking almost nothing")
|
||||||
|
for label, value in addrs:
|
||||||
|
try:
|
||||||
|
net = ipaddress.ip_network(value, strict=False)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
check(covered(net),
|
||||||
|
f"{rel}: {label}={value}, an address THIS repository assigns to a cluster, is "
|
||||||
|
"NOT inside the tunnel's excludedCIDRs — a vpn-class pod in that environment "
|
||||||
|
"would try to reach its own cluster/LAN through the VPN")
|
||||||
|
|
||||||
|
|
||||||
|
# ============ (4) THE VPN PATH IS A SECOND TABLE, NEVER THE DEFAULT (split tunnel) ========
|
||||||
|
TPL = VPN_ROLE / "templates"
|
||||||
|
up = (TPL / "vpn-egress-up.sh.j2")
|
||||||
|
down = (TPL / "vpn-egress-down.sh.j2")
|
||||||
|
conf = (TPL / "vpn-egress.conf.j2")
|
||||||
|
nft = (TPL / "vpn-egress-killswitch.nft.j2")
|
||||||
|
for f in (up, down, conf, nft):
|
||||||
|
check(f.is_file(), f"{f.relative_to(ROOT)} is missing — the second egress path's routing "
|
||||||
|
"rules are not authored")
|
||||||
|
if FAILURES:
|
||||||
|
die()
|
||||||
|
|
||||||
|
defaults = yaml.safe_load((VPN_ROLE / "defaults" / "main.yml").read_text()) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def render(text: str) -> str:
|
||||||
|
"""Substitute `{{ var }}` with the role's own default value, for every default.
|
||||||
|
|
||||||
|
The templates reference their knobs by NAME, so an analysis that looked for the VALUE
|
||||||
|
(e.g. the nft set name) would silently match nothing and pass vacuously — which is
|
||||||
|
exactly what an earlier draft of this check did. Rendering first means the assertions
|
||||||
|
below examine the text the node actually gets. Only simple `{{ name }}` expressions are
|
||||||
|
substituted; anything else is left as-is (and the assertions are written so an
|
||||||
|
unsubstituted expression fails rather than passes).
|
||||||
|
"""
|
||||||
|
out = text
|
||||||
|
for k, v in defaults.items():
|
||||||
|
if isinstance(v, (str, int)):
|
||||||
|
out = re.sub(r"\{\{\s*" + re.escape(str(k)) + r"\s*\}\}", str(v), out)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
up_t, conf_t, nft_t = render(up.read_text()), render(conf.read_text()), render(nft.read_text())
|
||||||
|
table_id = str(defaults.get("vpn_egress_gateway_route_table_id", ""))
|
||||||
|
check(table_id.isdigit() and table_id not in ("0", "253", "254", "255"),
|
||||||
|
f"the tunnel's routing table id is {table_id!r} — the second path must live in a "
|
||||||
|
"DEDICATED table. 254 (main), 255 (local) and 253 (default) are the kernel's own, and "
|
||||||
|
"putting the tunnel's default route in any of them makes the VPN the node's default "
|
||||||
|
"route instead of a selectable second path")
|
||||||
|
check(str(defaults.get("vpn_egress_gateway_rule_priority", "")).isdigit(),
|
||||||
|
"no ip-rule priority is declared for the tunnel table — 'selected by routing rules' "
|
||||||
|
"needs a rule, at a defined precedence")
|
||||||
|
|
||||||
|
# Every `ip route ... default` the up-script installs must name the dedicated table.
|
||||||
|
route_lines = [l.strip() for l in up_t.splitlines()
|
||||||
|
if re.search(r"^\s*ip\s+route\s+(replace|add)\b", l)]
|
||||||
|
check(len(route_lines) >= 2,
|
||||||
|
f"the up-script installs {len(route_lines)} route(s) — the vpn path needs its own "
|
||||||
|
"default route via the tunnel AND the fail-closed blackhole behind it")
|
||||||
|
for l in route_lines:
|
||||||
|
check(re.search(r'table\s+"?\$\{?TABLE_ID', l) is not None,
|
||||||
|
f"the up-script installs a route WITHOUT naming the dedicated table: {l!r}. A route "
|
||||||
|
"landing in the main table changes the path every other pod and the node itself "
|
||||||
|
"uses — the VPN would stop being opt-in")
|
||||||
|
check("blackhole" not in l or re.search(r'table\s+"?\$\{?TABLE_ID', l) is not None,
|
||||||
|
f"a blackhole route is installed outside the tunnel table: {l!r}. docs/03: the "
|
||||||
|
"kill-switch 'blackholes **only** that class'; `internet` fails OPEN and must never "
|
||||||
|
"be blackholed")
|
||||||
|
|
||||||
|
# Every `ip rule` must be SCOPED. An unscoped rule (`ip rule add lookup <table>`) sends
|
||||||
|
# everything into the tunnel table, which would leave one path instead of two.
|
||||||
|
rule_lines = [l.strip() for l in up_t.splitlines() if re.search(r"^\s*ip\s+rule\s+add\b", l)]
|
||||||
|
check(len(rule_lines) >= 1,
|
||||||
|
"the up-script adds no `ip rule` — nothing SELECTS the tunnel path, so the second "
|
||||||
|
"routing table would never be consulted (R-NET-04's 'selected by routing rules')")
|
||||||
|
for l in rule_lines:
|
||||||
|
check(re.search(r"\bfrom\s+\S|\bfwmark\s+\S|\biif\s+\S", l) is not None,
|
||||||
|
f"an unscoped ip rule is installed: {l!r}. Without a `from`/`fwmark`/`iif` selector "
|
||||||
|
"EVERY packet is looked up in the tunnel table, so the WAN path disappears and the "
|
||||||
|
"VPN is no longer opt-in")
|
||||||
|
check(re.search(r'lookup\s+"?\$\{?TABLE_ID', l) is not None,
|
||||||
|
f"an ip rule looks up a table other than the tunnel's: {l!r}")
|
||||||
|
check(re.search(r'ip rule add from\s+"\$\{SRC\}"', up_t) is not None,
|
||||||
|
"the steering rule is not scoped to ${SRC}, the tunnel's own local address — that "
|
||||||
|
"address is what Cilium SNATs vpn-class pods to, and it is the ONLY thing that "
|
||||||
|
"distinguishes the steered class from every other flow on the gateway node")
|
||||||
|
check(re.search(r"^\s*if \[ -z \"\$\{SRC\}\" \]", up_t, re.M) is not None,
|
||||||
|
"the up-script does not refuse to run when the tunnel address is unknown — installing "
|
||||||
|
"a partial rule set is how a path ends up half-selected")
|
||||||
|
|
||||||
|
# The provider must not be able to make the tunnel the default route either.
|
||||||
|
check(re.search(r"^\s*route-noexec\s*$", conf_t, re.M) is not None,
|
||||||
|
"the OpenVPN wrapper config lacks `route-noexec` — OpenVPN would install the pushed "
|
||||||
|
"routes itself, in the MAIN table, and the VPN would become the node's default route "
|
||||||
|
"(docs/03: 'does **not** set the node's default route (`route-noexec` + policy "
|
||||||
|
"routing)')")
|
||||||
|
check(re.search(r'pull-filter ignore "redirect-gateway"', conf_t) is not None,
|
||||||
|
"the OpenVPN wrapper config does not ignore a pushed `redirect-gateway` — the VPN "
|
||||||
|
"provider could redirect the node's whole default route into the tunnel, collapsing "
|
||||||
|
"the two paths into one")
|
||||||
|
check(re.search(r"^\s*redirect-gateway", conf_t, re.M) is None,
|
||||||
|
"the OpenVPN wrapper config itself sets `redirect-gateway` — that IS making the VPN the "
|
||||||
|
"default route")
|
||||||
|
|
||||||
|
|
||||||
|
# ================= (5) THE KILL-SWITCH BLACKHOLES ONLY THE VPN CLASS (internet fails OPEN)
|
||||||
|
src_set = str(defaults.get("vpn_egress_gateway_nft_src_set", ""))
|
||||||
|
fwmark = str(defaults.get("vpn_egress_gateway_fwmark", ""))
|
||||||
|
check(bool(src_set),
|
||||||
|
"the role declares no nftables source set — the kill-switch would have no way to name "
|
||||||
|
"'the steered class' and could only match broadly")
|
||||||
|
policies_lines = [l.strip() for l in nft_t.splitlines() if re.search(r"^\s*type filter hook", l)]
|
||||||
|
check(len(policies_lines) >= 1,
|
||||||
|
"the kill-switch nft template declares no base chain — nothing would be enforced")
|
||||||
|
for l in policies_lines:
|
||||||
|
check(re.search(r"policy\s+accept\s*;", l) is not None,
|
||||||
|
f"an nft base chain is not `policy accept`: {l!r}. A default-drop chain here "
|
||||||
|
"blackholes the `internet` path too, and docs/03 requires the two classes to fail "
|
||||||
|
"in OPPOSITE directions — 'internet fails **open** (WAN, never blackholed)'")
|
||||||
|
drop_lines = [l.strip() for l in nft_t.splitlines()
|
||||||
|
if re.search(r"\bdrop\b", l) and not l.lstrip().startswith("#")]
|
||||||
|
check(len(drop_lines) >= 1,
|
||||||
|
"the kill-switch nft template contains no drop rule at all — there is no kill-switch, "
|
||||||
|
"and R-NET-04's 'the kill-switch blackholes only that class' has nothing to scope")
|
||||||
|
for l in drop_lines:
|
||||||
|
scoped = (src_set and src_set in l) or (fwmark and "mark" in l)
|
||||||
|
check(scoped,
|
||||||
|
f"an nft DROP rule is not scoped to the vpn class: {l!r}. It must match the tunnel "
|
||||||
|
f"source set (@{src_set}) or the class's fwmark; an unqualified drop would blackhole "
|
||||||
|
"the default `internet` path, which must never be blackholed")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- verdict
|
||||||
|
# NO-OP GUARD: a check that quietly asserts nothing is worse than no check.
|
||||||
|
check(CHECKS >= 35,
|
||||||
|
f"only {CHECKS} assertions ran — the check degenerated to a near-no-op and its pass "
|
||||||
|
"would be meaningless")
|
||||||
|
|
||||||
|
if FAILURES:
|
||||||
|
die()
|
||||||
|
print(f"check OK: egress-paths-selected-by-rules — {CHECKS} assertions; "
|
||||||
|
f"paths={sorted(PATHS)} (default={egress_prop.get('default')}), "
|
||||||
|
f"{len(policies)} steering rule selecting {CLASS_LABEL}=vpn only, tunnel default route "
|
||||||
|
f"in dedicated table {table_id} behind a scoped ip rule, blackhole and every nft DROP "
|
||||||
|
f"scoped to the vpn class. R-NET-04's RULE-SET half only: the paths' actual BEHAVIOUR "
|
||||||
|
"is L2 (egress-class for the two labelled classes, egress-default-path for the "
|
||||||
|
"unlabelled default) and the kill-switch firing is L3 (vpn-killswitch).")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — Flux postBuild substitution is complete and fail-closed-safe
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
#
|
||||||
|
# R-ENV-02 ("authored once, promoted dev -> prod") is implemented by ONE mechanism:
|
||||||
|
# `postBuild.substituteFrom cluster-vars` injecting `${TOKEN}`s into the manifests each
|
||||||
|
# Flux Kustomization renders. That mechanism FAILS OPEN — an undefined variable becomes
|
||||||
|
# the empty string while the Kustomization still reports Ready — which emptied Cilium's
|
||||||
|
# k8sServiceHost and broke the CNI on 2/3 dev nodes (DECISIONS-LOG 2026-09-01).
|
||||||
|
#
|
||||||
|
# GIVEN every Flux Kustomization under clusters/ that declares postBuild.substituteFrom,
|
||||||
|
# WHEN each `spec.path` is resolved to the exact file set it renders (following
|
||||||
|
# kustomization.yaml `resources:` transitively, not globbing the repo),
|
||||||
|
# THEN every `${...}` in those files is either DEFINED in clusters/base/cluster-vars.yaml
|
||||||
|
# or deliberately ESCAPED `$${...}`; no unescaped expansion carries a default
|
||||||
|
# operator on an undefined name (the silent-empty class strict mode does NOT catch);
|
||||||
|
# no unescaped expansion is unparseable by envsubst (which fails the WHOLE
|
||||||
|
# Kustomization); every path rendering a knob actually substitutes cluster-vars;
|
||||||
|
# every overlay-patched knob has a base default; and the check is wired into the
|
||||||
|
# merge gate as a blocking, unconditionally-run stage.
|
||||||
|
#
|
||||||
|
# This is the prerequisite proof for enabling kustomize-controller's
|
||||||
|
# StrictPostBuildSubstitutions feature gate, and the permanent guard that keeps it safe.
|
||||||
|
#
|
||||||
|
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (standard library
|
||||||
|
# only); conformance/run.sh --layer l0 and ci/l0.sh run it directly, and this Chainsaw
|
||||||
|
# Test wraps the SAME script.
|
||||||
|
#
|
||||||
|
# NOT proven (named in check.sh's header): this is a STATIC scan of the SOURCE manifests
|
||||||
|
# a path includes — a deliberate superset of the post-`kustomize build` stream. It does
|
||||||
|
# not render, does not prove the feature gate is enabled on the live kustomize-controller,
|
||||||
|
# and does not validate knob VALUES. R-GITOPS-01 is tagged for the narrow static slice
|
||||||
|
# only: what git says a Kustomization renders is fully determined, with no variable
|
||||||
|
# silently emptied on the way in. The live-state half is L2, untested today.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: flux-postbuild-substitution
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-ENV-02: "true"
|
||||||
|
conformance.home.k3s/R-GITOPS-01: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-ENV-02, R-GITOPS-01"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Every variable a substituted Flux path renders is defined in cluster-vars or
|
||||||
|
escaped, so post-build substitution cannot silently empty a value and enabling
|
||||||
|
StrictPostBuildSubstitutions cannot fail a legitimate manifest.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: audit-every-substituted-flux-path
|
||||||
|
description: >-
|
||||||
|
Resolve each postBuild.substituteFrom Kustomization to the files it renders and
|
||||||
|
assert every ${...} is defined or escaped, then assert the gate wires it blocking.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — flux-postbuild-substitution
|
||||||
|
# Serves: R-ENV-02 ("Same GitOps config promotes dev/test -> prod (authored once)."
|
||||||
|
# — spec docs/02-environments-and-provisioning.md)
|
||||||
|
# R-GITOPS-01 ("All desired state in git, reconciled by Flux."
|
||||||
|
# — spec docs/05-gitops-and-delivery.md)
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS — Flux post-build substitution FAILS OPEN
|
||||||
|
# --------------------------------------------------------
|
||||||
|
# R-ENV-02 is implemented by exactly one mechanism: `clusters/base/cluster-vars.yaml`
|
||||||
|
# is injected into every `${TOKEN}` of the manifests each Flux Kustomization renders,
|
||||||
|
# via `postBuild.substituteFrom`. That mechanism is silently lossy by default: an
|
||||||
|
# UNDEFINED variable becomes the EMPTY STRING and the Kustomization still reports
|
||||||
|
# Ready. On 2026-09-01 that emptied `k8sServiceHost` in the Cilium HelmRelease and put
|
||||||
|
# cilium into Init:Error on 2 of 3 dev nodes (DECISIONS-LOG "substitution race that
|
||||||
|
# fails OPEN"). "Authored once, promoted" is therefore only true while every token a
|
||||||
|
# substituted path renders is either DEFINED or deliberately ESCAPED — and nothing in
|
||||||
|
# the repo asserted that. This check does.
|
||||||
|
#
|
||||||
|
# ENVSUBST BEHAVIOUR ASSERTED HERE (empirically verified against the pinned toolchain,
|
||||||
|
# `flux envsubst`, flux 2.4.0 — this is measured behaviour, not assumption):
|
||||||
|
# ${DEFINED} -> substituted with the cluster-vars value.
|
||||||
|
# ${UNDEFINED} -> EMPTY STRING, silently. `--strict` / the cluster-wide
|
||||||
|
# StrictPostBuildSubstitutions feature gate turns this into a
|
||||||
|
# loud Kustomization failure ("variable not set (strict mode)").
|
||||||
|
# ${UNDEFINED:-d} -> the DEFAULT is applied AT RECONCILE TIME. Strict mode does
|
||||||
|
# NOT catch this (it exempts default-operator forms by design),
|
||||||
|
# so a shell guard written `[ -z "${X:-}" ]` inside a rendered
|
||||||
|
# manifest is pre-resolved to `[ -z "" ]` and its else-branch
|
||||||
|
# becomes unreachable in the container. Silent, and permanent.
|
||||||
|
# $${VAR}, $${V:-d}, $${ARR[0]} -> emitted as the LITERAL ${...} for a downstream
|
||||||
|
# shell/cloud-init consumer. Escaping is total: it neutralises
|
||||||
|
# even the indexed form.
|
||||||
|
# $VAR, $(cmd) -> left alone by envsubst; they need no escaping.
|
||||||
|
# ${...}, ${a b}, ${ARR[0]} (UNESCAPED) -> HARD PARSE ERROR ("unable to parse
|
||||||
|
# variable name" / "missing closing brace") which fails the
|
||||||
|
# WHOLE Kustomization — every resource in the path, not just
|
||||||
|
# the offending one.
|
||||||
|
# The escape contract this enforces is the one already documented in
|
||||||
|
# `clusters/base/apps-vms.yaml`; this check is that comment made executable.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as authored,
|
||||||
|
# WHEN every Flux Kustomization under clusters/ that declares postBuild.substituteFrom
|
||||||
|
# is resolved to the exact file set its `spec.path` renders (by following
|
||||||
|
# kustomization.yaml `resources:` transitively — a path, not a glob),
|
||||||
|
# THEN (1) every `${...}` in those files is DEFINED in cluster-vars, or ESCAPED `$${...}`;
|
||||||
|
# (2) no UNESCAPED expansion carries a default/length/substitution operator on an
|
||||||
|
# undefined name (the silent-empty class strict mode does not catch);
|
||||||
|
# (3) no UNESCAPED expansion is unparseable by envsubst (`${...}`, `${ARR[0]}`) —
|
||||||
|
# that fails the entire Kustomization, so it is fatal wherever it appears;
|
||||||
|
# (4) every Kustomization that renders a cluster-vars token actually declares
|
||||||
|
# `substituteFrom` a ConfigMap named cluster-vars (else the literal token
|
||||||
|
# ships into the cluster);
|
||||||
|
# (5) every knob an overlay patches (`/data/KEY`) exists in the BASE ConfigMap —
|
||||||
|
# the "add the knob with a base default BEFORE referencing it" rule that the
|
||||||
|
# incident's interim discipline depends on;
|
||||||
|
# AND (6) this check is LOAD-BEARING: ci/l0.sh runs it on every gate run and
|
||||||
|
# ci/gate.sh sets the merge block when the l0 stage fails.
|
||||||
|
# Assertion (1)-(3) are applied to COMMENTS as well as content: `kustomize build`
|
||||||
|
# preserves body comments into the stream envsubst reads (verified: a `$`-carrying
|
||||||
|
# comment survives into the built apps/wave-1 output), so a braced expression in a
|
||||||
|
# comment is a live hazard, not prose.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* PROVE (honest gaps — this is a STATIC check):
|
||||||
|
# - It does NOT render the manifests. It scans the SOURCE files a path includes,
|
||||||
|
# which is a deliberate SUPERSET of the post-`kustomize build` stream (kustomize
|
||||||
|
# drops some comments). A finding in a comment may not reach envsubst TODAY; it is
|
||||||
|
# still reported, because whether a given comment survives the build is a kustomize
|
||||||
|
# implementation detail that changes with the pinned version, and the failure it
|
||||||
|
# causes is total (assertion 3). The end-to-end proof — `kustomize build <path> |
|
||||||
|
# flux envsubst --strict` for every substituted path — needs kustomize + flux,
|
||||||
|
# which the L0 CI toolchain does not install (.gitea/workflows/scripts/install-tools.sh
|
||||||
|
# l0), so it cannot run here without permanently SKIPPING. It was run by hand and
|
||||||
|
# recorded in DECISIONS-LOG; this check is the reproducible static half.
|
||||||
|
# - It does NOT prove the cluster's live state matches git, nor that the
|
||||||
|
# StrictPostBuildSubstitutions feature gate is actually enabled on
|
||||||
|
# kustomize-controller. Both are cluster facts (L2), untested here. Tagging
|
||||||
|
# R-GITOPS-01 claims ONLY the static slice: what git says a Kustomization renders
|
||||||
|
# is fully determined, with no variable silently emptied on the way in.
|
||||||
|
# - It does NOT validate cluster-vars VALUES (that GATEWAY_VIP is inside the LB pool,
|
||||||
|
# that an overlay's LAN block is self-consistent). Presence, not correctness.
|
||||||
|
# - A cluster-vars key nothing references is reported as an ADVISORY, never a failure:
|
||||||
|
# several are deliberately reference-only and say so in cluster-vars.yaml
|
||||||
|
# (PIA_REGION "retained, not substituted"; CLUSTER_POD_CIDR "Documents ..."), so
|
||||||
|
# failing on them would be a false block on documented intent.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
# Honest SKIP (never green) if the thing under test is not present at all.
|
||||||
|
if [ ! -d "${ROOT}/clusters" ] || [ ! -f "${ROOT}/clusters/base/cluster-vars.yaml" ]; then
|
||||||
|
echo "check SKIPPED: clusters/ or clusters/base/cluster-vars.yaml absent — nothing to audit" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "${ROOT}"
|
||||||
|
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||||
|
"""Static audit of Flux postBuild substitution safety. Pure standard library.
|
||||||
|
|
||||||
|
No PyYAML: the file shapes parsed here are line-constrained and the parse contracts are
|
||||||
|
documented at each parser, matching lib/coverage.py + lib/status.py. The kustomization
|
||||||
|
`resources:` parser is IMPORTED from lib/status.py rather than forked, so there is one
|
||||||
|
definition of "what a Flux path actually renders" in the harness.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
sys.dont_write_bytecode = True # keep __pycache__ out of the tracked tree
|
||||||
|
sys.path.insert(0, str(ROOT / "conformance" / "lib"))
|
||||||
|
import status as st # noqa: E402 — reuse the walk, don't fork it
|
||||||
|
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
ADVISORIES: list[str] = []
|
||||||
|
CHECKS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ cluster-vars
|
||||||
|
# Parse contract: `data:` block of a single ConfigMap, one ` KEY: "value"` per line
|
||||||
|
# (every entry in clusters/base/cluster-vars.yaml is a quoted scalar; comments and
|
||||||
|
# continuation-comment lines are indented further and never match).
|
||||||
|
_DATA_KEY_RE = re.compile(r'^ ([A-Za-z_][A-Za-z0-9_]*):\s*(?:"|\')')
|
||||||
|
|
||||||
|
|
||||||
|
def cluster_vars_keys(path: Path) -> set[str]:
|
||||||
|
keys: set[str] = set()
|
||||||
|
in_data = False
|
||||||
|
for raw in path.read_text().splitlines():
|
||||||
|
if re.match(r"^data:\s*$", raw):
|
||||||
|
in_data = True
|
||||||
|
continue
|
||||||
|
if not in_data:
|
||||||
|
continue
|
||||||
|
if st._TOP_KEY_RE.match(raw): # a new top-level key ends the data block
|
||||||
|
break
|
||||||
|
m = _DATA_KEY_RE.match(raw)
|
||||||
|
if m:
|
||||||
|
keys.add(m.group(1))
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
VARS_FILE = ROOT / "clusters" / "base" / "cluster-vars.yaml"
|
||||||
|
KEYS = cluster_vars_keys(VARS_FILE)
|
||||||
|
check(len(KEYS) > 0, f"no data keys parsed from {VARS_FILE} — the parser or the file shape changed")
|
||||||
|
|
||||||
|
# ------------------------------------------- Flux Kustomizations with substituteFrom
|
||||||
|
# Parse contract mirrors lib/status.py's flux_paths(): docs split on a lone `---`, a doc
|
||||||
|
# is a Flux entrypoint only if it carries the kustomize.toolkit apiVersion AND
|
||||||
|
# `kind: Kustomization` AND a spec-level `path:`. Inline `patch: |-` bodies in the
|
||||||
|
# overlays carry Flux Kustomizations WITHOUT a path and are correctly skipped.
|
||||||
|
_SUBFROM_RE = re.compile(r"^(\s*)substituteFrom:\s*$", re.M)
|
||||||
|
_SUBITEM_KIND_RE = re.compile(r"^\s*-\s*kind:\s*(\S+)")
|
||||||
|
_SUBITEM_NAME_RE = re.compile(r"^\s*(?:-\s*)?name:\s*([^\s#]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def substitute_sources(doc: str) -> list[tuple[str, str]]:
|
||||||
|
"""[(kind, name)] declared under postBuild.substituteFrom in this doc."""
|
||||||
|
m = _SUBFROM_RE.search(doc)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
indent = len(m.group(1))
|
||||||
|
out: list[tuple[str, str]] = []
|
||||||
|
kind = ""
|
||||||
|
for raw in doc[m.end():].splitlines():
|
||||||
|
if raw.strip() == "" or raw.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
if len(raw) - len(raw.lstrip()) <= indent and not raw.lstrip().startswith("-"):
|
||||||
|
break # dedented out of the list
|
||||||
|
km = _SUBITEM_KIND_RE.match(raw)
|
||||||
|
if km:
|
||||||
|
kind = km.group(1)
|
||||||
|
continue
|
||||||
|
nm = _SUBITEM_NAME_RE.match(raw)
|
||||||
|
if nm and kind:
|
||||||
|
out.append((kind, nm.group(1)))
|
||||||
|
kind = ""
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
kustomizations: list[dict] = []
|
||||||
|
for yfile in sorted((ROOT / "clusters").rglob("*.yaml")):
|
||||||
|
text = yfile.read_text()
|
||||||
|
if st.FLUX_API not in text:
|
||||||
|
continue
|
||||||
|
for doc in st._DOC_SPLIT_RE.split(text):
|
||||||
|
if st.FLUX_API not in doc or not re.search(r"^kind:\s*Kustomization\s*$", doc, re.M):
|
||||||
|
continue
|
||||||
|
pm = st._PATH_RE.search(doc)
|
||||||
|
if not pm:
|
||||||
|
continue
|
||||||
|
nm = st._NAME_RE.search(doc)
|
||||||
|
srcs = substitute_sources(doc)
|
||||||
|
if not srcs:
|
||||||
|
continue # no substitution => nothing to audit
|
||||||
|
kustomizations.append({
|
||||||
|
"file": str(yfile.relative_to(ROOT)),
|
||||||
|
"name": nm.group(1) if nm else "?",
|
||||||
|
"path": pm.group(1),
|
||||||
|
"sources": srcs,
|
||||||
|
})
|
||||||
|
|
||||||
|
check(len(kustomizations) > 0,
|
||||||
|
"no Flux Kustomization with postBuild.substituteFrom found under clusters/ — "
|
||||||
|
"the parser or the repo layout changed; this check would silently audit nothing")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ token scanning
|
||||||
|
# One `$`-run followed by a braced expression. `[^{}]*` is exhaustive for this tree:
|
||||||
|
# a repo-wide sweep found no nested/unclosed brace expression in any manifest (only
|
||||||
|
# Gitea Actions `${{ ... }}` in .gitea/, which no Flux Kustomization renders).
|
||||||
|
TOKEN_RE = re.compile(r"(\$+)\{([^{}]*)\}")
|
||||||
|
NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||||
|
# An expansion whose name is followed by a POSIX parameter operator. envsubst EVALUATES
|
||||||
|
# these, so an undefined name yields the default/empty AT RECONCILE TIME.
|
||||||
|
OP_RE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)(:[-=?+].*|:[0-9].*|/.*|#.*|%.*|\^.*|,.*)$")
|
||||||
|
LEN_RE = re.compile(r"^#([A-Za-z_][A-Za-z0-9_]*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def classify(dollars: int, inner: str) -> tuple[str, str] | None:
|
||||||
|
"""Return (severity-tag, remediation) for a hazardous expansion, else None."""
|
||||||
|
if dollars >= 2:
|
||||||
|
return None # escaped: emitted as a literal
|
||||||
|
if NAME_RE.match(inner):
|
||||||
|
if inner in KEYS:
|
||||||
|
return None # defined knob — the happy path
|
||||||
|
return ("UNDEFINED",
|
||||||
|
f"strict mode fails the Kustomization; today it renders EMPTY. Either add "
|
||||||
|
f"'{inner}' to clusters/base/cluster-vars.yaml with a base default, or "
|
||||||
|
f"escape it as $${{{inner}}} if it is meant for a shell/cloud-init consumer")
|
||||||
|
m = OP_RE.match(inner) or LEN_RE.match(inner)
|
||||||
|
if m:
|
||||||
|
base = m.group(1)
|
||||||
|
if base in KEYS:
|
||||||
|
return None # defined knob with an operator
|
||||||
|
return ("SILENT-EMPTY",
|
||||||
|
f"envsubst resolves this AT RECONCILE TIME because '{base}' is not a "
|
||||||
|
f"cluster-var, so the rendered manifest carries the default, not the "
|
||||||
|
f"expression. StrictPostBuildSubstitutions does NOT catch this form. "
|
||||||
|
f"Escape it as $${{{inner}}} if the CONTAINER's shell is meant to expand it")
|
||||||
|
return ("FATAL-PARSE",
|
||||||
|
"envsubst cannot parse this and fails the ENTIRE Kustomization (every resource "
|
||||||
|
"in the path). Escape it as $${...} or rewrite the prose without braces")
|
||||||
|
|
||||||
|
|
||||||
|
def rendered_files(path_spec: str) -> tuple[list[str], bool]:
|
||||||
|
"""Repo-relative files the Flux `spec.path` renders, resolving `resources:` transitively."""
|
||||||
|
rel = os.path.normpath(path_spec.lstrip("./")) if path_spec not in (".", "./") else "."
|
||||||
|
if not (ROOT / rel).is_dir():
|
||||||
|
return [], False
|
||||||
|
applied, _ = st.reconciled_files(ROOT, rel)
|
||||||
|
return sorted(applied), True
|
||||||
|
|
||||||
|
|
||||||
|
findings: list[str] = []
|
||||||
|
referenced: dict[str, set[str]] = {}
|
||||||
|
inventory: list[str] = []
|
||||||
|
|
||||||
|
for k in kustomizations:
|
||||||
|
files, exists = rendered_files(k["path"])
|
||||||
|
check(exists, f"{k['file']}: Kustomization '{k['name']}' renders path '{k['path']}' "
|
||||||
|
f"which is not a directory in this repo")
|
||||||
|
has_cluster_vars = ("ConfigMap", "cluster-vars") in k["sources"]
|
||||||
|
n_def = n_esc = n_bad = 0
|
||||||
|
for rel in files:
|
||||||
|
f = ROOT / rel
|
||||||
|
try:
|
||||||
|
text = f.read_text()
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
for lineno, line in enumerate(text.splitlines(), 1):
|
||||||
|
for m in TOKEN_RE.finditer(line):
|
||||||
|
dollars, inner = len(m.group(1)), m.group(2)
|
||||||
|
verdict = classify(dollars, inner)
|
||||||
|
if verdict is None:
|
||||||
|
if dollars >= 2:
|
||||||
|
n_esc += 1
|
||||||
|
else:
|
||||||
|
n_def += 1
|
||||||
|
referenced.setdefault(re.split(r"[:/#%^,]", inner)[0] or inner,
|
||||||
|
set()).add(k["name"])
|
||||||
|
# (4) a DEFINED knob is only substituted if this Kustomization
|
||||||
|
# actually pulls cluster-vars in; otherwise the literal ships.
|
||||||
|
check(has_cluster_vars,
|
||||||
|
f"{rel}:{lineno}: renders cluster-var ${{{inner}}} but "
|
||||||
|
f"Kustomization '{k['name']}' ({k['file']}) does not "
|
||||||
|
f"substituteFrom ConfigMap/cluster-vars — the literal token "
|
||||||
|
f"would ship into the cluster")
|
||||||
|
continue
|
||||||
|
n_bad += 1
|
||||||
|
tag, fix = verdict
|
||||||
|
findings.append(f" [{tag}] {rel}:{lineno} {m.group(0)}\n"
|
||||||
|
f" rendered by Flux Kustomization '{k['name']}' "
|
||||||
|
f"({k['file']}, path {k['path']})\n"
|
||||||
|
f" {fix}\n"
|
||||||
|
f" | {line.strip()[:120]}")
|
||||||
|
inventory.append(f" {k['name']:<16} path={k['path']:<22} files={len(files):<3} "
|
||||||
|
f"defined={n_def:<3} escaped={n_esc:<3} hazards={n_bad}")
|
||||||
|
|
||||||
|
check(not findings,
|
||||||
|
f"{len(findings)} unescaped, undefined or unparseable expansion(s) in substituted paths")
|
||||||
|
|
||||||
|
# --------------------------------------------- (5) overlay knobs exist in the base
|
||||||
|
# Parse contract: overlays patch cluster-vars with JSON6902 ops (`- op: replace` /
|
||||||
|
# `path: /data/KEY`) inside an inline `patch: |-` block. The repo rule (cluster-vars.yaml
|
||||||
|
# header) is that a knob is ADDED TO THE BASE with a default BEFORE any overlay or
|
||||||
|
# manifest references it — that ordering is what closes the define-then-use race.
|
||||||
|
_OP_PATH_RE = re.compile(r"^\s*(?:-\s*)?path:\s*/data/([A-Za-z_][A-Za-z0-9_]*)\s*$", re.M)
|
||||||
|
for overlay in sorted((ROOT / "clusters").glob("*/kustomization.yaml")):
|
||||||
|
for key in set(_OP_PATH_RE.findall(overlay.read_text())):
|
||||||
|
check(key in KEYS,
|
||||||
|
f"{overlay.relative_to(ROOT)}: patches /data/{key} but '{key}' has no BASE "
|
||||||
|
f"default in clusters/base/cluster-vars.yaml — a second overlay would render "
|
||||||
|
f"${{{key}}} empty (R-ENV-02: author once, supply per env)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------- (6) the check is load-bearing
|
||||||
|
L0 = (ROOT / "ci" / "l0.sh").read_text() if (ROOT / "ci" / "l0.sh").is_file() else ""
|
||||||
|
GATE = (ROOT / "ci" / "gate.sh").read_text() if (ROOT / "ci" / "gate.sh").is_file() else ""
|
||||||
|
check("flux-postbuild-substitution/check.sh" in L0,
|
||||||
|
"ci/l0.sh does not run flux-postbuild-substitution/check.sh — the requirement-scoped "
|
||||||
|
"conformance selection can filter this check out of a gate run, so l0.sh must invoke "
|
||||||
|
"it directly and unconditionally or the gate is not actually enforcing it")
|
||||||
|
check(re.search(r"^for s in .*\bl0\b.*; do", GATE, re.M) and re.search(r"fail\).*BLOCK=1", GATE),
|
||||||
|
"ci/gate.sh does not set the merge block when the l0 stage fails")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- advisory: dead knobs
|
||||||
|
for key in sorted(KEYS - set(referenced)):
|
||||||
|
ADVISORIES.append(f"cluster-vars knob '{key}' is referenced by NO reconciled manifest "
|
||||||
|
f"(dead knob, or documentation-only — advisory, not a failure)")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------- report
|
||||||
|
print("Flux postBuild substitution inventory (per Kustomization):")
|
||||||
|
for line in inventory:
|
||||||
|
print(line)
|
||||||
|
print(f"\ncluster-vars knobs defined: {len(KEYS)} referenced: {len(referenced)}")
|
||||||
|
|
||||||
|
if ADVISORIES:
|
||||||
|
print("\nADVISORIES (not failures):")
|
||||||
|
for a in ADVISORIES:
|
||||||
|
print(f" - {a}")
|
||||||
|
|
||||||
|
if findings:
|
||||||
|
print("\nHAZARDS — unescaped expansions that are not cluster-vars knobs:", file=sys.stderr)
|
||||||
|
for f in findings:
|
||||||
|
print(f, file=sys.stderr)
|
||||||
|
|
||||||
|
print()
|
||||||
|
if not FAILURES:
|
||||||
|
print(f"check OK: flux-postbuild-substitution — {CHECKS} assertions "
|
||||||
|
f"(R-ENV-02, R-GITOPS-01)")
|
||||||
|
raise SystemExit(0)
|
||||||
|
print(f"CHECK FAIL (flux-postbuild-substitution): {len(FAILURES)} failed, "
|
||||||
|
f"{CHECKS - len(FAILURES)} passed", file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" FAIL {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — the HA control plane is authored, for every environment (R-PLAT-01) [EXECUTABLE]
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
# Spec: docs/00 R-PLAT-01 ("The platform MUST run on k3s across 3 nodes with
|
||||||
|
# production-grade HA control plane in prod; dev/test MAY run fewer nodes but MUST
|
||||||
|
# exercise the same multi-node topology"), elaborated by docs/01 ("k3s (HA,
|
||||||
|
# embedded etcd)") and docs/02 ("HA embedded etcd (k3s server on all 3)",
|
||||||
|
# "3 physical nodes", "no external datastore", "the *same* Ansible bootstrap runs").
|
||||||
|
#
|
||||||
|
# WHY L0: R-PLAT-01 has an authored half and an observed half. The authored half — that the
|
||||||
|
# configuration in git BUILDS a 3-server embedded-etcd control plane in BOTH environments
|
||||||
|
# from one code path, with exactly one --cluster-init founder, no external datastore, and a
|
||||||
|
# floating API endpoint that is SAN'd into the serving cert and cannot collide with the
|
||||||
|
# Service-LB allocator — is provable with no cluster and is ONLY provable here: a live
|
||||||
|
# cluster cannot tell you what the NEXT environment brought up from this repo would look
|
||||||
|
# like. The observed half is asserted separately at L2 by
|
||||||
|
# tests/l2-behavioral/ha-control-plane, which carries the same requirement label.
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC: no hostname, address, node count or group name is written down.
|
||||||
|
# Environments are enumerated from the R-ENV-02 seam itself (k3s_primary_group /
|
||||||
|
# k3s_servers_group in group_vars), and WHICH one is prod is derived from the Flux overlay
|
||||||
|
# that environment bootstraps (flux_bootstrap_env_path) — never from a group's name.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it,
|
||||||
|
# WHEN the inventory, the k3s_server and kube_vip roles, their plays and each
|
||||||
|
# environment's Flux overlay are parsed and cross-checked against each other,
|
||||||
|
# THEN k3s is the pinned distro and nothing is installed as an agent; every environment
|
||||||
|
# resolves to an ODD, quorum-capable server count (prod exactly three) with EXACTLY
|
||||||
|
# ONE --cluster-init founder and every other server joining it with --server;
|
||||||
|
# the datastore is k3s's embedded etcd (no --datastore-endpoint, no --disable-etcd);
|
||||||
|
# the API's floating endpoint is a leader-elected, control-plane-ONLY kube-vip
|
||||||
|
# DaemonSet on every server whose address is the environment-scoped VIP, that VIP is
|
||||||
|
# in the apiserver serving cert's SANs together with every server's own address, and
|
||||||
|
# it is neither a node address nor the ingress Gateway VIP nor inside that
|
||||||
|
# environment's Cilium LoadBalancer pool; and dev/test and prod are built by the SAME
|
||||||
|
# roles and plays retargeted by variables only.
|
||||||
|
#
|
||||||
|
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (git tree + PyYAML; no
|
||||||
|
# cluster, no network); conformance/run.sh --layer l0 and ci/l0.sh run it directly, and this
|
||||||
|
# Chainsaw Test wraps the SAME script.
|
||||||
|
#
|
||||||
|
# KNOWN, DELIBERATE GAPS (enumerated in check.sh's header; this test claims none of them):
|
||||||
|
# * The LIVE clusters' shape (three Ready servers, all etcd members, VIP actually floating,
|
||||||
|
# API answering through it) — asserted at L2 by tests/l2-behavioral/ha-control-plane.
|
||||||
|
# * FAILOVER. That the control plane survives losing a server (quorum holds, the VIP moves)
|
||||||
|
# is a fault-injection claim and is proven NOWHERE yet — tests/l3-fault-injection/node-loss
|
||||||
|
# drains a node for R-STATE-02's data claim and asserts nothing about the control plane.
|
||||||
|
# * "Production-grade" as a hardware/performance judgement (operator decision, §2 of
|
||||||
|
# infra/PROD-PROVISIONING.md).
|
||||||
|
#
|
||||||
|
# TWO GAPS CLOSED 2026-09-06 (they used to be listed above; recorded so the change is legible):
|
||||||
|
# * `K3S_API_HOST` (Cilium's k8sServiceHost) was the PRIMARY NODE's address in both
|
||||||
|
# environments rather than the floating VIP, contradicting the prod overlay's own comment.
|
||||||
|
# It was ESCALATED, not asserted, because the workstream could not decide it (R-TEST-06).
|
||||||
|
# The operator decided the VIP, so it is now ASSERTED — on both sides of the [parity]
|
||||||
|
# contract and per environment.
|
||||||
|
# * kube-vip was NOT imported by playbooks/site.yml. It is now, BETWEEN k3s and cilium, and
|
||||||
|
# the order is asserted — not as a tidiness preference but because the CNI's endpoint is
|
||||||
|
# the VIP, so an unfloated VIP means a cold build brings up a CNI that cannot reach its
|
||||||
|
# API server.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: ha-control-plane-as-code
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-PLAT-01: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-PLAT-01"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Given the tracked tree, when the inventory, the k3s/kube-vip roles and plays and each
|
||||||
|
environment's Flux overlay are parsed and cross-checked, then every environment is
|
||||||
|
authored as an odd, quorum-capable set of k3s servers (prod exactly three) with one
|
||||||
|
embedded-etcd founder, no external datastore, and a leader-elected control-plane-only
|
||||||
|
VIP that is in the serving cert's SANs and collides with no other allocator — built by
|
||||||
|
one code path for both environments.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: then-every-environment-is-authored-as-an-ha-embedded-etcd-control-plane
|
||||||
|
description: >-
|
||||||
|
Run the offline assertion script over the tracked tree (same script ci/l0.sh and
|
||||||
|
conformance/run.sh --layer l0 execute).
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,729 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — ha-control-plane-as-code
|
||||||
|
# Serves: R-PLAT-01 ("The platform MUST run on k3s across 3 nodes with production-grade
|
||||||
|
# HA control plane in prod; dev/test MAY run fewer nodes but MUST exercise the
|
||||||
|
# same multi-node topology." — spec docs/00-vision-and-scope.md).
|
||||||
|
#
|
||||||
|
# The elaborating spec text this check quotes and asserts against:
|
||||||
|
# docs/01-architecture-overview.md "Kubernetes distro | **k3s** (HA, embedded etcd)"
|
||||||
|
# docs/02-environments-and-provisioning.md, table:
|
||||||
|
# "Control plane | HA embedded etcd (k3s server on all 3) | HA embedded etcd
|
||||||
|
# (k3s server on all 3)" (the SAME cell for dev/test and prod)
|
||||||
|
# "Node count | 3 VMs (mirror prod topology) | 3 physical nodes"
|
||||||
|
# docs/02 §"k3s topology decisions":
|
||||||
|
# "HA control plane with **embedded etcd** across all 3 nodes (no external datastore)."
|
||||||
|
# "Nodes are both control-plane and workload (3-node converged cluster); with only 3
|
||||||
|
# nodes we do not isolate control-plane nodes."
|
||||||
|
# "For prod bare-metal, the *same* Ansible bootstrap runs; only the machine-creation
|
||||||
|
# step differs"
|
||||||
|
#
|
||||||
|
# WHY L0 IS THE RIGHT LAYER FOR THESE CLAUSES (and where the rest lives)
|
||||||
|
# ---------------------------------------------------------------------
|
||||||
|
# R-PLAT-01 splits cleanly in two, and the halves are provable at different layers:
|
||||||
|
#
|
||||||
|
# AUTHORED half — "the configuration in git builds a 3-server HA embedded-etcd control
|
||||||
|
# plane in BOTH environments, from ONE code path": provable with no cluster, and
|
||||||
|
# ONLY provable here. A live cluster cannot tell you whether the *next* environment
|
||||||
|
# brought up from this repo will also be HA, whether a second `--cluster-init`
|
||||||
|
# founder could exist, or whether an external datastore is configured anywhere. That
|
||||||
|
# is this check.
|
||||||
|
# OBSERVED half — "the cluster this suite is pointed at really is 3 Ready servers, all
|
||||||
|
# etcd members, with the API answering through the floating VIP": that is cluster
|
||||||
|
# state and lives in tests/l2-behavioral/ha-control-plane (same requirement label).
|
||||||
|
#
|
||||||
|
# Neither half is sufficient alone, and this file claims only the first.
|
||||||
|
#
|
||||||
|
# ENVIRONMENTS ARE DERIVED, NOT LISTED. The check does not know the words "dev" or "prod"
|
||||||
|
# as inventory names. It enumerates environments from the R-ENV-02 seam itself — the
|
||||||
|
# `k3s_primary_group` / `k3s_servers_group` pair in group_vars/all.yml plus every
|
||||||
|
# group_vars/<group>/vars.yml that overrides them — and identifies WHICH environment is
|
||||||
|
# prod by the Flux overlay that environment bootstraps (`flux_bootstrap_env_path`), not by
|
||||||
|
# its group name. Add a fourth prod node, rename a group, or add a third environment and
|
||||||
|
# the assertions follow; nothing here restates an address, a hostname or a node count.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it,
|
||||||
|
# WHEN the Ansible inventory, the k3s/kube-vip roles and plays, and each environment's
|
||||||
|
# Flux overlay are parsed and cross-checked against each other,
|
||||||
|
# THEN DISTRO: k3s is the distro, pinned, installed by the one role, and no node is
|
||||||
|
# installed as an `agent` (an agent is not a control-plane member);
|
||||||
|
# SERVERS: every environment's server group resolves to a quorum-capable, ODD
|
||||||
|
# number of hosts (>= 3, tolerating >= 1 loss), the PROD environment to
|
||||||
|
# EXACTLY three, and each has EXACTLY ONE `--cluster-init` founder while
|
||||||
|
# every other server joins it with `--server` (so a second founder — the
|
||||||
|
# split-brain shape — cannot be authored);
|
||||||
|
# ETCD: the datastore is k3s's EMBEDDED etcd: no `--datastore-endpoint` and no
|
||||||
|
# `--disable-etcd` anywhere in the provisioning code, so every server is
|
||||||
|
# an etcd member (docs/02's "no external datastore", "on all 3");
|
||||||
|
# ENDPOINT: the API has a floating endpoint that survives losing the founder — the
|
||||||
|
# kube-vip VIP is control-plane-only (never Service LB, or it would fight
|
||||||
|
# Cilium LB-IPAM for ARP), leader-elected, deployed to EVERY server, and
|
||||||
|
# baked into the apiserver serving cert's SAN list ALONG WITH every
|
||||||
|
# server's own address (so TLS validates whichever node answers);
|
||||||
|
# ADDRESSES: each environment's control-plane VIP is not one of its node addresses,
|
||||||
|
# is not that environment's ingress Gateway VIP, and lies OUTSIDE that
|
||||||
|
# environment's Cilium LoadBalancer pool — the two allocators can never be
|
||||||
|
# handed the same address;
|
||||||
|
# CNI PATH: the CNI dials the FLOATING endpoint, not a machine (added 2026-09-06). The
|
||||||
|
# two API addresses stay SPLIT — k3s_api_host is the founder and feeds only
|
||||||
|
# the cold `--server` join, k3s_apiserver_endpoint derives from
|
||||||
|
# kube_vip_address and feeds Cilium's k8sServiceHost — the Flux overlay's
|
||||||
|
# K3S_API_HOST equals that environment's VIP and is none of its node
|
||||||
|
# addresses ([parity], both halves), the HelmRelease keeps a per-environment
|
||||||
|
# token rather than a literal, and site.yml floats the VIP BETWEEN k3s and
|
||||||
|
# Cilium with roles/kube_vip blocking until it answers;
|
||||||
|
# ONE PATH: dev/test and prod are brought up by the SAME role and the SAME plays,
|
||||||
|
# retargeted by variables only (docs/02 "the *same* Ansible bootstrap
|
||||||
|
# runs"), so "exercise the same multi-node topology" is a property of the
|
||||||
|
# code rather than a coincidence of two hand-written inventories.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this check as claiming them):
|
||||||
|
# - THE LIVE CLUSTERS. That three nodes are Ready, that all three are etcd members with
|
||||||
|
# a healthy quorum, that kube-vip is actually floating the VIP, and that the API
|
||||||
|
# answers through it. All of that is asserted at L2 by
|
||||||
|
# tests/l2-behavioral/ha-control-plane, which reads it from the cluster under test.
|
||||||
|
# - FAILOVER. That the control plane SURVIVES the loss of a server (etcd quorum holds,
|
||||||
|
# the VIP moves, the API stays up) is a fault-injection claim. The nearest existing
|
||||||
|
# coverage is tests/l3-fault-injection/node-loss, which drains a node for R-STATE-02's
|
||||||
|
# data-availability claim and does NOT assert control-plane survival. A dedicated
|
||||||
|
# "kill the VIP leader / kill the founder" L3 test is the remaining R-PLAT-01 gap and
|
||||||
|
# is NOT claimed here or at L2.
|
||||||
|
# - "PRODUCTION-GRADE" AS A HARDWARE/OPERATIONS JUDGEMENT. Whether three converged
|
||||||
|
# mini-PCs with etcd sharing a spindle with Longhorn is production-grade is an operator
|
||||||
|
# decision recorded in infra/PROD-PROVISIONING.md §2, not a repository property. This
|
||||||
|
# check asserts the control-plane TOPOLOGY the spec fixes, not a performance verdict.
|
||||||
|
# - IPv6 / dual-stack, and any claim about the edge network.
|
||||||
|
#
|
||||||
|
# TWO FORMER GAPS ARE NOW ASSERTED, and the reasoning is worth keeping (2026-09-06):
|
||||||
|
# * CILIUM'S OWN API ENDPOINT used to be listed here as an ESCALATION — `K3S_API_HOST`
|
||||||
|
# (clusters/*/kustomization.yaml), which becomes Cilium's `k8sServiceHost`, was each
|
||||||
|
# environment's PRIMARY NODE address rather than the floating VIP, contradicting the prod
|
||||||
|
# overlay's own comment. Making the check fail over it would have turned the merge gate
|
||||||
|
# red on a design question this workstream may not decide (R-TEST-06), so it was reported
|
||||||
|
# instead. THE OPERATOR DECIDED THE VIP on 2026-09-06, which turns the question into a
|
||||||
|
# repository invariant, and invariants belong in assertions — see the "THE CNI DIALS THE
|
||||||
|
# VIP" section. Note what made it assertable: the fix SPLIT one variable in two, so there
|
||||||
|
# is finally an HA-valued name (`k3s_apiserver_endpoint`) to point the contract at.
|
||||||
|
# * kube-vip WAS NOT PART OF THE ONE-COMMAND BRING-UP (site.yml chained node-baseline ->
|
||||||
|
# k3s -> cilium -> verify -> flux-bootstrap), which was recorded here as an observation
|
||||||
|
# and not asserted, because R-PLAT-01 does not fix the bring-up order. It is asserted now
|
||||||
|
# for a different reason that IS in scope: with the CNI's endpoint being the VIP, an
|
||||||
|
# unfloated VIP means a cold build installs a CNI that cannot reach its API server. The
|
||||||
|
# ordering is no longer a preference about tidiness, it is a dependency of the control
|
||||||
|
# plane coming up at all.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
cd "${ROOT}"
|
||||||
|
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||||
|
file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
NOTES: list[str] = []
|
||||||
|
CHECKS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def note(msg):
|
||||||
|
NOTES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def die():
|
||||||
|
print(f"CHECK FAIL (ha-control-plane-as-code): {len(FAILURES)} of {CHECKS} assertions failed",
|
||||||
|
file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
ANS = ROOT / "infra" / "ansible"
|
||||||
|
INV = ANS / "inventory" / "hosts.ini"
|
||||||
|
GV = ANS / "inventory" / "group_vars"
|
||||||
|
K3S_ROLE = ANS / "roles" / "k3s_server"
|
||||||
|
VIP_ROLE = ANS / "roles" / "kube_vip"
|
||||||
|
PLAYBOOKS = ANS / "playbooks"
|
||||||
|
CLUSTERS = ROOT / "clusters"
|
||||||
|
|
||||||
|
for p, why in ((INV, "the inventory that defines the node set"),
|
||||||
|
(K3S_ROLE, "the role that installs the k3s control plane"),
|
||||||
|
(VIP_ROLE, "the role that floats the control-plane VIP"),
|
||||||
|
(CLUSTERS, "the Flux overlays that identify each environment")):
|
||||||
|
check(p.exists(), f"{p.relative_to(ROOT)} is missing — {why} does not exist, so no HA "
|
||||||
|
"control plane is authored at all (R-PLAT-01)")
|
||||||
|
if FAILURES:
|
||||||
|
die()
|
||||||
|
|
||||||
|
K3S_TEXT = "\n".join(p.read_text(errors="replace") for p in sorted(K3S_ROLE.rglob("*.yml")))
|
||||||
|
VIP_TEXT = "\n".join(p.read_text(errors="replace")
|
||||||
|
for p in sorted(list(VIP_ROLE.rglob("*.yml")) + list(VIP_ROLE.rglob("*.j2"))))
|
||||||
|
|
||||||
|
|
||||||
|
# ===================================================================== inventory parse
|
||||||
|
# Parser contract (same shape lib/coverage.py-style parsers use elsewhere): hosts.ini is a
|
||||||
|
# plain INI inventory — `[group]` sections of `host key=value ...` lines, `[group:children]`
|
||||||
|
# sections naming child groups, `[group:vars]` sections ignored. Comments start with '#'.
|
||||||
|
ini_hosts: dict[str, list[str]] = {}
|
||||||
|
ini_children: dict[str, list[str]] = {}
|
||||||
|
host_vars: dict[str, dict[str, str]] = {}
|
||||||
|
cur = None
|
||||||
|
for line in INV.read_text().splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if not s or s.startswith("#"):
|
||||||
|
continue
|
||||||
|
m = re.match(r"^\[([^\]]+)\]$", s)
|
||||||
|
if m:
|
||||||
|
cur = m.group(1)
|
||||||
|
continue
|
||||||
|
if cur is None or cur.endswith(":vars"):
|
||||||
|
continue
|
||||||
|
host = s.split()[0]
|
||||||
|
if cur.endswith(":children"):
|
||||||
|
ini_children.setdefault(cur.split(":")[0], []).append(host)
|
||||||
|
else:
|
||||||
|
ini_hosts.setdefault(cur, []).append(host)
|
||||||
|
hv = host_vars.setdefault(host, {})
|
||||||
|
for kv in s.split()[1:]:
|
||||||
|
if "=" in kv:
|
||||||
|
k, v = kv.split("=", 1)
|
||||||
|
hv[k] = v
|
||||||
|
|
||||||
|
|
||||||
|
def hosts_in(group: str, seen=None) -> set[str]:
|
||||||
|
seen = seen or set()
|
||||||
|
if group in seen:
|
||||||
|
return set()
|
||||||
|
seen.add(group)
|
||||||
|
out = set(ini_hosts.get(group, []))
|
||||||
|
for child in ini_children.get(group, []):
|
||||||
|
out |= hosts_in(child, seen)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_yaml(p: Path) -> dict:
|
||||||
|
try:
|
||||||
|
d = yaml.safe_load(p.read_text()) or {}
|
||||||
|
except yaml.YAMLError:
|
||||||
|
return {}
|
||||||
|
return d if isinstance(d, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
ALL_VARS = load_yaml(GV / "all.yml")
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================== ENVIRONMENTS, DERIVED FROM THE SEAM
|
||||||
|
# An "environment" here is a (primary group, servers group) pair. group_vars/all.yml holds
|
||||||
|
# the default pair; every group_vars/<g>/vars.yml that overrides `k3s_primary_group` is
|
||||||
|
# another environment. Nothing below knows the words "dev" or "prod" as group names.
|
||||||
|
class Env:
|
||||||
|
def __init__(self, label, vars_, path):
|
||||||
|
self.label = label
|
||||||
|
self.vars = vars_
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def var(self, name, default=None):
|
||||||
|
# group-scoped value wins, else the cluster-wide default from all.yml.
|
||||||
|
if name in self.vars:
|
||||||
|
return self.vars[name]
|
||||||
|
return ALL_VARS.get(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
envs: list[Env] = [Env("group_vars/all.yml (default environment)", ALL_VARS, GV / "all.yml")]
|
||||||
|
for vf in sorted(GV.glob("*/vars.yml")):
|
||||||
|
v = load_yaml(vf)
|
||||||
|
if "k3s_primary_group" in v or "k3s_servers_group" in v:
|
||||||
|
envs.append(Env(str(vf.relative_to(ROOT)), v, vf))
|
||||||
|
|
||||||
|
check(len(envs) >= 2,
|
||||||
|
"only ONE environment is derivable from the inventory's R-ENV-02 seam "
|
||||||
|
"(k3s_primary_group / k3s_servers_group). R-PLAT-01 constrains prod AND requires "
|
||||||
|
"dev/test to exercise the same multi-node topology, so two must be authored")
|
||||||
|
|
||||||
|
# WHICH environment is prod: the one that bootstraps the clusters/prod Flux overlay.
|
||||||
|
# roles/flux_bootstrap's own default supplies the value for an environment that does not
|
||||||
|
# override it, so this is read from code, never guessed from a group name.
|
||||||
|
fb_default = None
|
||||||
|
fb_defaults_file = ANS / "roles" / "flux_bootstrap" / "defaults" / "main.yml"
|
||||||
|
if fb_defaults_file.is_file():
|
||||||
|
fb_default = load_yaml(fb_defaults_file).get("flux_bootstrap_env_path")
|
||||||
|
check(isinstance(fb_default, str) and fb_default.startswith("clusters/"),
|
||||||
|
"roles/flux_bootstrap declares no flux_bootstrap_env_path default — the mapping from "
|
||||||
|
"an inventory environment to its Flux overlay is not expressed in code, so this check "
|
||||||
|
"cannot tell which environment is prod")
|
||||||
|
|
||||||
|
prod_envs = []
|
||||||
|
for e in envs:
|
||||||
|
overlay = e.var("flux_bootstrap_env_path", fb_default)
|
||||||
|
e.overlay = overlay if isinstance(overlay, str) else ""
|
||||||
|
if e.overlay.rstrip("/").endswith("/prod"):
|
||||||
|
prod_envs.append(e)
|
||||||
|
check(len(prod_envs) == 1,
|
||||||
|
f"{len(prod_envs)} environment(s) bootstrap the clusters/prod overlay "
|
||||||
|
f"({[e.label for e in prod_envs]}) — exactly one must be prod, or 'in prod' in "
|
||||||
|
"R-PLAT-01 has no unambiguous referent")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================== overlay knob values (per environment)
|
||||||
|
# Parser contract: clusters/base/cluster-vars.yaml holds the base ConfigMap `data:` map;
|
||||||
|
# each clusters/<env>/kustomization.yaml overrides values with JSON6902 patches written as
|
||||||
|
# - op: replace
|
||||||
|
# path: /data/<KEY>
|
||||||
|
# value: "<v>"
|
||||||
|
# (verified shape across both overlays). A key not patched keeps the base value.
|
||||||
|
BASE_VARS: dict[str, str] = {}
|
||||||
|
base_cm = CLUSTERS / "base" / "cluster-vars.yaml"
|
||||||
|
if base_cm.is_file():
|
||||||
|
d = load_yaml(base_cm)
|
||||||
|
BASE_VARS = {k: str(v) for k, v in (d.get("data") or {}).items()}
|
||||||
|
check(len(BASE_VARS) > 5,
|
||||||
|
"clusters/base/cluster-vars.yaml exposes no data map — the overlay knob surface "
|
||||||
|
"cannot be read, so the VIP/pool separation cannot be checked")
|
||||||
|
|
||||||
|
_PATCH_RE = re.compile(
|
||||||
|
r"-\s+op:\s*replace\s*\n\s*path:\s*/data/(?P<k>[A-Z0-9_]+)\s*\n\s*value:\s*(?P<v>\S.*?)\s*$",
|
||||||
|
re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def overlay_vars(overlay: str) -> dict[str, str]:
|
||||||
|
out = dict(BASE_VARS)
|
||||||
|
f = ROOT / overlay / "kustomization.yaml"
|
||||||
|
if not f.is_file():
|
||||||
|
return out
|
||||||
|
for m in _PATCH_RE.finditer(f.read_text()):
|
||||||
|
out[m.group("k")] = m.group("v").strip().strip('"').strip("'")
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ PER-ENVIRONMENT TOPOLOGY
|
||||||
|
def as_ip(s):
|
||||||
|
try:
|
||||||
|
return ipaddress.ip_address(str(s).strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
for e in envs:
|
||||||
|
L = e.label
|
||||||
|
pg = e.var("k3s_primary_group")
|
||||||
|
sg = e.var("k3s_servers_group")
|
||||||
|
check(isinstance(pg, str) and isinstance(sg, str) and pg and sg,
|
||||||
|
f"{L}: k3s_primary_group / k3s_servers_group are not both set — the roles cannot "
|
||||||
|
"resolve 'my environment's primary/servers' and would only ever build dev")
|
||||||
|
if not (isinstance(pg, str) and isinstance(sg, str)):
|
||||||
|
continue
|
||||||
|
primaries = hosts_in(pg)
|
||||||
|
servers = hosts_in(sg)
|
||||||
|
|
||||||
|
# --- SERVERS: quorum-capable, one founder, prod exactly three ---------------------
|
||||||
|
check(len(primaries) == 1,
|
||||||
|
f"{L}: group {pg!r} resolves to {sorted(primaries)} — EXACTLY ONE host may run "
|
||||||
|
"`--cluster-init`. Two founders is two etcd clusters (split brain), zero is no "
|
||||||
|
"cluster at all")
|
||||||
|
check(primaries and primaries <= servers,
|
||||||
|
f"{L}: the primary {sorted(primaries)} is not a member of the servers group "
|
||||||
|
f"{sg!r} ({sorted(servers)}) — the founder must itself be one of the servers, or "
|
||||||
|
"it is not part of the HA control plane it founds")
|
||||||
|
n = len(servers)
|
||||||
|
check(n >= 3,
|
||||||
|
f"{L}: group {sg!r} resolves to {n} server(s) {sorted(servers)}. docs/02 fixes "
|
||||||
|
"'HA embedded etcd (k3s server on all 3)' for dev/test AND prod, and an etcd "
|
||||||
|
"cluster of fewer than 3 members tolerates ZERO losses — it is not HA")
|
||||||
|
check(n % 2 == 1,
|
||||||
|
f"{L}: group {sg!r} has an EVEN number of servers ({n}). An even embedded-etcd "
|
||||||
|
f"member count adds a member without adding fault tolerance and introduces a tie: "
|
||||||
|
f"quorum for {n} is {n // 2 + 1}, the same as for {n - 1}")
|
||||||
|
if n:
|
||||||
|
tolerance = n - (n // 2 + 1)
|
||||||
|
check(tolerance >= 1,
|
||||||
|
f"{L}: {n} server(s) give a quorum of {n // 2 + 1} and therefore tolerate "
|
||||||
|
f"{tolerance} loss(es) — 'production-grade HA' requires surviving at least one")
|
||||||
|
if e in prod_envs:
|
||||||
|
check(n == 3,
|
||||||
|
f"{L} is the PROD environment and resolves to {n} server(s). R-PLAT-01: "
|
||||||
|
"'k3s across 3 nodes ... in prod'; docs/02: 'Node count | ... | 3 physical nodes'")
|
||||||
|
else:
|
||||||
|
check(n >= 2,
|
||||||
|
f"{L}: a non-prod environment resolves to {n} host(s). R-PLAT-01 allows fewer "
|
||||||
|
"nodes than prod but requires dev/test to 'exercise the same multi-node "
|
||||||
|
"topology' — one node exercises no topology")
|
||||||
|
|
||||||
|
# --- ADDRESSES: the VIP can collide with nothing ----------------------------------
|
||||||
|
vip_raw = e.var("kube_vip_address")
|
||||||
|
vip = as_ip(vip_raw)
|
||||||
|
check(vip is not None,
|
||||||
|
f"{L}: kube_vip_address is {vip_raw!r}, not an IP address — the control plane has "
|
||||||
|
"no floating endpoint, so losing the founder takes the API endpoint with it")
|
||||||
|
node_ips = {}
|
||||||
|
for h in sorted(servers):
|
||||||
|
ip = as_ip(host_vars.get(h, {}).get("k3s_node_ip", ""))
|
||||||
|
check(ip is not None,
|
||||||
|
f"{L}: host {h} declares no usable k3s_node_ip — k3s --node-ip, the join "
|
||||||
|
"target and the serving-cert SAN list all derive from it")
|
||||||
|
if ip is not None:
|
||||||
|
node_ips[h] = ip
|
||||||
|
if vip is not None:
|
||||||
|
check(vip not in set(node_ips.values()),
|
||||||
|
f"{L}: kube_vip_address {vip} is ALSO a node address "
|
||||||
|
f"({[h for h, i in node_ips.items() if i == vip]}) — a floating VIP that is a "
|
||||||
|
"node's own address cannot float off that node")
|
||||||
|
ov = overlay_vars(getattr(e, "overlay", ""))
|
||||||
|
gw = as_ip(ov.get("GATEWAY_VIP", ""))
|
||||||
|
lo = as_ip(ov.get("CILIUM_LB_POOL_START", ""))
|
||||||
|
hi = as_ip(ov.get("CILIUM_LB_POOL_STOP", ""))
|
||||||
|
check(gw is not None and lo is not None and hi is not None,
|
||||||
|
f"{L}: overlay {getattr(e, 'overlay', '')!r} does not resolve GATEWAY_VIP + "
|
||||||
|
"CILIUM_LB_POOL_START/STOP — the separation between the control-plane VIP and "
|
||||||
|
"the Service-LB allocator cannot be checked")
|
||||||
|
if gw is not None:
|
||||||
|
check(vip != gw,
|
||||||
|
f"{L}: the control-plane VIP and the ingress Gateway VIP are the SAME "
|
||||||
|
f"address ({vip}) — kube-vip and Cilium would both ARP for it")
|
||||||
|
if lo is not None and hi is not None:
|
||||||
|
check(not (lo <= vip <= hi),
|
||||||
|
f"{L}: the control-plane VIP {vip} lies INSIDE that environment's Cilium "
|
||||||
|
f"LoadBalancer pool {lo}-{hi} — LB-IPAM could hand the kube-API's VIP to a "
|
||||||
|
"Service, and the two allocators would fight over ARP for it")
|
||||||
|
check(lo <= hi,
|
||||||
|
f"{L}: Cilium LB pool {lo}-{hi} is inverted — the pool bound cannot be "
|
||||||
|
"used to prove the VIP is outside it")
|
||||||
|
|
||||||
|
# ==================================================================== DISTRO: k3s only
|
||||||
|
check(re.search(r"^k3s_version:\s*\"?v\d+\.\d+", (K3S_ROLE / "defaults" / "main.yml").read_text(), re.M)
|
||||||
|
is not None,
|
||||||
|
"roles/k3s_server pins no concrete k3s_version (vX.Y...) — an unpinned control-plane "
|
||||||
|
"version is not a reproducible platform (docs/01: the distro is k3s, ADR-0001)")
|
||||||
|
check("get.k3s.io" in K3S_TEXT,
|
||||||
|
"roles/k3s_server does not install from the k3s installer — R-PLAT-01 names k3s as the "
|
||||||
|
"distro (ADR-0001) and no other distro may stand in for it")
|
||||||
|
check(re.search(r"^\s*server\s*$", K3S_TEXT, re.M) is not None,
|
||||||
|
"roles/k3s_server never composes the `server` subcommand — without it k3s installs an "
|
||||||
|
"AGENT, which is not a control-plane member (docs/02: 'k3s server on all 3')")
|
||||||
|
for bad, why in (
|
||||||
|
(r"--datastore-endpoint", "an EXTERNAL datastore (docs/02: 'embedded etcd across all 3 "
|
||||||
|
"nodes (no external datastore)')"),
|
||||||
|
(r"datastore-endpoint\s*:", "an external datastore via config.yaml"),
|
||||||
|
(r"--disable-etcd", "a server with etcd DISABLED, i.e. a control-plane node that is not "
|
||||||
|
"an etcd member (docs/02: etcd 'across all 3 nodes')"),
|
||||||
|
(r"INSTALL_K3S_EXEC\s*[:=]\s*[\"']?agent", "an AGENT-only node"),
|
||||||
|
):
|
||||||
|
hits = [str(p.relative_to(ROOT)) for p in sorted(ANS.rglob("*"))
|
||||||
|
if p.is_file() and p.suffix in (".yml", ".yaml", ".j2", ".sh", ".ini")
|
||||||
|
and re.search(bad, p.read_text(errors="replace"))]
|
||||||
|
check(not hits,
|
||||||
|
f"{hits} configure {why} — R-PLAT-01's control plane is k3s's own embedded-etcd HA "
|
||||||
|
f"mode, and {bad!r} contradicts it")
|
||||||
|
|
||||||
|
# ============================================== FOUNDER / JOINER, EXACTLY ONE OF EACH
|
||||||
|
check(re.search(r"--cluster-init", K3S_TEXT) is not None,
|
||||||
|
"roles/k3s_server never passes --cluster-init — the embedded-etcd cluster is never "
|
||||||
|
"founded, so there is no HA datastore to join")
|
||||||
|
init_when = re.search(r"--cluster-init.*?\n(?:.*\n){0,8}?\s*when:\s*k3s_server_mode\s*==\s*'primary'",
|
||||||
|
K3S_TEXT)
|
||||||
|
check(init_when is not None,
|
||||||
|
"the --cluster-init install task is not gated on k3s_server_mode == 'primary' — an "
|
||||||
|
"ungated founder flag would re-found the cluster on every server (split brain)")
|
||||||
|
join = re.search(r"--server https://\{\{\s*k3s_api_host", K3S_TEXT)
|
||||||
|
check(join is not None,
|
||||||
|
"roles/k3s_server does not join secondaries with `--server https://{{ k3s_api_host }}` "
|
||||||
|
"— without a join target the servers are separate single-node clusters")
|
||||||
|
join_when = re.search(r"--server https://.*?\n(?:.*\n){0,10}?\s*when:\s*k3s_server_mode\s*==\s*'secondary'",
|
||||||
|
K3S_TEXT, re.S)
|
||||||
|
check(join_when is not None,
|
||||||
|
"the join install task is not gated on k3s_server_mode == 'secondary' — the founder "
|
||||||
|
"would also try to join itself")
|
||||||
|
k3s_play = PLAYBOOKS / "k3s.yml"
|
||||||
|
check(k3s_play.is_file(), "playbooks/k3s.yml is missing — the control-plane install has no entrypoint")
|
||||||
|
if k3s_play.is_file():
|
||||||
|
play_txt = k3s_play.read_text()
|
||||||
|
modes = re.findall(r"k3s_server_mode:\s*(\w+)", play_txt)
|
||||||
|
check(sorted(modes) == ["primary", "secondary"],
|
||||||
|
f"playbooks/k3s.yml declares server modes {modes} — exactly one primary play and "
|
||||||
|
"one secondary play must exist, or the founder/joiner split is not authored")
|
||||||
|
check(re.search(r"serial:\s*1", play_txt) is not None,
|
||||||
|
"playbooks/k3s.yml does not restart/join the secondaries with `serial: 1` — "
|
||||||
|
"converging two of three servers at once drops embedded etcd below quorum, which "
|
||||||
|
"is exactly the outage an HA control plane exists to avoid")
|
||||||
|
hosts_patterns = re.findall(r"^\s*hosts:\s*(.+)$", play_txt, re.M)
|
||||||
|
check(len(hosts_patterns) >= 2 and all("{{" in h for h in hosts_patterns),
|
||||||
|
f"playbooks/k3s.yml targets literal host patterns {hosts_patterns} — a literal "
|
||||||
|
"group name means this play can only ever build ONE environment, so prod cannot "
|
||||||
|
"be brought up by the same code (docs/02: 'the *same* Ansible bootstrap runs')")
|
||||||
|
|
||||||
|
# =========================================== SERVING-CERT SANs: VIP + every server
|
||||||
|
sans = str(load_yaml(K3S_ROLE / "defaults" / "main.yml").get("k3s_tls_sans", ""))
|
||||||
|
check("kube_vip_address" in sans,
|
||||||
|
"roles/k3s_server's k3s_tls_sans does not include kube_vip_address — the apiserver's "
|
||||||
|
"serving cert would carry no SAN for the floating VIP, so every client reaching the "
|
||||||
|
"API through it fails TLS validation and the HA endpoint is unusable")
|
||||||
|
check("k3s_servers_group" in sans and "k3s_node_ip" in sans,
|
||||||
|
"k3s_tls_sans does not extract k3s_node_ip for every host of k3s_servers_group — the "
|
||||||
|
"cert must be valid for WHICHEVER server answers behind the VIP, and per-environment "
|
||||||
|
"resolution of that set is what keeps one role serving both environments (R-ENV-02)")
|
||||||
|
check(re.search(r"tls-san:", K3S_TEXT) is not None,
|
||||||
|
"the SAN list is passed only as an install flag and never persisted to "
|
||||||
|
"/etc/rancher/k3s/config.yaml — on an ALREADY-installed cluster the install task is a "
|
||||||
|
"no-op, so a changed SAN set (e.g. a new server) would never reach the serving cert")
|
||||||
|
|
||||||
|
# ============================ THE CNI DIALS THE VIP, NOT THE FOUNDER (added 2026-09-06)
|
||||||
|
# This section used to be an ESCALATION in the header's gap list, not an assertion: Cilium's
|
||||||
|
# k8sServiceHost was each environment's PRIMARY NODE address, so on a 3-server HA control
|
||||||
|
# plane the CNI's control-plane path depended on one machine. Measured on live prod
|
||||||
|
# 2026-09-06: all three cilium-agents carried KUBERNETES_SERVICE_HOST=192.168.2.10
|
||||||
|
# (k3s-prod-1's own address) while kube-vip held 192.168.2.9. That failure is SILENT — the
|
||||||
|
# datapath keeps serving from Cilium's cache, so nothing looks broken while no service or
|
||||||
|
# policy update lands at all.
|
||||||
|
#
|
||||||
|
# The operator decided the VIP (2026-09-06), so this is now a repository property and belongs
|
||||||
|
# here rather than in the gap list. THIS IS THE RIGHT LAYER because the claim is about what
|
||||||
|
# the configuration in git WOULD build, in BOTH environments, including the next one — a live
|
||||||
|
# cluster can only tell you about itself.
|
||||||
|
#
|
||||||
|
# WHAT MAKES THIS CHECKABLE AT ALL is that the fix split one variable into two. Asserting
|
||||||
|
# "Cilium uses the HA address" requires an HA address to name; before the split there was
|
||||||
|
# only k3s_api_host, which had to be the founder for the cold join. So the assertions below
|
||||||
|
# check the SPLIT as much as the value: the join keeps the founder, the CNI gets the VIP, and
|
||||||
|
# the Flux half agrees with the Ansible half per environment.
|
||||||
|
CIL_ROLE = ANS / "roles" / "cilium"
|
||||||
|
cil_values_tpl = CIL_ROLE / "templates" / "cilium-values.yaml.j2"
|
||||||
|
check(cil_values_tpl.is_file(),
|
||||||
|
"roles/cilium ships no cilium-values.yaml.j2 — the bootstrap's CNI values, including the "
|
||||||
|
"API endpoint its kube-proxy replacement dials, are not authored")
|
||||||
|
if cil_values_tpl.is_file():
|
||||||
|
# Parse the rendered-shape template, never its prose: strip comment lines first, exactly
|
||||||
|
# as the kube-vip parser above does, so a reassuring comment cannot satisfy the check.
|
||||||
|
cil_body = "\n".join(l for l in cil_values_tpl.read_text(errors="replace").splitlines()
|
||||||
|
if not l.lstrip().startswith("#"))
|
||||||
|
m = re.search(r"^k8sServiceHost:\s*\"?([^\"\n]*)\"?\s*$", cil_body, re.M)
|
||||||
|
check(m is not None,
|
||||||
|
"roles/cilium's values template sets no k8sServiceHost — with kube-proxy disabled "
|
||||||
|
"(ADR-0001) the agent has no API endpoint to dial and the CNI cannot come up")
|
||||||
|
if m is not None:
|
||||||
|
host_expr = m.group(1).strip()
|
||||||
|
check("k3s_apiserver_endpoint" in host_expr,
|
||||||
|
f"roles/cilium renders k8sServiceHost={host_expr!r}. It MUST be the HA endpoint "
|
||||||
|
"variable {{ k3s_apiserver_endpoint }} (= kube_vip_address). Any node-valued "
|
||||||
|
"expression — k3s_api_host, a primary hostvar, a literal IP — makes the CNI's "
|
||||||
|
"control-plane path depend on ONE server of an HA control plane, and the failure "
|
||||||
|
"is silent: the datapath keeps running from Cilium's cache while no service or "
|
||||||
|
"policy update ever lands (measured on prod 2026-09-06)")
|
||||||
|
check("k3s_api_host" not in host_expr,
|
||||||
|
f"roles/cilium renders k8sServiceHost={host_expr!r}, which references "
|
||||||
|
"k3s_api_host — the FOUNDER's own address. That variable exists only for the "
|
||||||
|
"cold-bootstrap `--server` join and one tls-san entry; it is a single point of "
|
||||||
|
"failure by construction and must never be a steady-state endpoint")
|
||||||
|
|
||||||
|
# The HA endpoint variable must BE the VIP, and must be DERIVED from kube_vip_address rather
|
||||||
|
# than restated: an environment overrides one line (kube_vip_address) and both the floating
|
||||||
|
# address and the CNI's endpoint move together. A literal here could disagree with the VIP the
|
||||||
|
# same environment actually floats, which is the two-places-one-fact bug this repo keeps
|
||||||
|
# hitting.
|
||||||
|
endpoint_def = str(ALL_VARS.get("k3s_apiserver_endpoint", ""))
|
||||||
|
check("kube_vip_address" in endpoint_def,
|
||||||
|
f"group_vars/all.yml defines k3s_apiserver_endpoint as {endpoint_def!r}. It MUST derive "
|
||||||
|
"from kube_vip_address, so that overriding the VIP for an environment also moves what "
|
||||||
|
"that environment's CNI dials. A restated literal can silently disagree with the VIP "
|
||||||
|
"kube-vip is actually floating")
|
||||||
|
check("kube_vip_address" not in str(ALL_VARS.get("k3s_api_host", "")),
|
||||||
|
"group_vars/all.yml's k3s_api_host resolves from kube_vip_address — the two API "
|
||||||
|
"addresses have been re-merged. k3s_api_host is the secondary join target and on a COLD "
|
||||||
|
"build no VIP exists yet, so a VIP-valued join target makes a cold bootstrap unjoinable")
|
||||||
|
|
||||||
|
# THE [parity] CONTRACT, now machine-checked instead of asserted in a comment: the Flux
|
||||||
|
# overlay's K3S_API_HOST becomes Cilium's k8sServiceHost under the HelmRelease, and the
|
||||||
|
# Ansible bootstrap renders k3s_apiserver_endpoint for the same field. If the two disagree,
|
||||||
|
# Flux and the bootstrap describe different control-plane paths and fight over the CNI.
|
||||||
|
for e in envs:
|
||||||
|
L = e.label
|
||||||
|
ov = overlay_vars(getattr(e, "overlay", ""))
|
||||||
|
k3s_api_host_knob = as_ip(ov.get("K3S_API_HOST", ""))
|
||||||
|
vip = as_ip(e.var("kube_vip_address"))
|
||||||
|
check(k3s_api_host_knob is not None,
|
||||||
|
f"{L}: overlay {getattr(e, 'overlay', '')!r} resolves no usable K3S_API_HOST — that "
|
||||||
|
"token becomes Cilium's k8sServiceHost via platform/cilium/helmrelease.yaml, and "
|
||||||
|
"kustomize-controller runs with StrictPostBuildSubstitutions so an unresolvable one "
|
||||||
|
"fails the whole platform Kustomization")
|
||||||
|
if k3s_api_host_knob is not None and vip is not None:
|
||||||
|
check(k3s_api_host_knob == vip,
|
||||||
|
f"{L}: [parity] BROKEN — the Flux overlay sets K3S_API_HOST={k3s_api_host_knob} "
|
||||||
|
f"but this environment's control-plane VIP (kube_vip_address, which is what "
|
||||||
|
f"k3s_apiserver_endpoint and therefore the Ansible-side k8sServiceHost resolve "
|
||||||
|
f"to) is {vip}. Flux and the Ansible bootstrap would render DIFFERENT API "
|
||||||
|
"endpoints into the same CNI and fight over it. If they differ because the "
|
||||||
|
"overlay still holds a NODE address, that is the 2026-09-06 defect: the CNI of "
|
||||||
|
"an HA control plane must not dial one server")
|
||||||
|
check(k3s_api_host_knob not in set(
|
||||||
|
as_ip(host_vars.get(h, {}).get("k3s_node_ip", ""))
|
||||||
|
for h in hosts_in(str(e.var("k3s_servers_group")))),
|
||||||
|
f"{L}: the overlay's K3S_API_HOST {k3s_api_host_knob} is one of this "
|
||||||
|
"environment's own NODE addresses. Cilium's kube-proxy replacement would dial a "
|
||||||
|
"single machine on a control plane built specifically to survive losing one")
|
||||||
|
|
||||||
|
# The Flux half must stay a per-environment TOKEN. A literal IP in the shared HelmRelease
|
||||||
|
# would ship one environment's control-plane address to every environment (R-ENV-02), and an
|
||||||
|
# empty value is the 2026-09-01 incident verbatim (k8sServiceHost: "" made the agent's config
|
||||||
|
# init container fall back to the in-cluster ClusterIP 10.43.0.1:443, which only the agent
|
||||||
|
# being replaced could translate: deadlocked on 2 of 3 nodes).
|
||||||
|
hr = ROOT / "platform" / "cilium" / "helmrelease.yaml"
|
||||||
|
check(hr.is_file(), "platform/cilium/helmrelease.yaml is missing — nothing adopts the CNI "
|
||||||
|
"config after the bootstrap, so the parity contract has only one side")
|
||||||
|
if hr.is_file():
|
||||||
|
hr_body = "\n".join(l for l in hr.read_text(errors="replace").splitlines()
|
||||||
|
if not l.lstrip().startswith("#"))
|
||||||
|
hm = re.search(r"^\s*k8sServiceHost:\s*\"?([^\"\n]*)\"?\s*$", hr_body, re.M)
|
||||||
|
check(hm is not None and hm.group(1).strip() == "${K3S_API_HOST}",
|
||||||
|
f"platform/cilium/helmrelease.yaml sets k8sServiceHost="
|
||||||
|
f"{(hm.group(1) if hm else None)!r} instead of the cluster-vars token "
|
||||||
|
"\"${K3S_API_HOST}\" — a literal there would ship ONE environment's control-plane "
|
||||||
|
"address to every environment (R-ENV-02), and an empty value is the 2026-09-01 "
|
||||||
|
"incident verbatim")
|
||||||
|
|
||||||
|
# ORDERING: the VIP must be floating BEFORE the CNI is installed against it. site.yml is the
|
||||||
|
# one-command bring-up, so this is a property of the authored sequence, not of an operator's
|
||||||
|
# memory. (kube-vip can precede the CNI because its DaemonSet is hostNetwork with
|
||||||
|
# KUBERNETES_SERVICE_HOST=127.0.0.1 — asserted separately below/above via the manifest parse.)
|
||||||
|
site_play = PLAYBOOKS / "site.yml"
|
||||||
|
check(site_play.is_file(), "playbooks/site.yml is missing — there is no one-command bring-up "
|
||||||
|
"whose ORDER can be checked")
|
||||||
|
if site_play.is_file():
|
||||||
|
imports = re.findall(r"import_playbook:\s*(\S+)", site_play.read_text())
|
||||||
|
for needed in ("k3s.yml", "kube-vip.yml", "cilium.yml"):
|
||||||
|
check(needed in imports,
|
||||||
|
f"playbooks/site.yml does not import {needed} (imports: {imports}). Cilium's "
|
||||||
|
"k8sServiceHost is the control-plane VIP, so kube-vip is a PREREQUISITE of the "
|
||||||
|
"CNI, not an optional extra step — leaving it out of the bring-up means a cold "
|
||||||
|
"build installs a CNI pointed at an address nothing announces")
|
||||||
|
if all(n in imports for n in ("k3s.yml", "kube-vip.yml", "cilium.yml")):
|
||||||
|
check(imports.index("k3s.yml") < imports.index("kube-vip.yml") < imports.index("cilium.yml"),
|
||||||
|
f"playbooks/site.yml imports {imports} — the order MUST be k3s.yml -> "
|
||||||
|
"kube-vip.yml -> cilium.yml. kube-vip after cilium installs the CNI against an "
|
||||||
|
"unannounced VIP (the 2026-09-01 shape: agents that cannot reach the API server "
|
||||||
|
"on a cold cluster); kube-vip before k3s cannot work at all, because the VIP "
|
||||||
|
"ships as a k3s auto-deploy manifest and needs a cluster to apply it")
|
||||||
|
check(re.search(r"wait_for", VIP_TEXT) is not None
|
||||||
|
and re.search(r"kube_vip_address", VIP_TEXT) is not None,
|
||||||
|
"roles/kube_vip never WAITS for the VIP to answer — templating the manifest is not the "
|
||||||
|
"same as having a floating endpoint (k3s still has to apply it, the image still has to "
|
||||||
|
"pull, leader election still has to settle). Since the CNI is installed immediately "
|
||||||
|
"afterward against this exact address, an unverified VIP turns a missing endpoint into "
|
||||||
|
"unreachable cilium-agents diagnosed from the far side")
|
||||||
|
|
||||||
|
# ============================================ FLOATING ENDPOINT: kube-vip on every server
|
||||||
|
# Read the DaemonSet's ACTUAL container env out of the template, never the role's prose.
|
||||||
|
# (An earlier draft matched `svc_enable=false` anywhere in the role and passed happily with
|
||||||
|
# the template flipped to true — the comment in defaults/main.yml satisfied it. Parse the
|
||||||
|
# thing that ships.) Parser contract: the template writes each env var as an adjacent
|
||||||
|
# `- name: X` / `value: "Y"` pair; comment lines are dropped first.
|
||||||
|
VIP_TEMPLATES = sorted(VIP_ROLE.rglob("*.j2"))
|
||||||
|
check(len(VIP_TEMPLATES) >= 1,
|
||||||
|
"roles/kube_vip ships no manifest template — nothing deploys the API VIP")
|
||||||
|
vip_manifest = "\n".join(
|
||||||
|
"\n".join(l for l in p.read_text(errors="replace").splitlines()
|
||||||
|
if not l.lstrip().startswith("#"))
|
||||||
|
for p in VIP_TEMPLATES)
|
||||||
|
vip_env = dict(re.findall(r"-\s*name:\s*([A-Za-z_][A-Za-z0-9_]*)\s*\n\s*value:\s*\"?([^\"\n]*)\"?",
|
||||||
|
vip_manifest))
|
||||||
|
check(len(vip_env) >= 6,
|
||||||
|
f"only {len(vip_env)} env var(s) parsed out of the kube-vip manifest template — the "
|
||||||
|
"parser lost its grip on the file, so the assertions below would assert nothing")
|
||||||
|
check(vip_env.get("cp_enable") == "true",
|
||||||
|
f"the kube-vip manifest sets cp_enable={vip_env.get('cp_enable')!r} — control-plane "
|
||||||
|
"mode must be ON or it floats no API VIP at all")
|
||||||
|
check(vip_env.get("svc_enable") == "false",
|
||||||
|
f"the kube-vip manifest sets svc_enable={vip_env.get('svc_enable')!r} — it must be "
|
||||||
|
"false, or kube-vip ALSO serves Service LoadBalancers and contends with Cilium LB-IPAM "
|
||||||
|
"for the same addresses (ADR-0002 gives Service LB to Cilium)")
|
||||||
|
check(vip_env.get("vip_leaderelection") == "true",
|
||||||
|
f"the kube-vip manifest sets vip_leaderelection={vip_env.get('vip_leaderelection')!r} "
|
||||||
|
"— without leader election the VIP has no defined owner to fail over FROM, which is "
|
||||||
|
"the whole HA mechanism")
|
||||||
|
check(vip_env.get("address", "").strip() == "{{ kube_vip_address }}",
|
||||||
|
f"the kube-vip manifest announces address={vip_env.get('address')!r} instead of the "
|
||||||
|
"environment-scoped {{ kube_vip_address }} — a literal here would float DEV's VIP on "
|
||||||
|
"prod (R-ENV-02) and would not match the serving-cert SAN list")
|
||||||
|
check(re.search(r"^kind:\s*DaemonSet\s*$", vip_manifest, re.M) is not None,
|
||||||
|
"the kube-vip manifest is not a DaemonSet — a single-replica VIP holder dies with its "
|
||||||
|
"node, which is the opposite of a floating endpoint")
|
||||||
|
vip_play = PLAYBOOKS / "kube-vip.yml"
|
||||||
|
check(vip_play.is_file(), "playbooks/kube-vip.yml is missing — nothing deploys the API VIP")
|
||||||
|
if vip_play.is_file():
|
||||||
|
vp = vip_play.read_text()
|
||||||
|
vip_hosts = re.findall(r"^\s*hosts:\s*(.+)$", vp, re.M)
|
||||||
|
check(len(vip_hosts) == 1 and "k3s_hosts" in vip_hosts[0],
|
||||||
|
f"playbooks/kube-vip.yml targets {vip_hosts} — it must target the whole servers "
|
||||||
|
"group through the same environment-scoped variable the other plays use, so the "
|
||||||
|
"manifest exists on EVERY server and the VIP survives losing any one of them")
|
||||||
|
check(re.search(r"kube_vip_manifest_path", VIP_TEXT) is not None
|
||||||
|
and re.search(r"k3s_data_dir", VIP_TEXT) is not None,
|
||||||
|
"roles/kube_vip's manifest path is not derived from k3s_data_dir — k3s only auto-applies "
|
||||||
|
"<data-dir>/server/manifests, so an environment that moves --data-dir would write the "
|
||||||
|
"manifest somewhere k3s never reads and come up with no VIP at all")
|
||||||
|
|
||||||
|
# ================================================== ONE CODE PATH FOR BOTH ENVIRONMENTS
|
||||||
|
# docs/02: "For prod bare-metal, the *same* Ansible bootstrap runs; only the machine-creation
|
||||||
|
# step differs". A prod-only role or playbook would be a fork of the control-plane path.
|
||||||
|
role_names = {p.name for p in (ANS / "roles").iterdir() if p.is_dir()}
|
||||||
|
play_names = {p.name for p in PLAYBOOKS.glob("*.yml")}
|
||||||
|
env_words = re.compile(r"prod|dev", re.I)
|
||||||
|
forked = sorted(n for n in (role_names | play_names)
|
||||||
|
if env_words.search(n) and n not in ("memattr_survey", "memattr-survey.yml"))
|
||||||
|
check(not forked,
|
||||||
|
f"environment-named roles/playbooks exist ({forked}) — dev and prod must be built by "
|
||||||
|
"the SAME code retargeted by variables (docs/02), not by a per-environment fork")
|
||||||
|
for e in envs:
|
||||||
|
if e.path.name != "vars.yml":
|
||||||
|
continue
|
||||||
|
# Every key a non-default environment sets must be a variable something already reads,
|
||||||
|
# or it is an invented knob nothing consumes (a silent no-op that looks like config).
|
||||||
|
consumers = "\n".join(p.read_text(errors="replace") for p in sorted(ANS.rglob("*"))
|
||||||
|
if p.is_file() and p.suffix in (".yml", ".yaml", ".j2", ".sh"))
|
||||||
|
for k in sorted(e.vars):
|
||||||
|
if k.startswith("ansible_"):
|
||||||
|
continue
|
||||||
|
check(consumers.count(k) > 1,
|
||||||
|
f"{e.label} sets {k!r}, which appears nowhere else under infra/ansible — an "
|
||||||
|
"environment override that nothing reads is not a scoped override, it is dead "
|
||||||
|
"configuration (and would make prod silently inherit a dev default)")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- verdict
|
||||||
|
# NO-OP GUARD: a check that quietly asserts nothing is worse than no check.
|
||||||
|
check(CHECKS >= 40,
|
||||||
|
f"only {CHECKS} assertions ran — the check degenerated to a near-no-op and its pass "
|
||||||
|
"would be meaningless")
|
||||||
|
|
||||||
|
if FAILURES:
|
||||||
|
die()
|
||||||
|
for n in NOTES:
|
||||||
|
print(f" note: {n}")
|
||||||
|
summary = "; ".join(
|
||||||
|
f"{e.label.split('/')[-1]}[{'PROD' if e in prod_envs else 'non-prod'}]="
|
||||||
|
f"{len(hosts_in(str(e.var('k3s_servers_group'))))} servers"
|
||||||
|
for e in envs)
|
||||||
|
print(f"check OK: ha-control-plane-as-code — {CHECKS} assertions; {summary}. "
|
||||||
|
"R-PLAT-01's AUTHORED half only: k3s, one --cluster-init founder per environment, "
|
||||||
|
"embedded etcd on every server with no external datastore, a leader-elected "
|
||||||
|
"control-plane-only VIP on every server that is SAN'd into the serving cert and "
|
||||||
|
"collides with no other allocator, floated BEFORE the CNI that dials it (and the CNI "
|
||||||
|
"dials that VIP, not the founder, in both environments and on both sides of the "
|
||||||
|
"[parity] contract), from one code path for both environments. The LIVE "
|
||||||
|
"cluster shape is proven at L2 (tests/l2-behavioral/ha-control-plane); control-plane "
|
||||||
|
"SURVIVAL of a node loss is proven NOWHERE yet (see the header's gap list).")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — Keycloak client secrets are declared consistently, ordered by a REAL
|
||||||
|
# Flux edge, and healed by a reconciler whose behaviour is proven offline
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
#
|
||||||
|
# R-IDP-03 (identity config is declarative) and R-IDP-04 (IdP secrets SOPS-encrypted)
|
||||||
|
# are both implemented through ONE mechanism that FAILS OPEN: the KeycloakRealmImport
|
||||||
|
# resolves each client's secret from a `(env:NAME)` placeholder mapped to a key of the
|
||||||
|
# SOPS-encrypted keycloak-realm-clients Secret. When the operator cannot resolve a
|
||||||
|
# placeholder it stores the LITERAL placeholder text as the client secret AND THE IMPORT
|
||||||
|
# JOB STILL REPORTS SUCCESS — then rests at Done=True / Started=False and never re-runs.
|
||||||
|
#
|
||||||
|
# Measured 2026-09-06: prod had all 12 clients holding the literal placeholder (every
|
||||||
|
# OIDC login failed with "Invalid client or Invalid client credentials"); dev had exactly
|
||||||
|
# one (oauth2-proxy), because that Secret key was added AFTER dev's import completed.
|
||||||
|
# Declarative config that silently does not apply is not declarative config.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as authored,
|
||||||
|
# THEN every confidential client declares a placeholder, every placeholder is defined,
|
||||||
|
# resolves to the keycloak-realm-clients Secret, and names a key that EXISTS in the
|
||||||
|
# SOPS file under the `<clientId>-client-secret` contract the reconciler derives
|
||||||
|
# from; public clients declare no secret; the Flux Kustomization applying the Secret
|
||||||
|
# is a transitive `dependsOn` ancestor (wait: true) of the ones applying the import
|
||||||
|
# and the reconciler — a real edge, not a comment; the reconciler is reconciled from
|
||||||
|
# BOTH cluster entrypoints, mounts only what it needs, carries no ServiceAccount
|
||||||
|
# token and contains no dollar sign; and its own `--selftest` (extracted from the
|
||||||
|
# shipped ConfigMap and executed) proves compare-then-write, idempotence,
|
||||||
|
# no-secret-logging, the endpoint allow-list and fail-closed behaviour.
|
||||||
|
#
|
||||||
|
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (git + PyYAML +
|
||||||
|
# stdlib); conformance/run.sh --layer l0 and ci/l0.sh run it directly, and this Chainsaw
|
||||||
|
# Test wraps the SAME script.
|
||||||
|
#
|
||||||
|
# NOT proven here: that the LIVE realm's stored secrets match git (that is
|
||||||
|
# tests/l2-behavioral/keycloak-client-secrets-match-git), that the SOPS ciphertext
|
||||||
|
# decrypts to a usable value (key PRESENCE is offline-checkable; plaintext is not), or
|
||||||
|
# that the CronJob's schedule fires.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: keycloak-client-secret-reconciler
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-IDP-03: "true"
|
||||||
|
conformance.home.k3s/R-IDP-04: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-IDP-03, R-IDP-04"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Every OIDC client's secret is declared once, resolvable, ordered behind a real Flux
|
||||||
|
dependency, and continuously reconciled — so an unresolved import placeholder can
|
||||||
|
neither be merged nor silently persist in the realm.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: audit-declaration-ordering-and-reconciler
|
||||||
|
description: >-
|
||||||
|
Assert the realm import, the SOPS Secret and the reconciler agree; that the Flux
|
||||||
|
ordering is a real dependsOn edge; and run the reconciler's own selftest.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — keycloak-client-secret-reconciler
|
||||||
|
# Serves: R-IDP-03 ("Identity config (realms, clients, groups, roles) is declarative."
|
||||||
|
# — spec docs/15-identity-and-access.md)
|
||||||
|
# R-IDP-04 ("IdP secrets SOPS-encrypted; MFA available."
|
||||||
|
# — spec docs/15-identity-and-access.md)
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS — THE REALM IMPORT IS A ONE-SHOT THAT FAILS OPEN
|
||||||
|
# ---------------------------------------------------------------
|
||||||
|
# platform/keycloak/keycloak-realm-import.yaml declares 12 confidential OIDC clients
|
||||||
|
# whose secrets are `(env:NAME)` import placeholders, mapped by spec.placeholders to
|
||||||
|
# keys of the SOPS-encrypted keycloak-realm-clients Secret. Measured on both clusters
|
||||||
|
# on 2026-09-06:
|
||||||
|
#
|
||||||
|
# * when the Keycloak Operator cannot resolve a placeholder it stores the LITERAL
|
||||||
|
# placeholder text as the client secret AND THE IMPORT JOB STILL REPORTS SUCCESS;
|
||||||
|
# * the KeycloakRealmImport then rests at Done=True / Started=False and NEVER
|
||||||
|
# re-runs, so it never self-heals.
|
||||||
|
#
|
||||||
|
# Consequence: on prod all 12 clients held the literal placeholder and every OIDC
|
||||||
|
# login on the cluster failed ("Invalid client or Invalid client credentials"); on dev
|
||||||
|
# exactly one (oauth2-proxy) was broken, because that Secret key was added AFTER dev's
|
||||||
|
# import had already completed and a completed import is never re-run.
|
||||||
|
#
|
||||||
|
# Nothing in the repo asserted the declaration was self-consistent, and nothing
|
||||||
|
# corrected the realm afterwards. This check is the static half of the fix (the
|
||||||
|
# reconciler CronJob is the runtime half, and this check proves that too).
|
||||||
|
#
|
||||||
|
# GIVEN the repository as authored,
|
||||||
|
# THEN (1) DECLARATION IS COMPLETE: every confidential client in the realm import
|
||||||
|
# declares a secret placeholder; every placeholder it uses is defined in
|
||||||
|
# spec.placeholders; every placeholder resolves to the keycloak-realm-clients
|
||||||
|
# Secret; and every referenced key EXISTS in the SOPS file. A client whose
|
||||||
|
# key is missing is exactly the dev failure, and it is now unmergeable.
|
||||||
|
# (2) THE NAMING CONTRACT HOLDS: each key is `<clientId>-client-secret`. That is
|
||||||
|
# what lets the reconciler derive the client set from the mounted Secret with
|
||||||
|
# no second declaration — so it must be enforced, not assumed. No orphan
|
||||||
|
# placeholder, and no orphan Secret key (an orphan key would make every
|
||||||
|
# reconciler run fail with NOT FOUND).
|
||||||
|
# (3) PUBLIC CLIENTS DECLARE NO SECRET (the `kubernetes` client is PKCE-public;
|
||||||
|
# giving it a secret would be a silent downgrade).
|
||||||
|
# (4) ORDERING IS A REAL FLUX EDGE, NOT A COMMENT: the Flux Kustomization that
|
||||||
|
# renders the Secret is a transitive `dependsOn` ancestor — with `wait: true`
|
||||||
|
# — of the one that renders the realm import and the reconciler. The repo's
|
||||||
|
# hard-won rule: a dependency that is only DOCUMENTED is not a dependency.
|
||||||
|
# (5) THE RECONCILER IS WIRED AND SAFE: it is reconciled from BOTH cluster
|
||||||
|
# entrypoints, mounts exactly the two Secrets it needs at the paths its code
|
||||||
|
# defaults to, carries no ServiceAccount token, and contains no dollar sign
|
||||||
|
# (it ships inside a substituted Flux path, where an unescaped dollar-brace
|
||||||
|
# expansion is rewritten before any container runs).
|
||||||
|
# (6) THE RECONCILER'S BEHAVIOUR IS PROVEN, NOT ASSERTED: this check EXTRACTS the
|
||||||
|
# script from the ConfigMap and executes its `--selftest`, which drives the
|
||||||
|
# real code paths against an in-process stub Keycloak and asserts
|
||||||
|
# compare-then-write, idempotence (zero writes on a converged realm),
|
||||||
|
# no-secret-logging, the endpoint allow-list (users/groups/roles are
|
||||||
|
# unreachable) and fail-closed behaviour. What CI proves is byte-for-byte
|
||||||
|
# what ships.
|
||||||
|
# AND (7) THIS CHECK IS LOAD-BEARING: ci/l0.sh runs it unconditionally, ci/gate.sh
|
||||||
|
# blocks the merge when the l0 stage fails, ci/touched-requirements.sh maps
|
||||||
|
# platform/keycloak/ to these requirement IDs, and an L2 test exists that
|
||||||
|
# audits the LIVE realm. An unreferenced test is precisely the "looks applied
|
||||||
|
# and isn't" failure this repo keeps hitting.
|
||||||
|
#
|
||||||
|
# NOT proven here (named honestly, in the style of the sibling L0 checks):
|
||||||
|
# - that the LIVE realm's stored secrets match git. That is cluster state; it is
|
||||||
|
# conformance/tests/l2-behavioral/keycloak-client-secrets-match-git.
|
||||||
|
# - that the SOPS ciphertext decrypts to a usable secret. Key PRESENCE is checkable
|
||||||
|
# offline; the plaintext is not, and must not be.
|
||||||
|
# - that the CronJob's image can be pulled or its schedule fires.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
IMPORT_FILE="platform/keycloak/keycloak-realm-import.yaml"
|
||||||
|
SOPS_FILE="platform/keycloak/keycloak-realm-clients.sops.yaml"
|
||||||
|
RECON_FILE="platform/keycloak/client-secret-reconciler.yaml"
|
||||||
|
|
||||||
|
# Honest SKIP (never green) if the thing under test is absent entirely.
|
||||||
|
for f in "$IMPORT_FILE" "$SOPS_FILE" "$RECON_FILE"; do
|
||||||
|
if [ ! -f "${ROOT}/${f}" ]; then
|
||||||
|
echo "check SKIPPED: ${f} absent — nothing to audit" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
cd "${ROOT}"
|
||||||
|
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||||
|
"""Static audit of the Keycloak client-secret declaration, its Flux ordering, and the
|
||||||
|
reconciler that heals it. PyYAML (the same dependency tests/l0-static/no-plaintext-secrets
|
||||||
|
and least-privilege-rbac already take) parses the manifests; lib/status.py is IMPORTED for
|
||||||
|
the Flux graph walk rather than forked, so there is one definition of "what a Flux path
|
||||||
|
renders" in the harness."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||||
|
file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
sys.dont_write_bytecode = True # keep __pycache__ out of the tracked tree
|
||||||
|
sys.path.insert(0, str(ROOT / "conformance" / "lib"))
|
||||||
|
import status as st # noqa: E402 — reuse the walk, don't fork it
|
||||||
|
|
||||||
|
IMPORT_FILE = "platform/keycloak/keycloak-realm-import.yaml"
|
||||||
|
SOPS_FILE = "platform/keycloak/keycloak-realm-clients.sops.yaml"
|
||||||
|
RECON_FILE = "platform/keycloak/client-secret-reconciler.yaml"
|
||||||
|
SECRET_NAME = "keycloak-realm-clients"
|
||||||
|
ADMIN_SECRET = "keycloak-admin-credentials"
|
||||||
|
KEY_SUFFIX = "-client-secret"
|
||||||
|
SECRETS_MOUNT = "/etc/keycloak-realm-clients"
|
||||||
|
DOLLAR = chr(36)
|
||||||
|
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
CHECKS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def load(rel):
|
||||||
|
return list(yaml.safe_load_all((ROOT / rel).read_text()))
|
||||||
|
|
||||||
|
|
||||||
|
# ================================================================= (1)-(3) declaration
|
||||||
|
realm_import = next((d for d in load(IMPORT_FILE)
|
||||||
|
if d and d.get("kind") == "KeycloakRealmImport"), None)
|
||||||
|
check(realm_import is not None, f"{IMPORT_FILE}: no KeycloakRealmImport document found")
|
||||||
|
if realm_import is None:
|
||||||
|
print("CHECK FAIL: cannot parse the realm import", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
placeholders = realm_import["spec"].get("placeholders") or {}
|
||||||
|
clients = realm_import["spec"]["realm"].get("clients") or []
|
||||||
|
check(len(clients) > 0, f"{IMPORT_FILE}: parsed zero clients — the file shape changed and "
|
||||||
|
f"this check would silently audit nothing")
|
||||||
|
|
||||||
|
# The operator's placeholder syntax, spelled without a literal dollar so this file is
|
||||||
|
# itself safe to grep for one. Matches `<dollar>(env:NAME)`.
|
||||||
|
PLACEHOLDER_RE = re.compile(re.escape(DOLLAR) + r"\(env:([A-Za-z_][A-Za-z0-9_]*)\)")
|
||||||
|
|
||||||
|
used_placeholders: set[str] = set()
|
||||||
|
declared_pairs: dict[str, str] = {} # clientId -> secret key in the Secret
|
||||||
|
|
||||||
|
for c in clients:
|
||||||
|
cid = c.get("clientId", "<unnamed>")
|
||||||
|
secret = c.get("secret")
|
||||||
|
public = bool(c.get("publicClient"))
|
||||||
|
if public:
|
||||||
|
# (3) a PKCE public client must not carry a secret at all.
|
||||||
|
check(secret is None,
|
||||||
|
f"{IMPORT_FILE}: client '{cid}' is publicClient but declares a secret — a "
|
||||||
|
f"public client holds no secret; declaring one is a silent downgrade")
|
||||||
|
continue
|
||||||
|
# (1) every confidential client must declare one.
|
||||||
|
check(isinstance(secret, str) and secret,
|
||||||
|
f"{IMPORT_FILE}: confidential client '{cid}' declares NO secret. The operator "
|
||||||
|
f"would generate a random one that exists nowhere in git, so nothing could "
|
||||||
|
f"ever reconcile it and the consuming app's mirrored credential would be wrong")
|
||||||
|
if not isinstance(secret, str):
|
||||||
|
continue
|
||||||
|
m = PLACEHOLDER_RE.fullmatch(secret.strip())
|
||||||
|
check(m is not None,
|
||||||
|
f"{IMPORT_FILE}: client '{cid}' has a secret that is not an (env:NAME) "
|
||||||
|
f"placeholder. A literal secret in git violates R-IDP-04/R-SEC-01; the only "
|
||||||
|
f"permitted form is a placeholder resolved from the {SECRET_NAME} Secret")
|
||||||
|
if m is None:
|
||||||
|
continue
|
||||||
|
name = m.group(1)
|
||||||
|
used_placeholders.add(name)
|
||||||
|
# (1) the placeholder must be DEFINED. An undefined one is stored literally by the
|
||||||
|
# operator, with a successful-looking import — the whole incident.
|
||||||
|
ph = placeholders.get(name)
|
||||||
|
check(ph is not None,
|
||||||
|
f"{IMPORT_FILE}: client '{cid}' uses placeholder '{name}' but spec.placeholders "
|
||||||
|
f"does not define it. The operator stores the LITERAL placeholder as the client "
|
||||||
|
f"secret and STILL reports the import successful (measured 2026-09-06)")
|
||||||
|
if ph is None:
|
||||||
|
continue
|
||||||
|
ref = (ph or {}).get("secret") or {}
|
||||||
|
check(ref.get("name") == SECRET_NAME,
|
||||||
|
f"{IMPORT_FILE}: placeholder '{name}' reads Secret '{ref.get('name')}', not "
|
||||||
|
f"'{SECRET_NAME}'. The reconciler mounts only {SECRET_NAME}, so a second source "
|
||||||
|
f"would be declared but never healed")
|
||||||
|
key = ref.get("key")
|
||||||
|
check(isinstance(key, str) and key,
|
||||||
|
f"{IMPORT_FILE}: placeholder '{name}' names no Secret key")
|
||||||
|
if not isinstance(key, str):
|
||||||
|
continue
|
||||||
|
# (2) the naming contract the reconciler derives its client set from.
|
||||||
|
check(key == cid + KEY_SUFFIX,
|
||||||
|
f"{IMPORT_FILE}: client '{cid}' reads Secret key '{key}', but the reconciler "
|
||||||
|
f"derives the clientId from the key name, so it MUST be "
|
||||||
|
f"'{cid}{KEY_SUFFIX}'. Rename the key (and the SOPS entry) or the reconciler "
|
||||||
|
f"would look up a client that does not exist")
|
||||||
|
declared_pairs[cid] = key
|
||||||
|
|
||||||
|
# (2) no orphan placeholder: a defined-but-unused placeholder is dead config that will
|
||||||
|
# quietly rot out of sync with the Secret.
|
||||||
|
for name in sorted(set(placeholders) - used_placeholders):
|
||||||
|
check(False,
|
||||||
|
f"{IMPORT_FILE}: spec.placeholders defines '{name}' but no client uses it — "
|
||||||
|
f"dead declaration; remove it or wire it to the client that needs it")
|
||||||
|
|
||||||
|
# ---- the SOPS Secret: keys are cleartext in a SOPS-encrypted file; values are not.
|
||||||
|
sops_doc = next((d for d in load(SOPS_FILE) if d and d.get("kind") == "Secret"), None)
|
||||||
|
check(sops_doc is not None, f"{SOPS_FILE}: no Secret document found")
|
||||||
|
sops_keys = set((sops_doc or {}).get("stringData") or {})
|
||||||
|
check(len(sops_keys) > 0,
|
||||||
|
f"{SOPS_FILE}: parsed zero stringData keys — the file shape changed and this check "
|
||||||
|
f"would silently audit nothing")
|
||||||
|
check((sops_doc or {}).get("metadata", {}).get("name") == SECRET_NAME,
|
||||||
|
f"{SOPS_FILE}: the Secret is not named {SECRET_NAME}")
|
||||||
|
|
||||||
|
for cid, key in sorted(declared_pairs.items()):
|
||||||
|
# (1) THE DEV FAILURE, made unmergeable: a client declared in the import whose key
|
||||||
|
# is absent from the Secret imports "successfully" with a literal placeholder.
|
||||||
|
check(key in sops_keys,
|
||||||
|
f"{SOPS_FILE}: client '{cid}' needs key '{key}' and it is NOT in the Secret. "
|
||||||
|
f"The realm import would still report SUCCESS and store the literal placeholder "
|
||||||
|
f"as that client's secret — this is exactly how dev's oauth2-proxy broke")
|
||||||
|
|
||||||
|
# (2) no orphan Secret key: the reconciler treats every `<x>-client-secret` key as a
|
||||||
|
# client that MUST exist, and fails loudly if it does not. Catch it here instead.
|
||||||
|
for key in sorted(k for k in sops_keys if k.endswith(KEY_SUFFIX)):
|
||||||
|
cid = key[: -len(KEY_SUFFIX)]
|
||||||
|
check(cid in declared_pairs,
|
||||||
|
f"{SOPS_FILE}: key '{key}' has no matching client in {IMPORT_FILE}. The "
|
||||||
|
f"reconciler would look up client '{cid}', not find it, and fail every run")
|
||||||
|
|
||||||
|
# ===================================================== (4) ordering is a real Flux edge
|
||||||
|
# Parse contract mirrors lib/status.py's flux_paths(): docs split on a lone `---`; a doc
|
||||||
|
# is an entrypoint only if it carries the kustomize.toolkit apiVersion, `kind:
|
||||||
|
# Kustomization` and a spec-level `path:`. Overlay `patch: |-` bodies carry Kustomizations
|
||||||
|
# WITHOUT a path and are correctly skipped (they are patches, not entrypoints).
|
||||||
|
_DEPENDSON_RE = re.compile(r"^(\s*)dependsOn:\s*$", re.M)
|
||||||
|
_DEP_NAME_RE = re.compile(r"^\s*(?:-\s*)?name:\s*([^\s#]+)")
|
||||||
|
_WAIT_RE = re.compile(r"^\s{2,6}wait:\s*true\s*(?:#.*)?$", re.M)
|
||||||
|
|
||||||
|
|
||||||
|
def depends_on(doc: str) -> list[str]:
|
||||||
|
m = _DEPENDSON_RE.search(doc)
|
||||||
|
if not m:
|
||||||
|
return []
|
||||||
|
indent = len(m.group(1))
|
||||||
|
out: list[str] = []
|
||||||
|
for raw in doc[m.end():].splitlines():
|
||||||
|
if raw.strip() == "" or raw.lstrip().startswith("#"):
|
||||||
|
continue
|
||||||
|
if len(raw) - len(raw.lstrip()) <= indent and not raw.lstrip().startswith("-"):
|
||||||
|
break # dedented out of the list
|
||||||
|
nm = _DEP_NAME_RE.match(raw)
|
||||||
|
if nm:
|
||||||
|
out.append(nm.group(1))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
kustomizations: dict[str, dict] = {}
|
||||||
|
for yfile in sorted((ROOT / "clusters").rglob("*.yaml")):
|
||||||
|
text = yfile.read_text()
|
||||||
|
if st.FLUX_API not in text:
|
||||||
|
continue
|
||||||
|
for doc in st._DOC_SPLIT_RE.split(text):
|
||||||
|
if st.FLUX_API not in doc or not re.search(r"^kind:\s*Kustomization\s*$", doc, re.M):
|
||||||
|
continue
|
||||||
|
pm = st._PATH_RE.search(doc)
|
||||||
|
if not pm:
|
||||||
|
continue
|
||||||
|
nm = st._NAME_RE.search(doc)
|
||||||
|
name = nm.group(1) if nm else "?"
|
||||||
|
kustomizations[name] = {
|
||||||
|
"file": str(yfile.relative_to(ROOT)),
|
||||||
|
"path": pm.group(1),
|
||||||
|
"dependsOn": depends_on(doc),
|
||||||
|
"wait": bool(_WAIT_RE.search(doc)),
|
||||||
|
}
|
||||||
|
|
||||||
|
check(len(kustomizations) > 0,
|
||||||
|
"no Flux Kustomization with a spec.path found under clusters/ — the parser or the "
|
||||||
|
"repo layout changed; this check would silently audit nothing")
|
||||||
|
|
||||||
|
|
||||||
|
def direct_render(path_spec: str) -> set[str]:
|
||||||
|
"""Files a Flux `spec.path` renders ITSELF — a kustomize walk that deliberately does
|
||||||
|
NOT expand nested Flux Kustomizations.
|
||||||
|
|
||||||
|
lib/status.py's reconciled_files() follows every `spec.path` it discovers, which is
|
||||||
|
right for "is this file applied to the cluster at all" but wrong for OWNERSHIP: the
|
||||||
|
root Kustomization's path is clusters/<env>, so a transitive walk makes the root the
|
||||||
|
owner of every file in the repo and the ordering assertion below becomes vacuous
|
||||||
|
(measured: it reported the root as an owner of all three keycloak files). `resources:`
|
||||||
|
parsing itself is st.kustomize_resources, not forked.
|
||||||
|
"""
|
||||||
|
rel = path_spec.lstrip("./") or "."
|
||||||
|
out: set[str] = set()
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
|
def walk(d: str) -> None:
|
||||||
|
if d in seen:
|
||||||
|
return
|
||||||
|
seen.add(d)
|
||||||
|
kfile = ROOT / d / "kustomization.yaml"
|
||||||
|
if not kfile.is_file():
|
||||||
|
return
|
||||||
|
out.add(str(kfile.relative_to(ROOT)))
|
||||||
|
for item in st.kustomize_resources(kfile):
|
||||||
|
if "://" in item:
|
||||||
|
continue # remote base: not repo state
|
||||||
|
target = (ROOT / d / item).resolve()
|
||||||
|
try:
|
||||||
|
trel = target.relative_to(ROOT).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
continue # outside the repo
|
||||||
|
if target.is_dir():
|
||||||
|
walk(trel)
|
||||||
|
elif target.is_file():
|
||||||
|
out.add(trel)
|
||||||
|
|
||||||
|
if (ROOT / rel).is_dir():
|
||||||
|
walk(rel)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
rendered_by = {n: direct_render(k["path"]) for n, k in kustomizations.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def owners(rel_file: str) -> list[str]:
|
||||||
|
return sorted(n for n, files in rendered_by.items() if rel_file in files)
|
||||||
|
|
||||||
|
|
||||||
|
def ancestors(name: str, seen=None) -> set[str]:
|
||||||
|
"""Transitive dependsOn closure of `name` (its dependencies, direct and indirect)."""
|
||||||
|
seen = seen if seen is not None else set()
|
||||||
|
for dep in kustomizations.get(name, {}).get("dependsOn", []):
|
||||||
|
if dep in seen:
|
||||||
|
continue
|
||||||
|
seen.add(dep)
|
||||||
|
ancestors(dep, seen)
|
||||||
|
return seen
|
||||||
|
|
||||||
|
|
||||||
|
secret_owners = owners(SOPS_FILE)
|
||||||
|
import_owners = owners(IMPORT_FILE)
|
||||||
|
recon_owners = owners(RECON_FILE)
|
||||||
|
|
||||||
|
check(bool(secret_owners),
|
||||||
|
f"{SOPS_FILE} is rendered by NO Flux Kustomization — the client secrets are in git "
|
||||||
|
f"but never applied")
|
||||||
|
check(bool(import_owners),
|
||||||
|
f"{IMPORT_FILE} is rendered by NO Flux Kustomization")
|
||||||
|
# (5) the reconciler must actually be reconciled, or it is decoration.
|
||||||
|
check(bool(recon_owners),
|
||||||
|
f"{RECON_FILE} is rendered by NO Flux Kustomization — the healer would sit in git "
|
||||||
|
f"and never run, which is the 'looks applied and isn't' failure it exists to end")
|
||||||
|
|
||||||
|
for consumer_file, consumer_owners in ((IMPORT_FILE, import_owners),
|
||||||
|
(RECON_FILE, recon_owners)):
|
||||||
|
for co in consumer_owners:
|
||||||
|
for so in secret_owners:
|
||||||
|
if so == co:
|
||||||
|
continue # same Kustomization: one atomic apply, ordering moot
|
||||||
|
anc = ancestors(co)
|
||||||
|
check(so in anc,
|
||||||
|
f"ORDERING IS ONLY DOCUMENTED: '{co}' renders {consumer_file} but does "
|
||||||
|
f"not (transitively) dependsOn '{so}', which renders the "
|
||||||
|
f"{SECRET_NAME} Secret. Without that edge the consumer can reconcile "
|
||||||
|
f"before the secret exists, and the Keycloak Operator's response to a "
|
||||||
|
f"missing placeholder is to store it LITERALLY and report success")
|
||||||
|
if so in anc:
|
||||||
|
check(kustomizations[so]["wait"],
|
||||||
|
f"'{so}' (which renders the {SECRET_NAME} Secret) does not set "
|
||||||
|
f"wait: true, so '{co}' can proceed before it is applied")
|
||||||
|
|
||||||
|
# ------ both environments must reconcile the reconciler, not just dev.
|
||||||
|
for env in ("dev", "prod"):
|
||||||
|
entry = f"clusters/{env}"
|
||||||
|
if not (ROOT / entry).is_dir():
|
||||||
|
continue
|
||||||
|
applied, _ = st.reconciled_files(ROOT, entry)
|
||||||
|
check(RECON_FILE in applied,
|
||||||
|
f"{RECON_FILE} is not reachable from the {entry} entrypoint — the healer would "
|
||||||
|
f"exist on one cluster only, and prod is where all 12 clients were broken")
|
||||||
|
check(IMPORT_FILE in applied, f"{IMPORT_FILE} is not reachable from {entry}")
|
||||||
|
|
||||||
|
# ============================================ (5) the reconciler manifest is safe + wired
|
||||||
|
recon_docs = [d for d in load(RECON_FILE) if d]
|
||||||
|
cm = next((d for d in recon_docs if d.get("kind") == "ConfigMap"), None)
|
||||||
|
cj = next((d for d in recon_docs if d.get("kind") == "CronJob"), None)
|
||||||
|
check(cm is not None, f"{RECON_FILE}: no ConfigMap carrying the reconciler script")
|
||||||
|
check(cj is not None, f"{RECON_FILE}: no CronJob running the reconciler")
|
||||||
|
|
||||||
|
script = ""
|
||||||
|
if cm is not None:
|
||||||
|
data = cm.get("data") or {}
|
||||||
|
check(len(data) == 1,
|
||||||
|
f"{RECON_FILE}: the reconciler ConfigMap must carry exactly one script key "
|
||||||
|
f"(found {sorted(data)}) — a second copy would be untested by this gate")
|
||||||
|
script = next(iter(data.values()), "")
|
||||||
|
check(len(script.splitlines()) > 50,
|
||||||
|
f"{RECON_FILE}: the extracted script is implausibly short "
|
||||||
|
f"({len(script.splitlines())} lines) — the ConfigMap shape changed and the "
|
||||||
|
f"selftest below would prove nothing")
|
||||||
|
# THE nextcloud TRAP. This file is rendered by a Flux Kustomization with
|
||||||
|
# postBuild.substituteFrom, so envsubst rewrites it before any container runs. The
|
||||||
|
# script is written with NO dollar sign at all, so the hazard is structural rather
|
||||||
|
# than escaped and no future edit has to reason about escaping.
|
||||||
|
check(DOLLAR not in script,
|
||||||
|
f"{RECON_FILE}: the reconciler script contains a dollar sign. It ships inside a "
|
||||||
|
f"SUBSTITUTED Flux path, where a dollar-brace expansion is rewritten at "
|
||||||
|
f"reconcile time (see apps/wave-1/nextcloud.yaml). Rewrite without it — Python "
|
||||||
|
f"never needs one; chr(36) spells it where a literal is genuinely required")
|
||||||
|
|
||||||
|
if cj is not None:
|
||||||
|
pod = cj["spec"]["jobTemplate"]["spec"]["template"]["spec"]
|
||||||
|
ctr = pod["containers"][0]
|
||||||
|
vols = {v["name"]: v for v in pod.get("volumes", [])}
|
||||||
|
mounts = {m["name"]: m for m in ctr.get("volumeMounts", [])}
|
||||||
|
secret_vols = [v for v in vols.values()
|
||||||
|
if (v.get("secret") or {}).get("secretName") == SECRET_NAME]
|
||||||
|
check(len(secret_vols) == 1,
|
||||||
|
f"{RECON_FILE}: the CronJob must mount the {SECRET_NAME} Secret — that mount IS "
|
||||||
|
f"the desired state it reconciles towards")
|
||||||
|
if secret_vols:
|
||||||
|
mp = mounts.get(secret_vols[0]["name"], {}).get("mountPath")
|
||||||
|
check(mp == SECRETS_MOUNT,
|
||||||
|
f"{RECON_FILE}: {SECRET_NAME} is mounted at {mp}, but the script defaults "
|
||||||
|
f"to {SECRETS_MOUNT} and no env var overrides it")
|
||||||
|
cm_vols = [v for v in vols.values()
|
||||||
|
if (v.get("configMap") or {}).get("name") == (cm or {}).get(
|
||||||
|
"metadata", {}).get("name")]
|
||||||
|
check(len(cm_vols) == 1,
|
||||||
|
f"{RECON_FILE}: the CronJob does not mount the reconciler ConfigMap, so it "
|
||||||
|
f"would run nothing")
|
||||||
|
env_srcs = {e["name"]: e for e in ctr.get("env", [])}
|
||||||
|
for var in ("KEYCLOAK_ADMIN_USER", "KEYCLOAK_ADMIN_PASSWORD"):
|
||||||
|
ref = (env_srcs.get(var, {}).get("valueFrom") or {}).get("secretKeyRef") or {}
|
||||||
|
check(ref.get("name") == ADMIN_SECRET,
|
||||||
|
f"{RECON_FILE}: {var} must come from the {ADMIN_SECRET} Secret "
|
||||||
|
f"(got {ref.get('name')!r}) — the admin credential is never inlined")
|
||||||
|
for var in ("KEYCLOAK_BASE_URL", "KEYCLOAK_REALM"):
|
||||||
|
check(env_srcs.get(var, {}).get("value"),
|
||||||
|
f"{RECON_FILE}: {var} is not set; the script fails closed without it, but "
|
||||||
|
f"the CronJob should not ship broken")
|
||||||
|
# R-SEC-02: it talks only to Keycloak's REST API and needs no Kubernetes API rights.
|
||||||
|
check(pod.get("automountServiceAccountToken") is False,
|
||||||
|
f"{RECON_FILE}: the reconciler needs NO Kubernetes API access (its inputs are "
|
||||||
|
f"mounted Secrets) — set automountServiceAccountToken: false")
|
||||||
|
check(cj["spec"].get("concurrencyPolicy") == "Forbid",
|
||||||
|
f"{RECON_FILE}: set concurrencyPolicy: Forbid so a run stalled on an "
|
||||||
|
f"unavailable Keycloak is not lapped by the next tick")
|
||||||
|
img = ctr.get("image", "")
|
||||||
|
check(re.search(r":[0-9]", img),
|
||||||
|
f"{RECON_FILE}: container image {img!r} is not pinned to an explicit version")
|
||||||
|
|
||||||
|
# ============================== (6) the reconciler's BEHAVIOUR, proven by its own selftest
|
||||||
|
if script:
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
p = Path(td) / "reconcile.py"
|
||||||
|
p.write_text(script)
|
||||||
|
proc = subprocess.run([sys.executable, str(p), "--selftest"],
|
||||||
|
capture_output=True, text=True, timeout=180)
|
||||||
|
tail = (proc.stdout + proc.stderr).strip().splitlines()
|
||||||
|
check(proc.returncode == 0,
|
||||||
|
"the reconciler's --selftest FAILED (compare-then-write / idempotence / "
|
||||||
|
"no-secret-logging / endpoint allow-list / fail-closed):\n "
|
||||||
|
+ "\n ".join(tail[-12:]))
|
||||||
|
if proc.returncode == 0:
|
||||||
|
print(" reconciler --selftest: " + (tail[-1] if tail else "(no output)"))
|
||||||
|
|
||||||
|
# ======================================================= (7) this check is load-bearing
|
||||||
|
L0 = (ROOT / "ci" / "l0.sh").read_text() if (ROOT / "ci" / "l0.sh").is_file() else ""
|
||||||
|
GATE = (ROOT / "ci" / "gate.sh").read_text() if (ROOT / "ci" / "gate.sh").is_file() else ""
|
||||||
|
TOUCHED = ((ROOT / "ci" / "touched-requirements.sh").read_text()
|
||||||
|
if (ROOT / "ci" / "touched-requirements.sh").is_file() else "")
|
||||||
|
check("keycloak-client-secret-reconciler/check.sh" in L0,
|
||||||
|
"ci/l0.sh does not run keycloak-client-secret-reconciler/check.sh — the "
|
||||||
|
"requirement-scoped conformance selection can filter this check out of a gate run, "
|
||||||
|
"so l0.sh must invoke it directly and unconditionally or the gate is not enforcing "
|
||||||
|
"it. An unreferenced test is the failure this whole change exists to end")
|
||||||
|
check(re.search(r"^for s in .*\bl0\b.*; do", GATE, re.M) and re.search(r"fail\).*BLOCK=1", GATE),
|
||||||
|
"ci/gate.sh does not set the merge block when the l0 stage fails")
|
||||||
|
check(re.search(r"^\s*platform/keycloak/\*\)", TOUCHED, re.M),
|
||||||
|
"ci/touched-requirements.sh has no platform/keycloak/* entry in its path->requirement "
|
||||||
|
"map, so editing the realm import or this reconciler attributes NO requirement and "
|
||||||
|
"the L2 keycloak tests are filtered out of the merge gate's touched subset")
|
||||||
|
l2 = ROOT / "conformance/tests/l2-behavioral/keycloak-client-secrets-match-git/chainsaw-test.yaml"
|
||||||
|
check(l2.is_file(),
|
||||||
|
"the LIVE-realm counterpart (conformance/tests/l2-behavioral/"
|
||||||
|
"keycloak-client-secrets-match-git) is missing — this static check cannot see a "
|
||||||
|
"realm that drifted after apply, which is the whole failure mode")
|
||||||
|
if l2.is_file():
|
||||||
|
check("KEYCLOAK_CLIENT_SECRETS_AUDIT_OK" in l2.read_text(),
|
||||||
|
"the L2 test does not assert the reconciler's audit sentinel, so it could pass "
|
||||||
|
"on a Job that never ran the audit")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------ report
|
||||||
|
print(f"realm import: {len(clients)} client(s), {len(declared_pairs)} confidential with a "
|
||||||
|
f"declared secret, {len(placeholders)} placeholder(s)")
|
||||||
|
print(f"{SECRET_NAME} Secret: {len(sops_keys)} key(s) in git (names only; values stay "
|
||||||
|
f"SOPS-encrypted)")
|
||||||
|
print("flux ordering: " + ", ".join(
|
||||||
|
f"{f.split('/')[-1]} <- {owners(f)}" for f in (SOPS_FILE, IMPORT_FILE, RECON_FILE)))
|
||||||
|
|
||||||
|
print()
|
||||||
|
if not FAILURES:
|
||||||
|
print(f"check OK: keycloak-client-secret-reconciler — {CHECKS} assertions "
|
||||||
|
f"(R-IDP-03, R-IDP-04)")
|
||||||
|
raise SystemExit(0)
|
||||||
|
print(f"CHECK FAIL (keycloak-client-secret-reconciler): {len(FAILURES)} failed, "
|
||||||
|
f"{CHECKS - len(FAILURES)} passed", file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" FAIL {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PY
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — a placeholder value in cluster-vars is REFUSED by the guard that owns it
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
#
|
||||||
|
# THE BUG CLASS THIS CLOSES (it has now bitten three times in one file). A guard in
|
||||||
|
# `bootstrap/standup.sh` keys on the SHAPE of a value that lives in
|
||||||
|
# `clusters/base/cluster-vars.yaml`, and nothing tied the two together. On 2026-09-05 the
|
||||||
|
# S3 placeholder changed from `TBD-PROD-S3-BACKUP-ENDPOINT` to
|
||||||
|
# `https://TBD-R2-ACCOUNT-ID.r2.cloudflarestorage.com`; the three guards matched the glob
|
||||||
|
# `TBD-*`, a PREFIX match, and `fnmatch('https://TBD-R2-…','TBD-*')` is False. All three
|
||||||
|
# guards silently stopped matching, and `standup.sh resume` would have resumed `velero` on
|
||||||
|
# prod against a nonexistent hostname: installs clean, reports healthy, NO BACKUPS EXIST.
|
||||||
|
# It was found by accident. The guard is fixed (`s3_is_placeholder`, substring); the
|
||||||
|
# COUPLING was untested, which is what makes the class reintroducible.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as authored,
|
||||||
|
# WHEN the REAL predicate is extracted from bootstrap/standup.sh and executed by bash
|
||||||
|
# (never reimplemented — see check.sh "HOW THE REAL GUARD IS EXERCISED"),
|
||||||
|
# THEN (1) every authored value of a guarded knob that is placeholder-shaped by an
|
||||||
|
# INDEPENDENT detector is actually refused by that guard, in base and in every
|
||||||
|
# environment overlay;
|
||||||
|
# (2) no environment overlay carries a placeholder-shaped value for a knob no guard
|
||||||
|
# refuses;
|
||||||
|
# (3) every function in standup.sh that reads a guarded knob applies the guard to it
|
||||||
|
# (the "three guards died at once" regression);
|
||||||
|
# (4) the guard matches its own vocabulary ANYWHERE in a value, not just as a prefix
|
||||||
|
# — the exact anchoring defect above — and still accepts a real endpoint;
|
||||||
|
# AND (5) the extraction is faithful, and the check fails LOUDLY rather than silently
|
||||||
|
# testing a copy if the guard stops being a self-contained predicate.
|
||||||
|
#
|
||||||
|
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (standard library +
|
||||||
|
# PyYAML + bash); conformance/run.sh --layer l0 runs it directly and this Chainsaw Test
|
||||||
|
# wraps the SAME script.
|
||||||
|
#
|
||||||
|
# NOT proven: that the endpoint is REACHABLE or that backups exist (runtime facts —
|
||||||
|
# `standup.sh s3check` probes the former from a pod, l3-fault-injection/dr-restore proves
|
||||||
|
# the latter); that guards for knobs OTHER than the placeholder-refusing ones are correct;
|
||||||
|
# and that the guard runs at all in a real standup (the dispatch path is shell, executed
|
||||||
|
# only against a live cluster). R-STATE-03 is tagged for the narrow static slice only: the
|
||||||
|
# authored backup target cannot be a placeholder that the guard fails to recognise.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: placeholder-guard-coupling
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-ENV-02: "true"
|
||||||
|
conformance.home.k3s/R-STATE-03: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-ENV-02, R-STATE-03"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Every placeholder-shaped value authored in cluster-vars, for any environment
|
||||||
|
overlay, is actually recognised as a placeholder by the real bootstrap guard that
|
||||||
|
is supposed to refuse it — so a value change in one file cannot silently disarm a
|
||||||
|
guard in another and enable velero against an endpoint that does not exist.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: couple-authored-placeholders-to-the-real-guard
|
||||||
|
description: >-
|
||||||
|
Extract the real placeholder predicate from bootstrap/standup.sh, run it against
|
||||||
|
every authored cluster-vars value the repo actually carries, and assert the guard
|
||||||
|
refuses each one an independent detector calls a placeholder.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,516 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — placeholder-guard-coupling
|
||||||
|
# Serves: R-ENV-02 ("Same GitOps config promotes dev/test -> prod (authored once)."
|
||||||
|
# — spec docs/02-environments-and-provisioning.md)
|
||||||
|
# R-STATE-03 ("Scheduled off-cluster backups + restore (DR)."
|
||||||
|
# — spec docs/04-storage-and-state.md)
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS — A GUARD KEYED ON A VALUE THAT LIVES IN ANOTHER FILE
|
||||||
|
# --------------------------------------------------------------------
|
||||||
|
# `bootstrap/standup.sh` refuses to resume tiers whose configuration is still a
|
||||||
|
# placeholder. Three of those guards keyed on the SHAPE of `S3_BACKUP_ENDPOINT`, which is
|
||||||
|
# authored in `clusters/base/cluster-vars.yaml`. On 2026-09-05 the placeholder changed
|
||||||
|
# from `TBD-PROD-S3-BACKUP-ENDPOINT` to `https://TBD-R2-ACCOUNT-ID.r2.cloudflarestorage.com`
|
||||||
|
# (the MinIO -> Cloudflare R2 move). The guards matched the glob `TBD-*` — a PREFIX match —
|
||||||
|
# and `fnmatch('https://TBD-R2-…','TBD-*')` is FALSE. All three guards silently stopped
|
||||||
|
# matching. `standup.sh resume` would then have resumed `velero` on prod against a hostname
|
||||||
|
# that does not exist: the HelmRelease installs clean, the Kustomization reports Ready, and
|
||||||
|
# NO BACKUPS EXIST. The only reason it was not shipped is that someone noticed by accident.
|
||||||
|
#
|
||||||
|
# The predicate is fixed (`s3_is_placeholder`, substring match). The COUPLING is what was
|
||||||
|
# never tested: a guard in one file keying on a value in another, with nothing tying them
|
||||||
|
# together. This check is that tie, and it is derived from BOTH real files — no current
|
||||||
|
# string is hardcoded here, so it does not rot when the placeholder or the guard changes.
|
||||||
|
#
|
||||||
|
# HOW THE REAL GUARD IS EXERCISED (and why not a copy)
|
||||||
|
# ---------------------------------------------------
|
||||||
|
# `bootstrap/standup.sh` is NOT sourceable: at top level it parses argv, hard-exits when
|
||||||
|
# --kubeconfig/--expect-server/<step> are absent, `cd`s to the repo root, exports
|
||||||
|
# KUBECONFIG, installs an EXIT trap and finally dispatches steps against a live cluster.
|
||||||
|
# So this check EXTRACTS the predicate's own source lines from the file and sources ONLY
|
||||||
|
# those, then calls the real function. That is the real bytes, not a reimplementation:
|
||||||
|
# * every function whose name ends `_is_placeholder` is discovered, not named here;
|
||||||
|
# * a duplicate definition of one is a failure (which of the two would run?);
|
||||||
|
# * the extracted body must be a SELF-CONTAINED predicate — `case`/`return` and nothing
|
||||||
|
# else, no command substitution, no call to another function. If the guard ever grows
|
||||||
|
# a dependency, extraction stops being faithful and THIS CHECK FAILS LOUDLY, naming the
|
||||||
|
# remedy (move the predicate into a small sourceable library that standup.sh sources)
|
||||||
|
# rather than quietly falling back to testing a copy of the logic;
|
||||||
|
# * a CANARY pins the extraction itself: the extracted function must refuse the empty
|
||||||
|
# string and accept a realistic endpoint. An extraction that grabbed the wrong lines,
|
||||||
|
# or a predicate degraded to always-true / always-false, fails there — this repo has
|
||||||
|
# already been bitten by a check that passed when perturbed because it asked a question
|
||||||
|
# that was always satisfiable.
|
||||||
|
# There is deliberately NO second implementation of the matching logic anywhere in this file.
|
||||||
|
#
|
||||||
|
# THE INDEPENDENT DETECTOR (the other half of the comparison)
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# Asking "does the guard agree with the guard?" proves nothing, so the set of
|
||||||
|
# placeholder-shaped values is decided by a detector written HERE, whose vocabulary is the
|
||||||
|
# UNION of two independent sources:
|
||||||
|
# * SEED — universal loud-placeholder idioms (TBD, TODO, FIXME, PLACEHOLDER, REPLACE-ME,
|
||||||
|
# CHANGE-ME, FILL-ME, SET-ME, XXXX), plus `<angle-bracket>` templates and the empty
|
||||||
|
# string. These are NOT derived from the guard, so DELETING a guard arm cannot make a
|
||||||
|
# value stop being placeholder-shaped — it makes assertion (1) fail, which is the point.
|
||||||
|
# A value using an idiom the guard does not know is a finding either way: fix it by
|
||||||
|
# giving the knob a real value, or by teaching the guard the idiom.
|
||||||
|
# * the guard's OWN vocabulary, read out of its case-arms with the glob anchors STRIPPED.
|
||||||
|
# Stripping the anchors is what makes the comparison non-circular in the dimension that
|
||||||
|
# actually broke: the detector matches a word ANYWHERE, case-insensitively, and the
|
||||||
|
# guard must agree. A prefix-anchored guard cannot pass.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as authored,
|
||||||
|
# WHEN the real predicate(s) are extracted from bootstrap/standup.sh and executed,
|
||||||
|
# THEN (1) VALUES: for every knob a guard is responsible for, every authored value — the
|
||||||
|
# base default AND each environment overlay's value — that the independent
|
||||||
|
# detector calls placeholder-shaped is REFUSED by the real guard, and every value
|
||||||
|
# it calls usable is ACCEPTED (a guard that refuses a real endpoint leaves velero
|
||||||
|
# suspended forever, which is the same class of silent failure);
|
||||||
|
# (2) OVERLAYS: no environment overlay's EFFECTIVE cluster-vars carries a
|
||||||
|
# placeholder-shaped value for a knob that no guard refuses. (Base alone is
|
||||||
|
# exempt: it is not a bootstrap target — `flux bootstrap --path=clusters/<env>` —
|
||||||
|
# and its loud sentinels exist precisely to be overridden. A base placeholder an
|
||||||
|
# overlay forgets to override IS caught, because it becomes that overlay's
|
||||||
|
# effective value.)
|
||||||
|
# (3) CONSUMERS: every function in standup.sh that reads a guarded knob (via `cvar`,
|
||||||
|
# following one hop of variable aliasing) applies a placeholder guard to it. This
|
||||||
|
# is the "all three guards died at once" regression stated as a property: the
|
||||||
|
# guard being correct is worthless if a consumer stops calling it.
|
||||||
|
# (4) ANCHORING: for each word in the guard's own vocabulary, the guard refuses a
|
||||||
|
# value carrying that word in the MIDDLE (not at the start) — the precise defect
|
||||||
|
# of 2026-09-05 — while still accepting a realistic endpoint.
|
||||||
|
# (5) HARNESS: exactly one definition per predicate, the extracted body is verbatim
|
||||||
|
# from the file and self-contained, the canary holds, no divergent second copy of
|
||||||
|
# the matching logic exists elsewhere in standup.sh, and a floor on the number of
|
||||||
|
# assertions run keeps a pass from degenerating into a no-op.
|
||||||
|
# AND (6) LOAD-BEARING: ci/l0.sh runs the L0 conformance layer on every gate run and
|
||||||
|
# ci/gate.sh sets the merge block when that stage fails.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* PROVE (honest gaps)
|
||||||
|
# - Nothing about RUNTIME: not that the endpoint resolves, not that credentials work, not
|
||||||
|
# that a backup exists. `standup.sh s3check` probes reachability from a pod (operator-
|
||||||
|
# run, cluster-gated) and conformance/tests/l3-fault-injection/dr-restore proves restore.
|
||||||
|
# - It does not execute standup.sh's dispatch path, so it cannot prove the guard is
|
||||||
|
# REACHED in a real run — only that, when reached, it refuses what it must. The
|
||||||
|
# consumer property (3) is a static approximation of reachability: it is a text-level
|
||||||
|
# taint trace over one file, not an interpreter, and a guard whose result is computed
|
||||||
|
# and then ignored would satisfy it.
|
||||||
|
# - It says nothing about guards keyed on shapes OTHER than placeholders (e.g. the
|
||||||
|
# EXTERNALDNS_TXT_OWNER_ID == 'default' test), nor about placeholder-shaped values
|
||||||
|
# outside cluster-vars (the Ansible inventory's BLOCKED/NOT-A-DEVICE sentinels are
|
||||||
|
# asserted by the node_baseline role itself, at play time).
|
||||||
|
# - Stage 3b of ci/l0.sh scopes the conformance run to the TOUCHED requirements. An edit
|
||||||
|
# to bootstrap/* attributes NO requirement in ci/touched-requirements.sh's path map, so
|
||||||
|
# the touched set is empty and the FULL L0 layer runs (verified: ci/lib.sh
|
||||||
|
# load_touched_requirements) — this check runs. An edit to clusters/* maps to
|
||||||
|
# R-ENV-02, which this check carries, so a cluster-vars value change also selects it.
|
||||||
|
# A change that touches standup.sh ALONGSIDE some unrelated mapped path (say
|
||||||
|
# platform/cilium/) would scope the run away from this check; closing that needs a
|
||||||
|
# `bootstrap/*` entry in the path map, which is not this check's file to edit.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
STANDUP_REL="${STANDUP_REL:-bootstrap/standup.sh}"
|
||||||
|
|
||||||
|
cd "${ROOT}"
|
||||||
|
|
||||||
|
if [ ! -f "${ROOT}/${STANDUP_REL}" ]; then
|
||||||
|
echo "check SKIPPED (placeholder-guard-coupling): ${STANDUP_REL} is absent — nothing to couple." >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "${WORK}"' EXIT
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------
|
||||||
|
# PHASE 1 (python): extract the real predicate(s), derive the corpus from the real files,
|
||||||
|
# and run every assertion that does not need a shell. Writes:
|
||||||
|
# ${WORK}/guards.sh the predicate source lines, verbatim, and nothing else
|
||||||
|
# ${WORK}/corpus.tsv <guard>\t<expect: placeholder|usable>\t<origin>\t<value>
|
||||||
|
# ${WORK}/findings.txt one line per failed assertion
|
||||||
|
# ${WORK}/counts.txt <assertions-run>
|
||||||
|
# ---------------------------------------------------------------------------------------
|
||||||
|
"${PYTHON}" - "${ROOT}" "${WORK}" "${STANDUP_REL}" <<'PY'
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||||
|
file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
ROOT, WORK, STANDUP_REL = Path(sys.argv[1]), Path(sys.argv[2]), sys.argv[3]
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
CHECKS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def bail(msg):
|
||||||
|
"""A failure that makes the rest of the check meaningless: report and stop."""
|
||||||
|
print(f"CHECK FAIL (placeholder-guard-coupling): {msg}", file=sys.stderr)
|
||||||
|
(WORK / "findings.txt").write_text(msg + "\n")
|
||||||
|
(WORK / "counts.txt").write_text("0\n")
|
||||||
|
(WORK / "corpus.tsv").write_text("")
|
||||||
|
(WORK / "guards.sh").write_text("")
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
TEXT = (ROOT / STANDUP_REL).read_text()
|
||||||
|
LINES = TEXT.splitlines()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ (5) extract the guard
|
||||||
|
DEF_RE = re.compile(r"^([a-z0-9_]+_is_placeholder)\(\)\s*\{")
|
||||||
|
defs: dict[str, list[int]] = {}
|
||||||
|
for i, line in enumerate(LINES):
|
||||||
|
m = DEF_RE.match(line)
|
||||||
|
if m:
|
||||||
|
defs.setdefault(m.group(1), []).append(i)
|
||||||
|
|
||||||
|
if not defs:
|
||||||
|
bail(f"{STANDUP_REL} defines no *_is_placeholder predicate. Either the guard was renamed "
|
||||||
|
"(update this check's discovery pattern in the SAME change) or it was DELETED, which "
|
||||||
|
"is the 2026-09-05 failure with the guard removed instead of disarmed.")
|
||||||
|
|
||||||
|
# An "argument-shaped" call: <name> "<something>"
|
||||||
|
CALL_RE = re.compile(r"\b([a-z0-9_]+_is_placeholder)\s+\"([^\"]*)\"")
|
||||||
|
|
||||||
|
guard_src: list[str] = []
|
||||||
|
guard_words: dict[str, list[str]] = {}
|
||||||
|
ARM_RE = re.compile(r"^(?P<pats>[^()]*?)\)\s*return\s+[01]\s*;;$")
|
||||||
|
ALLOWED = (
|
||||||
|
re.compile(r"^$"),
|
||||||
|
re.compile(r"^#"),
|
||||||
|
re.compile(r"^case\s+\"\$\{?[0-9A-Za-z_]+\}?\"\s+in$"),
|
||||||
|
re.compile(r"^esac$"),
|
||||||
|
re.compile(r"^return\s+[01]$"),
|
||||||
|
ARM_RE,
|
||||||
|
)
|
||||||
|
|
||||||
|
for name, at in sorted(defs.items()):
|
||||||
|
check(len(at) == 1,
|
||||||
|
f"{STANDUP_REL} defines {name}() {len(at)} times (lines "
|
||||||
|
f"{', '.join(str(a + 1) for a in at)}) — which one runs depends on file order, and "
|
||||||
|
"this check can no longer say which one it extracted. Keep exactly one definition.")
|
||||||
|
if len(at) != 1:
|
||||||
|
continue
|
||||||
|
start = at[0]
|
||||||
|
end = next((j for j in range(start + 1, len(LINES)) if LINES[j] == "}"), None)
|
||||||
|
if end is None:
|
||||||
|
bail(f"{name}() at {STANDUP_REL}:{start + 1} has no closing brace at column 0 — the "
|
||||||
|
"extraction boundary this check relies on is gone. Restore the conventional "
|
||||||
|
"layout, or move the predicate into a sourceable library and point this check at it.")
|
||||||
|
body = LINES[start:end + 1]
|
||||||
|
block = "\n".join(body)
|
||||||
|
check(block in TEXT,
|
||||||
|
f"extracted {name}() is not a verbatim substring of {STANDUP_REL} — extraction bug; "
|
||||||
|
"the harness would be testing something the script does not contain")
|
||||||
|
|
||||||
|
words: list[str] = []
|
||||||
|
for raw in body[1:-1]:
|
||||||
|
s = raw.strip()
|
||||||
|
if not any(rx.match(s) for rx in ALLOWED) or "$(" in s or "`" in s:
|
||||||
|
bail(f"{name}() at {STANDUP_REL}:{start + 1} is no longer a SELF-CONTAINED "
|
||||||
|
f"case/return predicate (offending line: {s!r}). This check exercises the REAL "
|
||||||
|
"function by sourcing its own lines; a body that calls out to anything else "
|
||||||
|
"cannot be extracted faithfully, and silently testing a COPY of the logic is "
|
||||||
|
"exactly the failure mode this check exists to prevent. REMEDY: move the "
|
||||||
|
"predicate into a tiny library that standup.sh sources (e.g. "
|
||||||
|
"bootstrap/lib/placeholder.sh) and point STANDUP_REL/this extractor at it — do "
|
||||||
|
"NOT reimplement the matching here.")
|
||||||
|
m = ARM_RE.match(s)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
for pat in m.group("pats").split("|"):
|
||||||
|
pat = pat.strip()
|
||||||
|
if pat in ('""', "''", ""):
|
||||||
|
continue # the empty-value arm: covered by the canary
|
||||||
|
words.append(pat.strip("'\"").strip("*"))
|
||||||
|
check(len(words) >= 1,
|
||||||
|
f"{name}() has no non-empty placeholder pattern left — every arm that recognises a "
|
||||||
|
"loud placeholder string has been removed, so the guard now only refuses the empty "
|
||||||
|
"value and any 'TBD'-style endpoint would sail straight through it")
|
||||||
|
guard_words[name] = words
|
||||||
|
guard_src.append(block)
|
||||||
|
|
||||||
|
(WORK / "guards.sh").write_text("\n".join(guard_src) + "\n")
|
||||||
|
|
||||||
|
# ------------------------------------------------- no divergent second copy of the logic
|
||||||
|
# Any of the guard's own vocabulary appearing in EXECUTABLE text outside the predicate means
|
||||||
|
# a second, independently-drifting implementation of the same test (which is how three
|
||||||
|
# guards came to disagree with one value in the first place).
|
||||||
|
guard_line_nums = {j for name, at in defs.items() if len(at) == 1
|
||||||
|
for j in range(at[0], next(k for k in range(at[0] + 1, len(LINES))
|
||||||
|
if LINES[k] == "}") + 1)}
|
||||||
|
all_words = sorted({w.upper() for ws in guard_words.values() for w in ws if w})
|
||||||
|
divergent: list[str] = []
|
||||||
|
for j, line in enumerate(LINES):
|
||||||
|
if j in guard_line_nums or line.strip().startswith("#"):
|
||||||
|
continue
|
||||||
|
code = line.split("#", 1)[0]
|
||||||
|
hit = [w for w in all_words if w in code.upper()]
|
||||||
|
if hit:
|
||||||
|
divergent.append(f"{STANDUP_REL}:{j + 1} ({', '.join(hit)}): {line.strip()!r}")
|
||||||
|
check(not divergent,
|
||||||
|
"the guard's own placeholder vocabulary appears in EXECUTABLE text outside the "
|
||||||
|
f"predicate — a SECOND implementation of the same test, which is precisely how a value "
|
||||||
|
f"change disarmed three guards at once. Call the predicate instead. Sites: "
|
||||||
|
+ " | ".join(divergent))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- cluster-vars, as authored
|
||||||
|
CV = ROOT / "clusters" / "base" / "cluster-vars.yaml"
|
||||||
|
if not CV.is_file():
|
||||||
|
bail("clusters/base/cluster-vars.yaml is absent — the authored half of the coupling is "
|
||||||
|
"missing and this check cannot mean anything")
|
||||||
|
|
||||||
|
base: dict[str, str] = {}
|
||||||
|
for d in yaml.safe_load_all(CV.read_text()):
|
||||||
|
if isinstance(d, dict) and d.get("kind") == "ConfigMap" \
|
||||||
|
and (d.get("metadata") or {}).get("name") == "cluster-vars":
|
||||||
|
base = {str(k): "" if v is None else str(v) for k, v in (d.get("data") or {}).items()}
|
||||||
|
check(len(base) > 5, f"clusters/base/cluster-vars.yaml yielded {len(base)} knob(s) — the "
|
||||||
|
"ConfigMap parse failed, so every value assertion below would be vacuous")
|
||||||
|
|
||||||
|
overlays: dict[str, dict[str, str]] = {}
|
||||||
|
for overlay in sorted((ROOT / "clusters").glob("*/kustomization.yaml")):
|
||||||
|
env = overlay.parent.name
|
||||||
|
if env == "base":
|
||||||
|
continue
|
||||||
|
eff = dict(base)
|
||||||
|
try:
|
||||||
|
k = yaml.safe_load(overlay.read_text()) or {}
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
check(False, f"clusters/{env}/kustomization.yaml does not parse as YAML ({exc}) — this "
|
||||||
|
"check cannot compute that overlay's effective values")
|
||||||
|
continue
|
||||||
|
for p in (k.get("patches") or []):
|
||||||
|
tgt = p.get("target") or {}
|
||||||
|
if tgt.get("kind") != "ConfigMap" or tgt.get("name") != "cluster-vars":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ops = yaml.safe_load(p.get("patch") or "") or []
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
continue
|
||||||
|
for op in ops if isinstance(ops, list) else []:
|
||||||
|
if not isinstance(op, dict):
|
||||||
|
continue
|
||||||
|
m = re.match(r"^/data/([A-Za-z0-9_]+)$", str(op.get("path", "")))
|
||||||
|
if m and str(op.get("op", "")) in ("replace", "add"):
|
||||||
|
eff[m.group(1)] = "" if op.get("value") is None else str(op["value"])
|
||||||
|
overlays[env] = eff
|
||||||
|
check(len(overlays) >= 1,
|
||||||
|
"no clusters/<env>/kustomization.yaml overlay was parsed — assertion (2) would be vacuous")
|
||||||
|
|
||||||
|
# ------------------------------------------------ which knobs is a guard responsible for?
|
||||||
|
# Function bodies of standup.sh, then a one-hop taint trace from `x="$(cvar KEY)"` to a
|
||||||
|
# `*_is_placeholder "${x}"` call. Both halves are read out of the file; nothing is named here.
|
||||||
|
FUNC_RE = re.compile(r"^([a-z0-9_]+)\(\)\s*\{")
|
||||||
|
funcs: dict[str, list[str]] = {}
|
||||||
|
i = 0
|
||||||
|
while i < len(LINES):
|
||||||
|
m = FUNC_RE.match(LINES[i])
|
||||||
|
if m:
|
||||||
|
end = next((j for j in range(i + 1, len(LINES)) if LINES[j] == "}"), len(LINES) - 1)
|
||||||
|
funcs[m.group(1)] = LINES[i + 1:end]
|
||||||
|
i = end + 1
|
||||||
|
continue
|
||||||
|
i += 1
|
||||||
|
check(len(funcs) > 5, f"parsed only {len(funcs)} function(s) out of {STANDUP_REL} — the "
|
||||||
|
"consumer assertion (3) would be vacuous")
|
||||||
|
|
||||||
|
CVAR_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)=\"\$\(\s*cvar\s+([A-Z0-9_]+)\s*\)\"")
|
||||||
|
ALIAS_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)=\"\$\{([A-Za-z_][A-Za-z0-9_]*)[:}]")
|
||||||
|
|
||||||
|
guarded_keys: set[str] = set()
|
||||||
|
consumers: list[tuple[str, str, bool]] = [] # (func, key, guarded-in-this-func)
|
||||||
|
for fname, body in funcs.items():
|
||||||
|
text = "\n".join(body)
|
||||||
|
keys_of_var: dict[str, set[str]] = {}
|
||||||
|
for m in CVAR_RE.finditer(text):
|
||||||
|
keys_of_var.setdefault(m.group(1), set()).add(m.group(2))
|
||||||
|
# one hop of aliasing: ep="${live}" carries live's taint
|
||||||
|
for _ in range(3):
|
||||||
|
for m in ALIAS_RE.finditer(text):
|
||||||
|
src = keys_of_var.get(m.group(2))
|
||||||
|
if src:
|
||||||
|
keys_of_var.setdefault(m.group(1), set()).update(src)
|
||||||
|
called_on = {m.group(2) for m in CALL_RE.finditer(text)}
|
||||||
|
called_vars = {re.sub(r"^\$\{?|\}?$", "", a) for a in called_on}
|
||||||
|
# Aggregate per (function, knob): the knob is guarded HERE if ANY variable carrying its
|
||||||
|
# value is passed to a predicate — `live="$(cvar K)"; ep="${live}"; guard "${ep}"` counts.
|
||||||
|
guarded_here: dict[str, bool] = {}
|
||||||
|
for var, keys in keys_of_var.items():
|
||||||
|
for key in keys:
|
||||||
|
guarded_here[key] = guarded_here.get(key, False) or (var in called_vars)
|
||||||
|
for key, g in guarded_here.items():
|
||||||
|
consumers.append((fname, key, g))
|
||||||
|
if g:
|
||||||
|
guarded_keys.add(key)
|
||||||
|
|
||||||
|
if not guarded_keys:
|
||||||
|
bail(f"no cluster-vars knob in {STANDUP_REL} is passed to a *_is_placeholder predicate. "
|
||||||
|
"Either the guards were removed, or the way the script reads cluster-vars changed and "
|
||||||
|
"this check's derivation must be updated in the SAME change — it must never silently "
|
||||||
|
"verify an empty set.")
|
||||||
|
|
||||||
|
# (3) CONSUMERS: every reader of a guarded knob must apply the guard to it.
|
||||||
|
for fname, key, guarded in sorted(consumers):
|
||||||
|
if key not in guarded_keys:
|
||||||
|
continue # nothing claims responsibility for this knob at all
|
||||||
|
check(guarded,
|
||||||
|
f"{STANDUP_REL}: {fname}() reads cluster-vars knob {key} but never passes it to a "
|
||||||
|
"*_is_placeholder guard, while other functions do. On 2026-09-05 all three consumers "
|
||||||
|
"of S3_BACKUP_ENDPOINT were disarmed at once; a consumer that reads the knob and skips "
|
||||||
|
"the guard is that failure in one function.")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- independent detector
|
||||||
|
# Deliberately NOT derived from the guard alone (see the header): SEED is universal
|
||||||
|
# loud-placeholder vocabulary, and the guard's own words are added so that a guard whose
|
||||||
|
# ANCHORING is wrong still has its vocabulary tested against real values.
|
||||||
|
SEED = ["TBD", "TODO", "FIXME", "PLACEHOLDER", "REPLACE-ME", "REPLACEME",
|
||||||
|
"CHANGE-ME", "CHANGEME", "FILL-ME", "FILLME", "SET-ME", "XXXX"]
|
||||||
|
VOCAB = sorted({w.upper() for w in SEED} | set(all_words))
|
||||||
|
ANGLE = re.compile(r"<[^>\s]+>")
|
||||||
|
|
||||||
|
|
||||||
|
def placeholder_shaped(value: str) -> bool:
|
||||||
|
if value.strip() == "":
|
||||||
|
return True # an unset endpoint is not a target
|
||||||
|
up = value.upper()
|
||||||
|
return any(w in up for w in VOCAB) or bool(ANGLE.search(value))
|
||||||
|
|
||||||
|
|
||||||
|
rows: list[tuple[str, str, str, str]] = [] # guard, expect, origin, value
|
||||||
|
GUARD = sorted(guard_words)[0] if len(guard_words) == 1 else None
|
||||||
|
|
||||||
|
# (1) VALUES: base default and every overlay's value, for every guarded knob.
|
||||||
|
for key in sorted(guarded_keys):
|
||||||
|
seen: list[tuple[str, str]] = []
|
||||||
|
if key in base:
|
||||||
|
seen.append((f"clusters/base/cluster-vars.yaml:{key}", base[key]))
|
||||||
|
for env, eff in sorted(overlays.items()):
|
||||||
|
seen.append((f"clusters/{env} effective:{key}", eff.get(key, "")))
|
||||||
|
check(bool(seen), f"guarded knob {key} is not defined in cluster-vars at all — the guard "
|
||||||
|
"protects a knob nothing authors")
|
||||||
|
for origin, value in seen:
|
||||||
|
expect = "placeholder" if placeholder_shaped(value) else "usable"
|
||||||
|
for name in sorted(guard_words):
|
||||||
|
rows.append((name, expect, origin, value))
|
||||||
|
|
||||||
|
# (2) OVERLAYS: a placeholder-shaped effective value for an UNGUARDED knob.
|
||||||
|
for env, eff in sorted(overlays.items()):
|
||||||
|
for key, value in sorted(eff.items()):
|
||||||
|
if key in guarded_keys or not placeholder_shaped(value):
|
||||||
|
continue
|
||||||
|
check(False,
|
||||||
|
f"clusters/{env}: knob {key} resolves to the placeholder-shaped value {value!r}, "
|
||||||
|
f"and NO guard in {STANDUP_REL} refuses it. Either give it a real value before it "
|
||||||
|
"reaches an overlay, or guard it the way the S3 endpoint is guarded — an overlay "
|
||||||
|
"that ships a placeholder no guard recognises is the 2026-09-05 failure with a "
|
||||||
|
"different knob's name on it.")
|
||||||
|
|
||||||
|
# (4) ANCHORING: the guard's own vocabulary must match ANYWHERE, not just as a prefix,
|
||||||
|
# and a realistic endpoint must still be accepted.
|
||||||
|
for name, words in sorted(guard_words.items()):
|
||||||
|
for w in sorted(set(words)):
|
||||||
|
rows.append((name, "placeholder",
|
||||||
|
f"anchoring:{name}:mid-string({w})",
|
||||||
|
f"https://account-{w}-id.r2.cloudflarestorage.com"))
|
||||||
|
rows.append((name, "placeholder", f"anchoring:{name}:tail({w})",
|
||||||
|
f"https://r2-endpoint.example.invalid/{w}"))
|
||||||
|
rows.append((name, "placeholder", f"canary:{name}:empty", ""))
|
||||||
|
rows.append((name, "usable", f"canary:{name}:real-r2",
|
||||||
|
"https://0123456789abcdef0123456789abcd.r2.cloudflarestorage.com"))
|
||||||
|
rows.append((name, "usable", f"canary:{name}:real-minio", "http://192.168.1.9:9000"))
|
||||||
|
|
||||||
|
# Every guarded knob must reach the oracle once per authored source (base + each overlay),
|
||||||
|
# so a knob silently dropping out of the corpus is a failure rather than a quiet pass.
|
||||||
|
authored = [r for r in rows if not r[2].startswith(("anchoring:", "canary:"))]
|
||||||
|
expected_rows = len(guarded_keys) * (1 + len(overlays)) * len(guard_words)
|
||||||
|
check(len(authored) == expected_rows,
|
||||||
|
f"{len(authored)} authored value(s) reached the guard, expected {expected_rows} "
|
||||||
|
f"({len(guarded_keys)} guarded knob(s) x base+{len(overlays)} overlay(s) x "
|
||||||
|
f"{len(guard_words)} predicate(s)) — a guarded knob is missing from cluster-vars or from "
|
||||||
|
"an overlay's effective values, and the coupling is only partly exercised")
|
||||||
|
|
||||||
|
# ------------------------------------------------------- (6) the check is load-bearing
|
||||||
|
L0 = (ROOT / "ci" / "l0.sh").read_text() if (ROOT / "ci" / "l0.sh").is_file() else ""
|
||||||
|
GATE = (ROOT / "ci" / "gate.sh").read_text() if (ROOT / "ci" / "gate.sh").is_file() else ""
|
||||||
|
check("--layer l0" in L0,
|
||||||
|
"ci/l0.sh does not run the L0 conformance layer — this check would never execute in the gate")
|
||||||
|
check(bool(re.search(r"^for s in .*\bl0\b.*; do", GATE, re.M)) and bool(re.search(r"fail\).*BLOCK=1", GATE)),
|
||||||
|
"ci/gate.sh does not set the merge block when the l0 stage fails")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- handoff
|
||||||
|
with (WORK / "corpus.tsv").open("w") as fh:
|
||||||
|
for name, expect, origin, value in rows:
|
||||||
|
fh.write(f"{name}\t{expect}\t{origin}\t{value}\n")
|
||||||
|
(WORK / "findings.txt").write_text("".join(f"{f}\n" for f in FAILURES))
|
||||||
|
(WORK / "counts.txt").write_text(f"{CHECKS}\n")
|
||||||
|
|
||||||
|
print(f"guards: {', '.join(sorted(guard_words))} "
|
||||||
|
f"vocabulary: {', '.join(VOCAB)}")
|
||||||
|
print(f"guarded knobs: {', '.join(sorted(guarded_keys))} "
|
||||||
|
f"consumers traced: {len(consumers)} overlays: {', '.join(sorted(overlays))}")
|
||||||
|
for name, expect, origin, value in rows:
|
||||||
|
if not origin.startswith(("anchoring:", "canary:")):
|
||||||
|
print(f" authored {origin} = {value!r} -> expect {expect}")
|
||||||
|
PY
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------------------
|
||||||
|
# PHASE 2 (bash): source the REAL predicate lines and run the corpus through them.
|
||||||
|
# ---------------------------------------------------------------------------------------
|
||||||
|
# shellcheck disable=SC1090,SC1091
|
||||||
|
source "${WORK}/guards.sh"
|
||||||
|
|
||||||
|
oracle_failures=0
|
||||||
|
oracle_rows=0
|
||||||
|
while IFS=$'\t' read -r guard expect origin value; do
|
||||||
|
[ -n "${guard}" ] || continue
|
||||||
|
oracle_rows=$((oracle_rows + 1))
|
||||||
|
if "${guard}" "${value}"; then verdict=placeholder; else verdict=usable; fi
|
||||||
|
if [ "${verdict}" != "${expect}" ]; then
|
||||||
|
oracle_failures=$((oracle_failures + 1))
|
||||||
|
if [ "${expect}" = placeholder ]; then
|
||||||
|
printf '%s\n' "${guard}() in ${STANDUP_REL} ACCEPTS '${value}' (from ${origin}) as a usable value, but it is placeholder-shaped. This is the 2026-09-05 failure exactly: the guard is asked about a value it does not recognise, returns 'fine', and the tier it protects is enabled against something that does not exist. Fix the predicate (match the idiom anywhere, not as a prefix) or stop authoring that idiom." >> "${WORK}/findings.txt"
|
||||||
|
else
|
||||||
|
printf '%s\n' "${guard}() in ${STANDUP_REL} REFUSES '${value}' (from ${origin}), which is a usable value. An over-matching guard leaves the tier it protects suspended forever and reports it as an operator decision — a silent failure in the other direction." >> "${WORK}/findings.txt"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done < "${WORK}/corpus.tsv"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------ verdict
|
||||||
|
py_checks="$(cat "${WORK}/counts.txt")"
|
||||||
|
total=$((py_checks + oracle_rows))
|
||||||
|
findings="$(cat "${WORK}/findings.txt")"
|
||||||
|
|
||||||
|
# NO-OP GUARD: this repo has been bitten by a check that passed when perturbed because it
|
||||||
|
# asked a question that was always satisfiable. The floors below are the minimum evidence
|
||||||
|
# for the verdict to mean anything, not a style rule.
|
||||||
|
if [ "${oracle_rows}" -lt 6 ]; then
|
||||||
|
findings="${findings}only ${oracle_rows} value(s) were put to the real guard — the coupling is not actually being exercised
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
if [ "${total}" -lt 20 ]; then
|
||||||
|
findings="${findings}only ${total} assertions ran — the check degenerated to a near-no-op and its pass would be meaningless
|
||||||
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "${findings}" ]; then
|
||||||
|
echo "CHECK FAIL (placeholder-guard-coupling): $(printf '%s' "${findings}" | grep -c . ) of ${total} assertions failed" >&2
|
||||||
|
printf '%s' "${findings}" | sed 's/^/ - /' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "check OK: placeholder-guard-coupling — ${total} assertions; ${oracle_rows} value(s) run through the REAL extracted predicate(s) from ${STANDUP_REL} (no reimplementation), covering every authored cluster-vars value of every guarded knob in base and each overlay, the guard's own vocabulary matched mid-string, and the empty/real-endpoint canaries. Static slice only: refusal, not reachability — see the header."
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — prod is bare metal, and is not a development surface (R-ENV-03) [EXECUTABLE]
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
# Spec: docs/00 R-ENV-03 ("prod is a 3× mini-PC bare-metal k3s cluster with local
|
||||||
|
# storage; it MUST NOT be an active development surface until the roadmap
|
||||||
|
# declares it ready"); docs/02 (prod = 3 physical nodes, local SSD/NVMe to
|
||||||
|
# Longhorn, "the *same* Ansible bootstrap runs; only the machine-creation step
|
||||||
|
# differs", "Never develop against prod"); roadmap/phases.md Phase 7.
|
||||||
|
#
|
||||||
|
# HOW A "MUST NOT" IS MADE TESTABLE — the same discipline as R-TEST-06's test: a
|
||||||
|
# governance rule is verified through the property that ENFORCES it, never by observing
|
||||||
|
# that nobody misbehaved today. Here that property is structural: prod receives the base
|
||||||
|
# configuration plus environment-scoped overrides and nothing else, from the same branch
|
||||||
|
# and the same Ansible code as dev, so there is no place to put prod-only work at all.
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC: the prod inventory group is DERIVED (it is the group whose
|
||||||
|
# group_vars retargets the k3s roles away from the global default), the prod overlay is
|
||||||
|
# derived from CLUSTER_ENV, and the storage/replica expectations are compared against the
|
||||||
|
# node count that inventory resolves to. No hostname, address or environment name is
|
||||||
|
# written into the test.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it (plus the read-only spec repo when mounted),
|
||||||
|
# WHEN the prod inventory, overlay, IaC module and readiness record are inspected — and
|
||||||
|
# the shared provisioning chain is actually retargeted at prod with --list-hosts,
|
||||||
|
# THEN prod resolves to exactly three nodes reached directly on their own sshd; no IaC
|
||||||
|
# resource creates any of them (bare metal is imaged, not instantiated); prod's
|
||||||
|
# Longhorn path is node-local with one replica per node and the default
|
||||||
|
# StorageClass is the local driver; prod runs the same playbooks/roles as dev,
|
||||||
|
# retargeted only by variables the shared code already defines; the prod overlay
|
||||||
|
# carries only the base plus environment-scoped patches on the same Git ref dev
|
||||||
|
# tracks, with no prod-only manifest anywhere in the shared configuration; and prod
|
||||||
|
# artifacts exist only alongside the operator's recorded opening of the prod phase.
|
||||||
|
#
|
||||||
|
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (git, PyYAML, and —
|
||||||
|
# where present — ansible-playbook in --list-hosts mode, which opens no connection);
|
||||||
|
# conformance/run.sh --layer l0 and ci/l0.sh run it directly, and this Chainsaw Test
|
||||||
|
# wraps the SAME script.
|
||||||
|
#
|
||||||
|
# KNOWN, DELIBERATE GAPS (enumerated in check.sh's header; this test claims none of them):
|
||||||
|
# * THE HARDWARE. That the three machines are mini-PCs, are bare metal, and hold local
|
||||||
|
# SSD/NVMe is operator-attested (infra/PROD-STANDUP.md, PROD-PROVISIONING.md); the
|
||||||
|
# node-health/BIOS surveys that establish it write to gitignored artifacts, and the
|
||||||
|
# Kubernetes API exposes no "am I virtual" fact, so nothing here asserts it.
|
||||||
|
# * The LIVE prod cluster's shape (three Ready nodes, all control-plane+etcd, Longhorn
|
||||||
|
# disks at the local path) — cluster state, belonging to R-PLAT-01's still-uncovered
|
||||||
|
# HA test; asserting it here would also make this test environment-specific, since
|
||||||
|
# the same suite must pass against dev.
|
||||||
|
# * Git HISTORY: this is the working tree at HEAD.
|
||||||
|
# * Whether the roadmap SHOULD have opened prod — only that prod work is accompanied by
|
||||||
|
# the recorded declaration.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: prod-baremetal-not-dev-surface
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-ENV-03: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-ENV-03"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Given the tracked tree, when prod's inventory, overlay, IaC and readiness record are
|
||||||
|
inspected, then prod is authored as three bare-metal nodes with node-local storage,
|
||||||
|
bootstrapped by dev's own code retargeted by variables, receiving only
|
||||||
|
environment-scoped overrides on the same branch — so there is nowhere to develop
|
||||||
|
against prod — and it exists only because the roadmap phase was opened.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: then-prod-is-bare-metal-and-carries-no-development-of-its-own
|
||||||
|
description: >-
|
||||||
|
Run the offline assertion script over the tracked tree (same script ci/l0.sh and
|
||||||
|
conformance/run.sh --layer l0 execute).
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,528 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — prod-baremetal-not-dev-surface
|
||||||
|
# Serves: R-ENV-03 ("prod is a 3× mini-PC bare-metal k3s cluster with local storage; it
|
||||||
|
# MUST NOT be an active development surface until the roadmap declares it ready."
|
||||||
|
# — spec docs/00-vision-and-scope.md. Detail in docs/02: prod substrate "3× mini-PC
|
||||||
|
# bare metal", "3 physical nodes", storage "local SSD/NVMe presented to Longhorn",
|
||||||
|
# "For prod bare-metal, the *same* Ansible bootstrap runs; only the machine-creation
|
||||||
|
# step differs", and "**Never develop against prod.** Prod hardware is a target, not
|
||||||
|
# a sandbox (R-ENV-03)". The roadmap gate is roadmap/phases.md Phase 7: entry
|
||||||
|
# "operator declares prod hardware ready"; note "Do not begin building on prod
|
||||||
|
# hardware before this phase is explicitly opened".)
|
||||||
|
#
|
||||||
|
# HOW A "MUST NOT" IS MADE TESTABLE
|
||||||
|
# --------------------------------
|
||||||
|
# The second clause is a governance rule, and the same discipline applies as to R-TEST-06
|
||||||
|
# (tests/l0-static/spec-immutability-enforced): "nobody developed against prod today"
|
||||||
|
# cannot be observed, but the PROPERTY THAT MAKES DEVELOPING AGAINST PROD IMPOSSIBLE can
|
||||||
|
# be. In this repo that property is structural — prod receives the base configuration plus
|
||||||
|
# environment-scoped overrides and NOTHING ELSE, so there is no place to put prod-only work
|
||||||
|
# even if someone wanted to. This check asserts that, together with the first clause's
|
||||||
|
# authored facts (three nodes, no VM IaC behind them, node-local storage, the same
|
||||||
|
# bootstrap code as dev).
|
||||||
|
#
|
||||||
|
# The first clause's HARDWARE facts (that the three machines really are mini-PCs, really
|
||||||
|
# are bare metal, really hold local NVMe) are operator-attested, not machine-checkable —
|
||||||
|
# see the gap list. What IS checkable is that the repository describes prod that way and
|
||||||
|
# that no IaC creates prod nodes as virtual machines.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it (plus the read-only spec repo when mounted),
|
||||||
|
# WHEN the prod inventory, the prod overlay, the IaC module and the roadmap record are
|
||||||
|
# inspected — and, where offline execution is possible, the provisioning chain is
|
||||||
|
# actually retargeted at prod with `--list-hosts`,
|
||||||
|
# THEN (1) prod resolves to EXACTLY THREE nodes, reached directly on their own sshd
|
||||||
|
# (no NAT indirection, i.e. real machines on the LAN);
|
||||||
|
# (2) NO IaC resource creates any prod node: the VM module's generated node names are
|
||||||
|
# disjoint from the prod host set and no prod hostname appears anywhere under
|
||||||
|
# infra/tofu — prod is imaged by the operator (docs/02 stage 1), not instantiated;
|
||||||
|
# (3) prod's storage is NODE-LOCAL: the Longhorn data path for prod is an absolute
|
||||||
|
# local path, and the replica count equals the node count (one replica per node's
|
||||||
|
# own disk). A distinct bulk/media tier is explicitly permitted (R-STATE-04) and
|
||||||
|
# is not counted against this;
|
||||||
|
# (4) prod runs THE SAME bootstrap code as dev: the same playbooks/roles, retargeted
|
||||||
|
# by variables only — every prod group_vars key is an existing role/global
|
||||||
|
# variable (an invented prod-only knob would be a fork), and no prod-only role
|
||||||
|
# or playbook exists;
|
||||||
|
# (5) prod is NOT a development surface: clusters/prod carries only the base plus
|
||||||
|
# sync.yaml, every patch it applies targets an ENVIRONMENT-SCOPED object
|
||||||
|
# (cluster-vars, a Flux Kustomization's sops decryption, or the Git source),
|
||||||
|
# prod's Git ref is the SAME ref dev tracks (no prod-only branch to develop on),
|
||||||
|
# and no prod-only manifest exists anywhere in the rendered configuration;
|
||||||
|
# (6) prod artifacts exist only WITH a declaration of readiness: the spec roadmap
|
||||||
|
# carries the phase that opens prod build (checked when the spec repo is mounted),
|
||||||
|
# and the repository records that the operator opened it.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this test as claiming them):
|
||||||
|
# - The HARDWARE. That the three nodes are mini-PCs, are bare metal, and present local
|
||||||
|
# SSD/NVMe is operator-attested in infra/PROD-STANDUP.md / PROD-PROVISIONING.md; the
|
||||||
|
# node-health and BIOS surveys that establish it write to gitignored artifacts, so
|
||||||
|
# nothing tracked can be asserted against. Cluster-side, the Kubernetes API exposes no
|
||||||
|
# "am I virtual" fact either — a bare-metal check would need a privileged host-path
|
||||||
|
# probe of DMI data, which is a heavier and more invasive test than the claim warrants.
|
||||||
|
# - The LIVE prod cluster's shape (three Ready nodes, all control-plane+etcd, Longhorn
|
||||||
|
# disks at the local path). That is cluster state and belongs to R-PLAT-01's
|
||||||
|
# (currently uncovered) HA test; asserting it here would also make this test
|
||||||
|
# environment-specific, since the same suite must pass against dev.
|
||||||
|
# - That prod's HISTORY contains no development. This is the working tree at HEAD.
|
||||||
|
# - Whether the roadmap SHOULD have opened prod (the entry-gate judgement). Only that
|
||||||
|
# prod work is accompanied by the operator's recorded declaration.
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
: "${SPEC_REPO:=${ROOT}/../home-network-k3s}"
|
||||||
|
export SPEC_REPO
|
||||||
|
|
||||||
|
cd "${ROOT}"
|
||||||
|
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||||
|
file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
NOTES: list[str] = []
|
||||||
|
CHECKS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def note(msg):
|
||||||
|
NOTES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# A LOCAL DEPENDENCY the repo deliberately does not carry — the vault passphrase (kept
|
||||||
|
# outside the repo, R-SEC-01) or the Galaxy collections (gitignored; installed from
|
||||||
|
# collections/requirements.yml). Their absence is an ENVIRONMENT gap, not a repo defect,
|
||||||
|
# and is the ONLY tolerated reason the driven sub-assertions may not run. Deliberately
|
||||||
|
# narrow: every other non-zero exit is a real failure. The parse assertions always run.
|
||||||
|
ENV_ONLY_FAILURE = re.compile(
|
||||||
|
r"vault password|vault-id|Attempting to decrypt"
|
||||||
|
r"|No module named 'ansible_collections|couldn't resolve module/action", re.I)
|
||||||
|
|
||||||
|
ANS = ROOT / "infra" / "ansible"
|
||||||
|
INV = ANS / "inventory" / "hosts.ini"
|
||||||
|
GV = ANS / "inventory" / "group_vars"
|
||||||
|
TOFU = ROOT / "infra" / "tofu"
|
||||||
|
check(INV.is_file(), "infra/ansible/inventory/hosts.ini is missing — prod is not described anywhere")
|
||||||
|
if not INV.is_file():
|
||||||
|
print("CHECK FAIL (prod-baremetal-not-dev-surface): no inventory", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- inventory parsing
|
||||||
|
ini_groups: dict[str, list[dict]] = {}
|
||||||
|
children: dict[str, list[str]] = {}
|
||||||
|
cur = None
|
||||||
|
for line in INV.read_text().splitlines():
|
||||||
|
s = line.strip()
|
||||||
|
if not s or s.startswith("#"):
|
||||||
|
continue
|
||||||
|
m = re.match(r"^\[([^\]]+)\]$", s)
|
||||||
|
if m:
|
||||||
|
cur = m.group(1)
|
||||||
|
continue
|
||||||
|
if cur is None or cur.endswith(":vars"):
|
||||||
|
continue
|
||||||
|
if cur.endswith(":children"):
|
||||||
|
children.setdefault(cur.split(":")[0], []).append(s.split()[0])
|
||||||
|
continue
|
||||||
|
parts = s.split()
|
||||||
|
host = {"name": parts[0]}
|
||||||
|
for kv in parts[1:]:
|
||||||
|
if "=" in kv:
|
||||||
|
k, v = kv.split("=", 1)
|
||||||
|
host[k] = v
|
||||||
|
ini_groups.setdefault(cur, []).append(host)
|
||||||
|
|
||||||
|
|
||||||
|
def hosts_in(group: str, seen=None) -> list[dict]:
|
||||||
|
seen = seen or set()
|
||||||
|
if group in seen:
|
||||||
|
return []
|
||||||
|
seen.add(group)
|
||||||
|
out = list(ini_groups.get(group, []))
|
||||||
|
for child in children.get(group, []):
|
||||||
|
out += hosts_in(child, seen)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_gv(group: str) -> dict:
|
||||||
|
d: dict = {}
|
||||||
|
for p in [GV / f"{group}.yml", GV / group / "vars.yml"]:
|
||||||
|
if p.is_file():
|
||||||
|
try:
|
||||||
|
d.update(yaml.safe_load(p.read_text()) or {})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
# WHICH GROUP IS PROD? Derived, never named here: it is the group whose group_vars
|
||||||
|
# RETARGETS the k3s roles (k3s_primary_group / k3s_servers_group) away from the global
|
||||||
|
# dev default. That is exactly the seam docs/02 + infra/ansible/README describe for prod.
|
||||||
|
ALL_VARS = load_gv("all")
|
||||||
|
dev_primary_group = str(ALL_VARS.get("k3s_primary_group", ""))
|
||||||
|
candidates = []
|
||||||
|
for entry in sorted(GV.iterdir()) if GV.is_dir() else []:
|
||||||
|
g = entry.name[:-4] if entry.name.endswith(".yml") else entry.name
|
||||||
|
if g == "all":
|
||||||
|
continue
|
||||||
|
v = load_gv(g)
|
||||||
|
if v.get("k3s_primary_group") and str(v["k3s_primary_group"]) != dev_primary_group:
|
||||||
|
candidates.append((g, v))
|
||||||
|
check(len(candidates) == 1,
|
||||||
|
f"expected exactly ONE environment whose group_vars retargets the k3s roles (prod); found "
|
||||||
|
f"{[c[0] for c in candidates]} — R-ENV-03 describes a single prod estate reached through "
|
||||||
|
"the same roles as dev")
|
||||||
|
if not candidates:
|
||||||
|
print("CHECK FAIL (prod-baremetal-not-dev-surface): no prod group_vars found", file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
PROD_GROUP, PROD_VARS = candidates[0]
|
||||||
|
PROD_PRIMARY = str(PROD_VARS["k3s_primary_group"])
|
||||||
|
prod_hosts = hosts_in(PROD_GROUP)
|
||||||
|
prod_names = sorted(h["name"] for h in prod_hosts)
|
||||||
|
|
||||||
|
# ------------------------------------------------- (1) exactly three, directly reachable
|
||||||
|
check(len(prod_names) == 3,
|
||||||
|
f"prod group {PROD_GROUP!r} resolves to {len(prod_names)} host(s) {prod_names} — R-ENV-03 "
|
||||||
|
"says prod is a 3× mini-PC cluster")
|
||||||
|
dev_hosts = hosts_in(str(ALL_VARS.get("k3s_servers_group", "k3s_servers")))
|
||||||
|
for h in prod_hosts:
|
||||||
|
# Real machines on the LAN answer their own sshd. Dev's hosts are reached through a
|
||||||
|
# NAT port-forward (ansible_port 225x -> a VM's :22), which is precisely the
|
||||||
|
# indirection a hypervisor-hosted estate needs and bare metal does not.
|
||||||
|
check(str(h.get("ansible_port", "22")) == "22",
|
||||||
|
f"prod node {h['name']} is reached on port {h.get('ansible_port')} — a forwarded port "
|
||||||
|
"implies an indirection (NAT to a virtual estate), not a machine on the LAN")
|
||||||
|
check(h.get("k3s_node_ip") == h.get("ansible_host"),
|
||||||
|
f"prod node {h['name']} has ansible_host={h.get('ansible_host')} but "
|
||||||
|
f"k3s_node_ip={h.get('k3s_node_ip')} — a split address is the dev NAT pattern; a "
|
||||||
|
"bare-metal node's SSH address IS its cluster address")
|
||||||
|
|
||||||
|
# ------------------------------------------------------ (2) nothing creates prod as a VM
|
||||||
|
TF = "\n".join(p.read_text(errors="replace") for p in sorted(TOFU.glob("*.tf"))) \
|
||||||
|
if TOFU.is_dir() else ""
|
||||||
|
check(len(TF) > 500, "infra/tofu/*.tf is empty — cannot show that it does NOT create prod")
|
||||||
|
|
||||||
|
|
||||||
|
def tf_default(var: str):
|
||||||
|
m = re.search(r'variable\s+"' + re.escape(var) + r'"\s*\{(.*?)\n\}', TF, re.S)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
d = re.search(r"default\s*=\s*(.+)", m.group(1))
|
||||||
|
return d.group(1).strip() if d else None
|
||||||
|
|
||||||
|
|
||||||
|
prefix = (tf_default("node_name_prefix") or "").strip('"')
|
||||||
|
try:
|
||||||
|
node_count = int((tf_default("node_count") or "0").strip())
|
||||||
|
except ValueError:
|
||||||
|
node_count = 0
|
||||||
|
iac_names = {f"{prefix}{i + 1}" for i in range(node_count)}
|
||||||
|
check(len(iac_names) > 0, "the IaC module generates no node names — cannot compare with prod")
|
||||||
|
check(iac_names.isdisjoint(set(prod_names)),
|
||||||
|
f"the IaC VM module would create {sorted(iac_names & set(prod_names))}, which are PROD nodes "
|
||||||
|
"— prod is bare metal that the operator images (docs/02 stage 1), never instantiated by the "
|
||||||
|
"hypervisor module")
|
||||||
|
for name in prod_names:
|
||||||
|
check(name not in TF,
|
||||||
|
f"prod node {name} is named inside infra/tofu — prod hardware must not be described by "
|
||||||
|
"the VM module at all")
|
||||||
|
# The prod host addresses must not appear as IaC node addresses either (a renamed VM is
|
||||||
|
# still a VM).
|
||||||
|
tf_ips = set(re.findall(r'"(\d+\.\d+\.\d+\.\d+)"', tf_default("node_ips") or ""))
|
||||||
|
prod_ips = {str(h.get("ansible_host")) for h in prod_hosts}
|
||||||
|
check(tf_ips.isdisjoint(prod_ips),
|
||||||
|
f"the IaC module addresses {sorted(tf_ips & prod_ips)}, which are prod node addresses")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- (3) node-local storage
|
||||||
|
# Every environment's value for a cluster-vars knob (base default + each overlay patch).
|
||||||
|
def env_values(key: str) -> dict[str, str]:
|
||||||
|
vals: dict[str, str] = {}
|
||||||
|
base = ROOT / "clusters" / "base" / "cluster-vars.yaml"
|
||||||
|
if base.is_file():
|
||||||
|
for d in yaml.safe_load_all(base.read_text()):
|
||||||
|
if isinstance(d, dict) and d.get("kind") == "ConfigMap" \
|
||||||
|
and (d.get("metadata") or {}).get("name") == "cluster-vars":
|
||||||
|
v = (d.get("data") or {}).get(key)
|
||||||
|
if v is not None:
|
||||||
|
vals["base"] = str(v)
|
||||||
|
for overlay in sorted((ROOT / "clusters").glob("*/kustomization.yaml")):
|
||||||
|
env = overlay.parent.name
|
||||||
|
if env == "base":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
k = yaml.safe_load(overlay.read_text()) or {}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for p in (k.get("patches") or []):
|
||||||
|
tgt = p.get("target") or {}
|
||||||
|
if tgt.get("kind") != "ConfigMap" or tgt.get("name") != "cluster-vars":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ops = yaml.safe_load(p.get("patch") or "") or []
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for op in ops if isinstance(ops, list) else []:
|
||||||
|
if isinstance(op, dict) and str(op.get("path", "")) == f"/data/{key}":
|
||||||
|
vals[env] = str(op.get("value"))
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
# The prod OVERLAY is the one whose CLUSTER_ENV is not the base default.
|
||||||
|
cluster_envs = env_values("CLUSTER_ENV")
|
||||||
|
prod_overlays = [e for e, v in cluster_envs.items()
|
||||||
|
if e != "base" and v != cluster_envs.get("base")]
|
||||||
|
check(len(prod_overlays) >= 1,
|
||||||
|
"no clusters/ overlay sets its own CLUSTER_ENV — prod has no overlay of its own")
|
||||||
|
prod_overlay = None
|
||||||
|
for e in prod_overlays:
|
||||||
|
if PROD_GROUP.replace("k3s_", "") in cluster_envs[e] or cluster_envs[e] in PROD_GROUP:
|
||||||
|
prod_overlay = e
|
||||||
|
check(prod_overlay is not None,
|
||||||
|
f"no overlay's CLUSTER_ENV corresponds to the prod inventory group {PROD_GROUP!r} — the "
|
||||||
|
"cluster config and the machine inventory disagree about which estate is prod")
|
||||||
|
|
||||||
|
data_paths = env_values("LONGHORN_DATA_PATH")
|
||||||
|
replicas = env_values("LONGHORN_DEFAULT_REPLICA_COUNT")
|
||||||
|
if prod_overlay:
|
||||||
|
p = data_paths.get(prod_overlay, data_paths.get("base", ""))
|
||||||
|
check(p.startswith("/"),
|
||||||
|
f"prod's LONGHORN_DATA_PATH is {p!r} — R-ENV-03's 'local storage' requires an absolute "
|
||||||
|
"node-local filesystem path")
|
||||||
|
check(not re.match(r"^/(net|nfs|mnt/nfs)|:", p),
|
||||||
|
f"prod's LONGHORN_DATA_PATH {p!r} looks like a network mount, not local SSD/NVMe")
|
||||||
|
r = replicas.get(prod_overlay, replicas.get("base", ""))
|
||||||
|
check(r.isdigit() and int(r) == len(prod_names),
|
||||||
|
f"prod's LONGHORN_DEFAULT_REPLICA_COUNT is {r!r} for {len(prod_names)} node(s) — "
|
||||||
|
"'local storage' on a 3-node estate means one replica per node's own disk")
|
||||||
|
# The DEFAULT storage class must be the local (Longhorn) driver. A separate bulk/media tier
|
||||||
|
# on network storage is explicitly a different requirement (R-STATE-04, docs/04) and is not
|
||||||
|
# counted here.
|
||||||
|
default_scs = []
|
||||||
|
for f in subprocess.run(["git", "-C", str(ROOT), "ls-files", "-z", "*.yaml", "*.yml"],
|
||||||
|
capture_output=True, text=True, check=True).stdout.split("\0"):
|
||||||
|
if not f:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
for d in yaml.safe_load_all((ROOT / f).read_text(errors="replace")):
|
||||||
|
if isinstance(d, dict) and d.get("kind") == "StorageClass" and \
|
||||||
|
str(((d.get("metadata") or {}).get("annotations") or {})
|
||||||
|
.get("storageclass.kubernetes.io/is-default-class", "")) == "true":
|
||||||
|
default_scs.append((f, str(d.get("provisioner", ""))))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
check(len(default_scs) == 1,
|
||||||
|
f"expected exactly one default StorageClass, found {default_scs} — the tier prod's workloads "
|
||||||
|
"land on by default must be unambiguous")
|
||||||
|
for f, prov in default_scs:
|
||||||
|
check(prov == "driver.longhorn.io",
|
||||||
|
f"{f}: the default StorageClass provisioner is {prov!r}, not Longhorn — prod's default "
|
||||||
|
"tier would not be its own local disks")
|
||||||
|
|
||||||
|
# ------------------------------------- (4) prod runs the SAME bootstrap code, retargeted
|
||||||
|
PLAYBOOKS = ANS / "playbooks"
|
||||||
|
ROLES = ANS / "roles"
|
||||||
|
prod_token = PROD_GROUP.replace("k3s_", "")
|
||||||
|
for d, what in ((ROLES, "role"), (PLAYBOOKS, "playbook")):
|
||||||
|
for entry in sorted(d.iterdir()) if d.is_dir() else []:
|
||||||
|
check(prod_token not in entry.name.lower(),
|
||||||
|
f"infra/ansible/{d.name}/{entry.name} is a prod-specific {what} — docs/02 requires "
|
||||||
|
"the SAME Ansible bootstrap for prod, differing only in the machine-creation step")
|
||||||
|
# Every prod group_vars key must be a variable the shared code already knows: a role default,
|
||||||
|
# a global, or an ansible built-in. An invented key is a fork of the role by another name.
|
||||||
|
known: set[str] = set(ALL_VARS)
|
||||||
|
for rd in sorted(ROLES.iterdir()) if ROLES.is_dir() else []:
|
||||||
|
for p in (rd / "defaults").glob("*.yml") if (rd / "defaults").is_dir() else []:
|
||||||
|
try:
|
||||||
|
known |= set((yaml.safe_load(p.read_text()) or {}).keys())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
check(len(known) > 20, f"only {len(known)} shared variables found — (4) would prove nothing")
|
||||||
|
for k in sorted(PROD_VARS):
|
||||||
|
check(k.startswith("ansible_") or k in known,
|
||||||
|
f"prod group_vars sets {k!r}, which is not a role default or a global — prod-only "
|
||||||
|
"configuration that the dev path never exercises is developing against prod")
|
||||||
|
# ...and it must retarget, not re-implement: the group it names must exist in the inventory.
|
||||||
|
check(PROD_PRIMARY in ini_groups or PROD_PRIMARY in children,
|
||||||
|
f"prod group_vars names primary group {PROD_PRIMARY!r}, which the inventory does not define")
|
||||||
|
|
||||||
|
# ---------------------------------------- (5) prod is not a development surface (overlay)
|
||||||
|
if prod_overlay:
|
||||||
|
odir = ROOT / "clusters" / prod_overlay
|
||||||
|
files = sorted(p.name for p in odir.iterdir() if p.is_file())
|
||||||
|
check(files == ["kustomization.yaml", "sync.yaml"],
|
||||||
|
f"clusters/{prod_overlay}/ contains {files} — a prod overlay may hold only its "
|
||||||
|
"kustomization and its own root sync; anything else is configuration authored FOR PROD")
|
||||||
|
k = yaml.safe_load((odir / "kustomization.yaml").read_text()) or {}
|
||||||
|
check(sorted(str(r) for r in (k.get("resources") or [])) == ["../base", "sync.yaml"],
|
||||||
|
f"clusters/{prod_overlay} resources are {k.get('resources')} — prod must render the base "
|
||||||
|
"plus its own sync only (docs/02: 'apply the prod overlay (no base edits)')")
|
||||||
|
# Every patch must be an ENVIRONMENT-SCOPED override, never a change to what runs.
|
||||||
|
ALLOWED = {("ConfigMap", "cluster-vars"), ("GitRepository", None)}
|
||||||
|
for p in (k.get("patches") or []):
|
||||||
|
tgt = p.get("target") or {}
|
||||||
|
body = p.get("patch") or ""
|
||||||
|
kind = tgt.get("kind")
|
||||||
|
if kind is None: # inline patch: kind comes from the body
|
||||||
|
try:
|
||||||
|
doc = yaml.safe_load(body) or {}
|
||||||
|
except Exception:
|
||||||
|
doc = {}
|
||||||
|
kind = doc.get("kind")
|
||||||
|
name = (doc.get("metadata") or {}).get("name")
|
||||||
|
if kind == "Kustomization":
|
||||||
|
# Flux Kustomization patches are allowed ONLY to wire sops decryption —
|
||||||
|
# anything else (path, interval, images, values) changes what prod RUNS.
|
||||||
|
keys = set((doc.get("spec") or {}).keys())
|
||||||
|
check(keys <= {"decryption"},
|
||||||
|
f"clusters/{prod_overlay}: patch of Kustomization/{name} touches {sorted(keys)} "
|
||||||
|
"— only `decryption` (the per-cluster sops key) is an environment-scoped "
|
||||||
|
"override; changing anything else is developing against prod")
|
||||||
|
continue
|
||||||
|
check((kind, None) in ALLOWED or (kind, name) in ALLOWED,
|
||||||
|
f"clusters/{prod_overlay}: patch targets {kind}/{name}, which is not an "
|
||||||
|
"environment-scoped object — prod must not carry configuration of its own")
|
||||||
|
else:
|
||||||
|
check((kind, tgt.get("name")) in ALLOWED or (kind, None) in ALLOWED,
|
||||||
|
f"clusters/{prod_overlay}: patch targets {kind}/{tgt.get('name')}, which is not "
|
||||||
|
"an environment-scoped object — prod must not carry configuration of its own")
|
||||||
|
|
||||||
|
# Same Git ref as the other environment(s): a prod-only branch is a development line
|
||||||
|
# that only prod runs, which is exactly what R-ENV-03 forbids.
|
||||||
|
def git_ref(overlay: str):
|
||||||
|
kk = yaml.safe_load((ROOT / "clusters" / overlay / "kustomization.yaml").read_text()) or {}
|
||||||
|
for p in (kk.get("patches") or []):
|
||||||
|
body = p.get("patch") or ""
|
||||||
|
try:
|
||||||
|
doc = yaml.safe_load(body) or {}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if doc.get("kind") == "GitRepository":
|
||||||
|
return (doc.get("spec") or {}).get("ref")
|
||||||
|
return None
|
||||||
|
|
||||||
|
others = [o.name for o in (ROOT / "clusters").iterdir()
|
||||||
|
if o.is_dir() and o.name not in ("base", prod_overlay)]
|
||||||
|
prod_ref = git_ref(prod_overlay)
|
||||||
|
check(prod_ref is not None,
|
||||||
|
f"clusters/{prod_overlay} does not pin a Git ref — prod would follow whatever the base "
|
||||||
|
"points at, with no record of what it runs")
|
||||||
|
for o in others:
|
||||||
|
check(git_ref(o) == prod_ref,
|
||||||
|
f"clusters/{o} tracks {git_ref(o)} but prod tracks {prod_ref} — a prod-only ref is a "
|
||||||
|
"development line only prod runs (R-ENV-03: prod is a target, not a sandbox)")
|
||||||
|
|
||||||
|
# No prod-only manifest anywhere in the rendered configuration.
|
||||||
|
tracked = [f for f in subprocess.run(["git", "-C", str(ROOT), "ls-files", "-z"],
|
||||||
|
capture_output=True, text=True, check=True
|
||||||
|
).stdout.split("\0") if f]
|
||||||
|
RENDERED_DIRS = ("platform/", "platform-config", "apps/", "catalog/", "clusters/base/")
|
||||||
|
for f in tracked:
|
||||||
|
if f.startswith(RENDERED_DIRS) and re.search(rf"(^|[/_.-]){re.escape(prod_token)}([/_.-]|$)",
|
||||||
|
f, re.I):
|
||||||
|
check(False,
|
||||||
|
f"{f} is a prod-specific file inside the shared configuration — every environment "
|
||||||
|
"renders these paths, so prod-only content here is development against prod")
|
||||||
|
check(len(tracked) > 50, f"git ls-files returned only {len(tracked)} paths — not a real checkout")
|
||||||
|
|
||||||
|
# --------------------------- (6) prod work exists only with a declaration of readiness
|
||||||
|
# In-repo: the operator's recorded decision to open the prod phase.
|
||||||
|
log = ROOT / "DECISIONS-LOG.md"
|
||||||
|
check(log.is_file() and re.search(r"PHASE\s*7\b.*OPEN", log.read_text(errors="replace"), re.I),
|
||||||
|
"DECISIONS-LOG.md carries no record of the operator OPENING the prod phase, yet prod "
|
||||||
|
"inventory/overlay exist — R-ENV-03 allows prod build only once the roadmap declares it ready")
|
||||||
|
# In the spec (read-only, may not be mounted): the roadmap phase that opens prod.
|
||||||
|
spec = Path(os.environ.get("SPEC_REPO", ""))
|
||||||
|
phases = spec / "roadmap" / "phases.md"
|
||||||
|
if phases.is_file():
|
||||||
|
txt = phases.read_text(errors="replace")
|
||||||
|
check(re.search(r"##\s*Phase\s*7\b", txt) is not None and
|
||||||
|
re.search(r"prod", txt, re.I) is not None,
|
||||||
|
"the spec roadmap has no Phase 7 opening prod build — the declaration R-ENV-03 defers "
|
||||||
|
"to does not exist")
|
||||||
|
check(re.search(r"explicitly opened|declares prod hardware ready|operator declares", txt, re.I)
|
||||||
|
is not None,
|
||||||
|
"the spec roadmap's prod phase states no explicit entry declaration — the gate R-ENV-03 "
|
||||||
|
"names would be unenforceable")
|
||||||
|
else:
|
||||||
|
note(f"spec repo not mounted at {spec} — the roadmap half of (6) was NOT checked "
|
||||||
|
"(the in-repo declaration still was)")
|
||||||
|
|
||||||
|
# --------------------------- (4b) DRIVE the retarget offline: same chain, prod hosts
|
||||||
|
if shutil.which("ansible-playbook") is None:
|
||||||
|
note("ansible-playbook is not installed here (e.g. the in-cluster chainsaw image), so the "
|
||||||
|
"prod retarget was PARSED but not DRIVEN; the merge gate's container has it")
|
||||||
|
else:
|
||||||
|
sec_groups = [g for g in children.get(PROD_GROUP, []) if g != PROD_PRIMARY]
|
||||||
|
args = ["--list-hosts", "playbooks/site.yml",
|
||||||
|
"-e", f"k3s_hosts={PROD_GROUP}",
|
||||||
|
"-e", f"k3s_primary_hosts={PROD_PRIMARY}"]
|
||||||
|
if sec_groups:
|
||||||
|
args += ["-e", f"k3s_secondary_hosts={sec_groups[0]}"]
|
||||||
|
lh = subprocess.run(["ansible-playbook", *args], cwd=ANS, capture_output=True, text=True)
|
||||||
|
out = (lh.stderr or "") + (lh.stdout or "")
|
||||||
|
if lh.returncode != 0 and ENV_ONLY_FAILURE.search(out):
|
||||||
|
# See the same narrow, self-policing skip in dev-nodes-provisioned-as-code.
|
||||||
|
note("ansible-playbook lacks a LOCAL dependency kept outside the repo by design (the vault "
|
||||||
|
"passphrase and/or the gitignored Galaxy collections), so the prod retarget was not "
|
||||||
|
"driven; every parse assertion above still ran")
|
||||||
|
else:
|
||||||
|
check(lh.returncode == 0,
|
||||||
|
f"retargeting the SHARED chain at prod failed: {out.strip()[:400]} — docs/02 requires "
|
||||||
|
"the same Ansible bootstrap to run for prod")
|
||||||
|
seen: set[str] = set()
|
||||||
|
in_hosts = False
|
||||||
|
for line in lh.stdout.splitlines():
|
||||||
|
if re.match(r"\s*hosts \(\d+\):", line):
|
||||||
|
in_hosts = True
|
||||||
|
continue
|
||||||
|
if re.search(r"play #\d+", line):
|
||||||
|
in_hosts = False
|
||||||
|
continue
|
||||||
|
if in_hosts and line.strip():
|
||||||
|
seen.add(line.strip())
|
||||||
|
check(seen == set(prod_names),
|
||||||
|
f"the shared chain retargeted at prod resolves to {sorted(seen)}, not the three prod "
|
||||||
|
f"nodes {prod_names} — prod would be bootstrapped by something other than dev's path")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- verdict
|
||||||
|
check(CHECKS >= 25,
|
||||||
|
f"only {CHECKS} assertions ran — the check degenerated to a near-no-op and its pass "
|
||||||
|
"would be meaningless")
|
||||||
|
|
||||||
|
if FAILURES:
|
||||||
|
print(f"CHECK FAIL (prod-baremetal-not-dev-surface): {len(FAILURES)} of {CHECKS} assertions failed",
|
||||||
|
file=sys.stderr)
|
||||||
|
for f in FAILURES:
|
||||||
|
print(f" - {f}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
for n in NOTES:
|
||||||
|
print(f" note: {n}")
|
||||||
|
print(f"check OK: prod-baremetal-not-dev-surface — {CHECKS} assertions; prod group {PROD_GROUP!r} = "
|
||||||
|
f"{prod_names} (3 nodes, none created by IaC), overlay clusters/{prod_overlay} renders the base "
|
||||||
|
f"plus environment-scoped patches only, local Longhorn path "
|
||||||
|
f"{data_paths.get(prod_overlay, data_paths.get('base'))!r} with "
|
||||||
|
f"{replicas.get(prod_overlay, replicas.get('base'))} replica(s). R-ENV-03's AUTHORED clauses "
|
||||||
|
"only — the hardware facts (mini-PC, bare metal, local NVMe) are operator-attested and the "
|
||||||
|
"live cluster's shape is NOT asserted here (see the header's gap list).")
|
||||||
|
PY
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L0 STATIC — certificate lifecycle needs no manual step (R-TLS-02) [EXECUTABLE]
|
||||||
|
# Catalog: docs/14 L0 row ("Pre-apply, in CI") — runs WITHOUT a cluster.
|
||||||
|
# Spec: docs/00 R-TLS-02 ("Certificates MUST be automatically issued, renewed, and
|
||||||
|
# revoked without manual steps"); mechanism in docs/08 + ADR-0006.
|
||||||
|
#
|
||||||
|
# R-TLS-02 is one adverbial clause plus three runtime verbs. This test asserts the
|
||||||
|
# clause — "without manual steps" — which in a GitOps platform is a property of the
|
||||||
|
# REPOSITORY: a human step in the certificate path leaves evidence in git (committed
|
||||||
|
# cert material, a Gateway serving a Secret nothing declares, an issuer with no
|
||||||
|
# automated solver, a credential never delivered, auto-approval switched off, or CRs
|
||||||
|
# nothing reconciles). The three verbs are observed by the companion L2 test.
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC (R-ENV-02): the issuer a Certificate names is `${ACME_CLUSTER_ISSUER}`,
|
||||||
|
# so the check resolves it against the BASE cluster-vars default AND every overlay's value and
|
||||||
|
# requires each resolved issuer to exist. dev's issuer and prod's issuer are both verified from
|
||||||
|
# one authored assertion, with no hostname, address or environment name written down.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it,
|
||||||
|
# WHEN every serving-TLS reference, Certificate, Issuer and the cert-manager release
|
||||||
|
# is inspected offline,
|
||||||
|
# THEN every Gateway listener that terminates TLS serves a Secret a tracked Certificate
|
||||||
|
# produces; no certificate material is committed anywhere; every Certificate's
|
||||||
|
# issuer resolves — in EVERY environment — to a tracked ACME issuer with an
|
||||||
|
# automated dns01/http01 solver whose credential is SOPS-delivered from git;
|
||||||
|
# cert-manager's automatic request approval is not disabled; and every one of
|
||||||
|
# those objects is rendered by a Flux Kustomization, so nothing in the chain
|
||||||
|
# depends on a human running kubectl.
|
||||||
|
#
|
||||||
|
# STATUS: EXECUTABLE. `check.sh` is a pure offline assertion script (git + PyYAML, no
|
||||||
|
# cluster, no network); conformance/run.sh --layer l0 and ci/l0.sh run it directly, and
|
||||||
|
# this Chainsaw Test wraps the SAME script.
|
||||||
|
#
|
||||||
|
# KNOWN, DELIBERATE GAPS (named in check.sh's header too — this test claims NONE of them):
|
||||||
|
# * ISSUED / RENEWED as runtime events: L2 (l2-behavioral/cert-lifecycle-automated).
|
||||||
|
# * REVOKED: not proven at any layer today. cert-manager performs no ACME revocation,
|
||||||
|
# and the spec's own reading (docs/08: "removing/rotating a certificate is a
|
||||||
|
# declarative change") needs `enableCertificateOwnerRef`, which the tracked
|
||||||
|
# cert-manager HelmRelease does not set — so a removed Certificate leaves its
|
||||||
|
# serving Secret behind. The L2 test asserts the withdrawal behaviour and is
|
||||||
|
# EXPECTED TO FAIL until that knob is set; escalated, not softened (R-TEST-06).
|
||||||
|
# * That the live cluster is serving the tracked Certificate (cluster state).
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: tls-lifecycle-automated
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-TLS-02: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-TLS-02"
|
||||||
|
conformance.home.k3s/layer: "L0"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Given the tracked tree, when the certificate path is inspected offline, then no
|
||||||
|
manual step exists anywhere in it: serving Secrets are cert-manager-produced, no
|
||||||
|
cert material is committed, every environment's issuer is a tracked ACME issuer
|
||||||
|
with an automated solver and a git-delivered credential, auto-approval is on, and
|
||||||
|
the whole chain is Flux-reconciled.
|
||||||
|
spec:
|
||||||
|
steps:
|
||||||
|
- name: then-no-manual-step-exists-in-the-certificate-path
|
||||||
|
description: >-
|
||||||
|
Run the offline assertion script over the tracked tree (same script ci/l0.sh
|
||||||
|
and conformance/run.sh --layer l0 execute).
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: bash ./check.sh
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# L0 static check (offline, no cluster) — tls-lifecycle-automated
|
||||||
|
# Serves: R-TLS-02 ("Certificates MUST be automatically **issued, renewed, and revoked**
|
||||||
|
# without manual steps." — spec docs/00-vision-and-scope.md; mechanism in
|
||||||
|
# docs/08-tls-and-certificates.md, ADR-0006.)
|
||||||
|
#
|
||||||
|
# WHICH CLAUSE THIS LAYER CAN PROVE — AND WHICH IT CANNOT
|
||||||
|
# ------------------------------------------------------
|
||||||
|
# R-TLS-02 has one adverbial clause ("without manual steps") and three verbs
|
||||||
|
# ("issued", "renewed", "revoked"). In a GitOps platform the *without manual steps*
|
||||||
|
# clause IS a repository property, and it is the half that can be proven with no
|
||||||
|
# cluster: if a human had to mint, install, or approve a serving certificate, the
|
||||||
|
# evidence of that human step would be IN THE REPO — a committed `tls.crt`, a Gateway
|
||||||
|
# pointing at a Secret nothing declares, an issuer that no automated solver can
|
||||||
|
# satisfy, a per-cert credential handed over out of band, or an admission path that
|
||||||
|
# waits for a manual CertificateRequest approval. This check asserts that none of
|
||||||
|
# those exist and that the automated path is completely wired instead.
|
||||||
|
#
|
||||||
|
# The three VERBS are runtime events. They are asserted by the companion L2 test
|
||||||
|
# (conformance/tests/l2-behavioral/cert-lifecycle-automated), which observes an
|
||||||
|
# issuance, an unattended renewal, and the withdrawal of a removed certificate. This
|
||||||
|
# check does NOT claim them; see "WHAT THIS DOES *NOT* PROVE" below.
|
||||||
|
#
|
||||||
|
# GIVEN the repository as git tracks it,
|
||||||
|
# WHEN every serving-TLS reference, Certificate, Issuer and the cert-manager release
|
||||||
|
# are inspected offline,
|
||||||
|
# THEN (1) at least one cert-manager `Certificate` is authored (vacuity floor: a repo
|
||||||
|
# with no Certificate cannot pass this check by having nothing to check);
|
||||||
|
# (2) every Gateway HTTPS listener that TERMINATES TLS references ONLY Secrets
|
||||||
|
# that a tracked Certificate declares as its `secretName` — so the serving
|
||||||
|
# credential is produced by the controller, never dropped in by hand;
|
||||||
|
# (3) NO certificate material is tracked anywhere: no Secret manifest of type
|
||||||
|
# kubernetes.io/tls, no tls.crt/tls.key/ca.crt key in any committed Secret
|
||||||
|
# (plain OR SOPS-encrypted), and no PEM certificate body in any tracked
|
||||||
|
# YAML — i.e. issuance is not, and cannot silently become, a human step;
|
||||||
|
# (4) every Certificate's `issuerRef` resolves to a TRACKED issuer IN EVERY
|
||||||
|
# ENVIRONMENT — the `${ACME_CLUSTER_ISSUER}` token is resolved against the
|
||||||
|
# base cluster-vars default AND every overlay's value, so an environment
|
||||||
|
# whose issuer does not exist is caught here rather than at prod bring-up;
|
||||||
|
# (5) every issuer so referenced is an ACME issuer with a NON-EMPTY solver list
|
||||||
|
# whose every solver is an automated challenge type (dns01 / http01) — the
|
||||||
|
# same code path serves issuance AND renewal, so a solver a human must
|
||||||
|
# complete would defeat both;
|
||||||
|
# (6) each dns01 solver's provider credential is referenced BY NAME and that
|
||||||
|
# Secret is delivered SOPS-encrypted from git via a kustomization — the
|
||||||
|
# operator's one-time token hand-off (docs/08 OQ-4), never a per-cert step;
|
||||||
|
# (7) cert-manager's automatic CertificateRequest approval is NOT disabled
|
||||||
|
# (`disableAutoApproval`), and any `approveSignerNames` narrowing still
|
||||||
|
# covers the ClusterIssuers actually used — a request that waits for a
|
||||||
|
# human approver is the definition of a manual step;
|
||||||
|
# (8) the whole chain is reconciled from git — cert-manager's HelmRelease, the
|
||||||
|
# ClusterIssuers and the Certificate are each included in a kustomization
|
||||||
|
# that a Flux Kustomization renders — so a deleted Certificate is
|
||||||
|
# re-created and re-issued with no human action;
|
||||||
|
# (9) no Certificate defeats renewal arithmetically (`renewBefore` >= `duration`
|
||||||
|
# would mean the renewal instant never arrives).
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this test as claiming them):
|
||||||
|
# - ISSUED: that an issuance actually happens. Asserted at L2 (step 2 of
|
||||||
|
# l2-behavioral/cert-lifecycle-automated) and, for the real ACME/DNS-01 path,
|
||||||
|
# evidenced by l2-behavioral/trusted-tls serving a publicly-trusted cert.
|
||||||
|
# - RENEWED: that a renewal actually happens unattended. docs/08's acceptance gate
|
||||||
|
# is explicit that this must be "observed across a renewal" — L2 step 3 does that
|
||||||
|
# by fast-forwarding a certificate's renewal window; nothing static can.
|
||||||
|
# - REVOKED: NOT PROVEN AT ANY LAYER TODAY, and not asserted here. Two distinct
|
||||||
|
# readings, neither established: (a) ACME revocation — cert-manager does not
|
||||||
|
# revoke certificates at all, so no repo state could show it; (b) the spec's own
|
||||||
|
# reading in docs/08 ("removing/rotating a certificate is a declarative change")
|
||||||
|
# — withdrawal of a REMOVED certificate's serving material requires cert-manager's
|
||||||
|
# `enableCertificateOwnerRef`, which the tracked HelmRelease does NOT set (chart
|
||||||
|
# default false). L2 step 4 asserts the withdrawal behaviour and is expected to
|
||||||
|
# FAIL until that knob is set; escalated to the PM rather than softened (R-TEST-06).
|
||||||
|
# - That the tracked Certificate is the one the live Gateway is actually serving
|
||||||
|
# (cluster state), or that the ACME account/DNS credential is still valid.
|
||||||
|
# - Certificate EXPIRY ALERTING, which docs/08 delegates to R-OBS-04 (see
|
||||||
|
# l3-fault-injection/alerting).
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ROOT="$(cd "${DIR}/../../../.." && pwd)" # repo root
|
||||||
|
PYTHON="${PYTHON:-python3}"
|
||||||
|
|
||||||
|
cd "${ROOT}"
|
||||||
|
"${PYTHON}" - "${ROOT}" <<'PY'
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
print("check FAIL: PyYAML is required (same dependency as catalog/render.py)",
|
||||||
|
file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
ROOT = Path(sys.argv[1])
|
||||||
|
FAILURES: list[str] = []
|
||||||
|
CHECKS = 0
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, msg):
|
||||||
|
global CHECKS
|
||||||
|
CHECKS += 1
|
||||||
|
if not cond:
|
||||||
|
FAILURES.append(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def tracked() -> list[str]:
|
||||||
|
out = subprocess.run(["git", "-C", str(ROOT), "ls-files", "-z"],
|
||||||
|
capture_output=True, text=True, check=True).stdout
|
||||||
|
return [p for p in out.split("\0") if p]
|
||||||
|
|
||||||
|
|
||||||
|
FILES = tracked()
|
||||||
|
check(len(FILES) > 50, f"git ls-files returned only {len(FILES)} paths — not a real checkout")
|
||||||
|
YAML_FILES = [f for f in FILES if re.search(r"\.(ya?ml)$", f)]
|
||||||
|
|
||||||
|
|
||||||
|
def docs_of(rel: str):
|
||||||
|
"""Parsed YAML documents of a tracked file, or [] when it is not parseable YAML."""
|
||||||
|
try:
|
||||||
|
return [d for d in yaml.safe_load_all((ROOT / rel).read_text(errors="replace"))
|
||||||
|
if isinstance(d, dict)]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
DOCS: list[tuple[str, dict]] = [(rel, d) for rel in YAML_FILES for d in docs_of(rel)]
|
||||||
|
|
||||||
|
|
||||||
|
def of_kind(kind: str, group_prefix: str = "") -> list[tuple[str, dict]]:
|
||||||
|
out = []
|
||||||
|
for rel, d in DOCS:
|
||||||
|
if d.get("kind") != kind:
|
||||||
|
continue
|
||||||
|
if group_prefix and not str(d.get("apiVersion", "")).startswith(group_prefix):
|
||||||
|
continue
|
||||||
|
out.append((rel, d))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ns_of(d: dict, default: str = "default") -> str:
|
||||||
|
return (d.get("metadata") or {}).get("namespace") or default
|
||||||
|
|
||||||
|
|
||||||
|
def name_of(d: dict) -> str:
|
||||||
|
return (d.get("metadata") or {}).get("name") or "<unnamed>"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ cluster-vars resolution
|
||||||
|
# Every environment's value for a ${TOKEN}: the base default plus each overlay's JSON6902
|
||||||
|
# `- op: replace / path: /data/KEY / value: ...` patch on the cluster-vars ConfigMap. This
|
||||||
|
# is the same mechanism flux-postbuild-substitution audits; here we need the VALUES, so an
|
||||||
|
# issuer name can be resolved per environment instead of being asserted for dev only.
|
||||||
|
def env_values(key: str) -> dict[str, str]:
|
||||||
|
vals: dict[str, str] = {}
|
||||||
|
base = ROOT / "clusters" / "base" / "cluster-vars.yaml"
|
||||||
|
if base.is_file():
|
||||||
|
for d in yaml.safe_load_all(base.read_text()):
|
||||||
|
if isinstance(d, dict) and d.get("kind") == "ConfigMap" \
|
||||||
|
and name_of(d) == "cluster-vars":
|
||||||
|
v = (d.get("data") or {}).get(key)
|
||||||
|
if v is not None:
|
||||||
|
vals["base"] = str(v)
|
||||||
|
for overlay in sorted((ROOT / "clusters").glob("*/kustomization.yaml")):
|
||||||
|
env = overlay.parent.name
|
||||||
|
if env == "base":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
k = yaml.safe_load(overlay.read_text()) or {}
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for p in (k.get("patches") or []):
|
||||||
|
tgt = p.get("target") or {}
|
||||||
|
if tgt.get("kind") != "ConfigMap" or tgt.get("name") != "cluster-vars":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ops = yaml.safe_load(p.get("patch") or "") or []
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for op in ops if isinstance(ops, list) else []:
|
||||||
|
if isinstance(op, dict) and str(op.get("path", "")) == f"/data/{key}":
|
||||||
|
vals[env] = str(op.get("value"))
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
TOKEN_RE = re.compile(r"^\$\{([A-Z0-9_]+)\}$")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_per_env(value: str) -> dict[str, str]:
|
||||||
|
"""A literal resolves to itself in every environment; a ${TOKEN} to each env's value."""
|
||||||
|
m = TOKEN_RE.match(value.strip())
|
||||||
|
if not m:
|
||||||
|
return {"(literal)": value.strip()}
|
||||||
|
return env_values(m.group(1))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------- what Flux actually renders (reachability)
|
||||||
|
# A manifest is DELIVERED only if it is reachable from a Flux Kustomization's `path` through
|
||||||
|
# the kustomize resource graph: <path>/kustomization.yaml -> resources -> (file | directory
|
||||||
|
# -> that directory's kustomization.yaml -> ...). Membership is deliberately computed by
|
||||||
|
# walking that graph rather than by asking "is this file's DIRECTORY mentioned somewhere":
|
||||||
|
# the loose form silently passes when an entry is deleted from the very kustomization.yaml
|
||||||
|
# that the directory reference renders (verified — it made two assertions here no-ops).
|
||||||
|
KUST_RESOURCES: dict[str, list[str]] = {}
|
||||||
|
for rel in [f for f in YAML_FILES if f.endswith("kustomization.yaml")]:
|
||||||
|
refs: list[str] = []
|
||||||
|
for d in docs_of(rel):
|
||||||
|
for r in (d.get("resources") or []) + (d.get("components") or []):
|
||||||
|
if not isinstance(r, str) or "://" in r: # skip remote bases
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
p = (ROOT / rel).parent.joinpath(r).resolve()
|
||||||
|
refs.append(str(p.relative_to(ROOT)))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
KUST_RESOURCES[rel] = refs
|
||||||
|
|
||||||
|
FLUX_PATHS: set[str] = set()
|
||||||
|
for _rel, _d in DOCS:
|
||||||
|
if _d.get("kind") == "Kustomization" and \
|
||||||
|
str(_d.get("apiVersion", "")).startswith("kustomize.toolkit.fluxcd.io/"):
|
||||||
|
p = str((_d.get("spec") or {}).get("path", "")).lstrip("./").rstrip("/")
|
||||||
|
if p:
|
||||||
|
FLUX_PATHS.add(p)
|
||||||
|
|
||||||
|
|
||||||
|
def _walk(entry: str, seen: set[str], out: set[str]) -> None:
|
||||||
|
if entry in seen:
|
||||||
|
return
|
||||||
|
seen.add(entry)
|
||||||
|
kust = entry if entry.endswith("kustomization.yaml") else f"{entry}/kustomization.yaml"
|
||||||
|
if kust in KUST_RESOURCES: # a directory (or a kustomization)
|
||||||
|
for r in KUST_RESOURCES[kust]:
|
||||||
|
_walk(r, seen, out)
|
||||||
|
return
|
||||||
|
out.add(entry) # a leaf manifest
|
||||||
|
|
||||||
|
|
||||||
|
RENDERED: set[str] = set()
|
||||||
|
for _p in FLUX_PATHS:
|
||||||
|
_walk(_p, set(), RENDERED)
|
||||||
|
|
||||||
|
|
||||||
|
def delivered(rel: str) -> bool:
|
||||||
|
"""True iff Flux renders this file from one of its Kustomization paths."""
|
||||||
|
return rel in RENDERED
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------- (1) there is something to check
|
||||||
|
CERTS = of_kind("Certificate", "cert-manager.io/")
|
||||||
|
check(len(CERTS) >= 1,
|
||||||
|
"no cert-manager.io Certificate is authored anywhere in the tracked tree — every "
|
||||||
|
"assertion below would be vacuous, so this is a FAILURE, not a pass (R-TLS-02)")
|
||||||
|
|
||||||
|
ISSUERS = of_kind("ClusterIssuer", "cert-manager.io/") + of_kind("Issuer", "cert-manager.io/")
|
||||||
|
check(len(ISSUERS) >= 1,
|
||||||
|
"no cert-manager ClusterIssuer/Issuer is authored — nothing could issue automatically")
|
||||||
|
|
||||||
|
# ---------------------------- (2) every terminating listener serves a controller cert
|
||||||
|
CERT_SECRETS: dict[tuple[str, str], str] = { # (namespace, secretName) -> file
|
||||||
|
(ns_of(d, "default"), str((d.get("spec") or {}).get("secretName", ""))): rel
|
||||||
|
for rel, d in CERTS
|
||||||
|
}
|
||||||
|
listeners_checked = 0
|
||||||
|
for rel, gw in of_kind("Gateway", "gateway.networking.k8s.io/"):
|
||||||
|
gw_ns = ns_of(gw)
|
||||||
|
for lis in ((gw.get("spec") or {}).get("listeners") or []):
|
||||||
|
tls = lis.get("tls") or {}
|
||||||
|
if str(tls.get("mode", "")) != "Terminate":
|
||||||
|
continue
|
||||||
|
listeners_checked += 1
|
||||||
|
refs = tls.get("certificateRefs") or []
|
||||||
|
check(len(refs) > 0,
|
||||||
|
f"{rel}: Gateway {gw_ns}/{name_of(gw)} listener {lis.get('name')!r} terminates "
|
||||||
|
"TLS with NO certificateRefs — it can only serve a certificate someone puts "
|
||||||
|
"there by hand")
|
||||||
|
for r in refs:
|
||||||
|
kind = str(r.get("kind", "Secret"))
|
||||||
|
key = (str(r.get("namespace") or gw_ns), str(r.get("name")))
|
||||||
|
check(kind == "Secret",
|
||||||
|
f"{rel}: listener {lis.get('name')!r} certificateRef kind is {kind!r}; only "
|
||||||
|
"Secret refs can be traced to a cert-manager Certificate")
|
||||||
|
check(key in CERT_SECRETS,
|
||||||
|
f"{rel}: listener {lis.get('name')!r} serves Secret {key[0]}/{key[1]}, which "
|
||||||
|
"NO tracked cert-manager Certificate declares as its secretName — that "
|
||||||
|
"credential would have to be created by hand (R-TLS-02 'without manual steps')")
|
||||||
|
check(listeners_checked > 0,
|
||||||
|
"no Gateway listener with tls.mode=Terminate found in the tracked tree — there is no "
|
||||||
|
"serving-TLS reference to trace, so (2) proved nothing")
|
||||||
|
|
||||||
|
# --------------------------------- (3) no certificate material is tracked, anywhere
|
||||||
|
TLS_KEYS = {"tls.crt", "tls.key", "ca.crt", "tls-combined.pem"}
|
||||||
|
for rel, d in DOCS:
|
||||||
|
if d.get("kind") != "Secret":
|
||||||
|
continue
|
||||||
|
stype = str(d.get("type", ""))
|
||||||
|
check(stype != "kubernetes.io/tls",
|
||||||
|
f"{rel}: a Secret of type kubernetes.io/tls is COMMITTED (metadata.name="
|
||||||
|
f"{name_of(d)}) — a serving certificate authored by a human, not issued")
|
||||||
|
for field in ("data", "stringData"):
|
||||||
|
for k in (d.get(field) or {}):
|
||||||
|
check(str(k) not in TLS_KEYS,
|
||||||
|
f"{rel}: committed Secret {name_of(d)} carries {field}.{k} — certificate "
|
||||||
|
"material in git means a manual issuance step exists (R-TLS-02)")
|
||||||
|
|
||||||
|
PEM_RE = re.compile(r"-----BEGIN (?:[A-Z ]+ )?CERTIFICATE-----")
|
||||||
|
for rel in YAML_FILES:
|
||||||
|
body = (ROOT / rel).read_text(errors="replace")
|
||||||
|
# Scoped to YAML manifests deliberately: prose/docs may legitimately SHOW a PEM header
|
||||||
|
# in an example, and a .md file is not applied to any cluster. A manifest cannot.
|
||||||
|
check(not PEM_RE.search(body),
|
||||||
|
f"{rel}: contains a PEM CERTIFICATE body — a hand-minted certificate in a manifest")
|
||||||
|
|
||||||
|
# --------------------------- (4)+(5)+(6) the issuer chain is automated in EVERY env
|
||||||
|
# The cert-manager release's namespace IS the controller's `--cluster-resource-namespace`
|
||||||
|
# (chart default), i.e. where a ClusterIssuer's solver credential must live. Derived from the
|
||||||
|
# HelmRelease, never written down here, so this holds if the release namespace ever changes.
|
||||||
|
cm_releases = [(rel, d) for rel, d in of_kind("HelmRelease") if name_of(d) == "cert-manager"]
|
||||||
|
CM_NS = ns_of(cm_releases[0][1], "cert-manager") if cm_releases else "cert-manager"
|
||||||
|
|
||||||
|
ISSUER_INDEX = {(d.get("kind"), name_of(d)): (rel, d) for rel, d in ISSUERS}
|
||||||
|
solver_creds: list[tuple[str, str, str, str]] = [] # (issuer, ns, secretName, ref field)
|
||||||
|
issuers_verified: set[str] = set()
|
||||||
|
for rel, cert in CERTS:
|
||||||
|
spec = cert.get("spec") or {}
|
||||||
|
ref = spec.get("issuerRef") or {}
|
||||||
|
kind = str(ref.get("kind", "Issuer"))
|
||||||
|
per_env = resolve_per_env(str(ref.get("name", "")))
|
||||||
|
check(len(per_env) > 0,
|
||||||
|
f"{rel}: Certificate {name_of(cert)} issuerRef.name is a token with NO value in "
|
||||||
|
"clusters/base/cluster-vars.yaml or any overlay — it would render empty and never issue")
|
||||||
|
for env, iname in sorted(per_env.items()):
|
||||||
|
hit = ISSUER_INDEX.get((kind, iname))
|
||||||
|
check(hit is not None,
|
||||||
|
f"{rel}: Certificate {name_of(cert)} resolves issuerRef to {kind}/{iname} in "
|
||||||
|
f"environment {env!r}, but no such issuer is tracked — that environment cannot "
|
||||||
|
"issue anything without a hand-created issuer (R-TLS-02, R-ENV-02)")
|
||||||
|
if hit is None:
|
||||||
|
continue
|
||||||
|
irel, issuer = hit
|
||||||
|
issuers_verified.add(iname)
|
||||||
|
ispec = issuer.get("spec") or {}
|
||||||
|
acme = ispec.get("acme")
|
||||||
|
check(isinstance(acme, dict),
|
||||||
|
f"{irel}: {kind}/{iname} (used by {name_of(cert)} in env {env!r}) is not an ACME "
|
||||||
|
f"issuer — its issuance/renewal path is {sorted(ispec)}; docs/08/ADR-0006 mandate "
|
||||||
|
"ACME so both are unattended")
|
||||||
|
if not isinstance(acme, dict):
|
||||||
|
continue
|
||||||
|
check(str(acme.get("server", "")).startswith("https://"),
|
||||||
|
f"{irel}: {kind}/{iname} has no https ACME directory endpoint")
|
||||||
|
check(bool((acme.get("privateKeySecretRef") or {}).get("name")),
|
||||||
|
f"{irel}: {kind}/{iname} declares no privateKeySecretRef — the ACME account key "
|
||||||
|
"would have to be provisioned by hand")
|
||||||
|
solvers = acme.get("solvers") or []
|
||||||
|
check(len(solvers) > 0,
|
||||||
|
f"{irel}: {kind}/{iname} declares NO solvers — every order would sit unsolved "
|
||||||
|
"until a human completed the challenge")
|
||||||
|
for s in solvers:
|
||||||
|
types = [t for t in ("dns01", "http01") if t in s]
|
||||||
|
check(len(types) == 1,
|
||||||
|
f"{irel}: {kind}/{iname} has a solver with challenge types {sorted(s)} — only "
|
||||||
|
"the automated dns01/http01 solvers satisfy 'without manual steps'")
|
||||||
|
for provider, cfg in (s.get("dns01") or {}).items():
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
continue
|
||||||
|
for field, val in cfg.items():
|
||||||
|
if isinstance(val, dict) and str(field).endswith(("SecretRef", "secretRef")):
|
||||||
|
n = str(val.get("name", ""))
|
||||||
|
check(bool(n),
|
||||||
|
f"{irel}: {kind}/{iname} dns01 {provider}.{field} has no secret name")
|
||||||
|
if n:
|
||||||
|
# A ClusterIssuer resolves secret refs in the controller's
|
||||||
|
# cluster-resource-namespace; a namespaced Issuer in its own.
|
||||||
|
want_ns = CM_NS if kind == "ClusterIssuer" else ns_of(issuer, CM_NS)
|
||||||
|
solver_creds.append((iname, want_ns, n, f"{provider}.{field}"))
|
||||||
|
|
||||||
|
check(len(issuers_verified) > 0, "no issuer was reachable from any Certificate — (5) proved nothing")
|
||||||
|
|
||||||
|
# (6) the solver credential is delivered, SOPS-encrypted, from git — never per-cert by hand.
|
||||||
|
check(len(solver_creds) > 0,
|
||||||
|
"no dns01 solver credential reference found — docs/08 (OQ-4) requires the scoped "
|
||||||
|
"Cloudflare token to drive DNS-01, so this check would otherwise be vacuous")
|
||||||
|
# Keyed by (namespace, name): a same-named Secret in ANOTHER namespace is NOT this credential.
|
||||||
|
# (Verified: keying by name alone let the external-dns copy of `cloudflare-api-token` satisfy
|
||||||
|
# the cert-manager reference, so deleting cert-manager's own copy passed — a silent no-op.)
|
||||||
|
SECRET_DOCS = {(ns_of(d, CM_NS), name_of(d)): (rel, d)
|
||||||
|
for rel, d in DOCS if d.get("kind") == "Secret"}
|
||||||
|
for iname, sns, sname, field in solver_creds:
|
||||||
|
hit = SECRET_DOCS.get((sns, sname))
|
||||||
|
check(hit is not None,
|
||||||
|
f"{iname}: dns01 {field} references Secret {sname!r}, which no tracked manifest "
|
||||||
|
f"delivers into namespace {sns!r} (the issuer's secret-resolution namespace) — the "
|
||||||
|
"credential would be created by hand in-cluster (R-TLS-02)")
|
||||||
|
if hit is None:
|
||||||
|
continue
|
||||||
|
srel, sdoc = hit
|
||||||
|
check("sops" in sdoc,
|
||||||
|
f"{srel}: Secret {sname} is the DNS-01 credential but is not SOPS-encrypted (R-SEC-01)")
|
||||||
|
check(delivered(srel),
|
||||||
|
f"{srel}: Secret {sname} is not rendered by any Flux Kustomization path — Flux would "
|
||||||
|
"never deliver it, so issuance would wait on a manual kubectl apply")
|
||||||
|
|
||||||
|
# ------------------------------- (7) automatic CertificateRequest approval is intact
|
||||||
|
check(len(cm_releases) == 1,
|
||||||
|
f"expected exactly one cert-manager HelmRelease, found {[r for r, _ in cm_releases]} — "
|
||||||
|
"the controller that issues and renews must itself be GitOps-managed")
|
||||||
|
for rel, hr in cm_releases:
|
||||||
|
values = ((hr.get("spec") or {}).get("values") or {})
|
||||||
|
check(values.get("disableAutoApproval") in (None, False),
|
||||||
|
f"{rel}: cert-manager values set disableAutoApproval={values.get('disableAutoApproval')!r} "
|
||||||
|
"— every CertificateRequest would then wait for an approver, which is a manual step")
|
||||||
|
signers = values.get("approveSignerNames")
|
||||||
|
if signers is not None:
|
||||||
|
pats = [str(s) for s in (signers if isinstance(signers, list) else [signers])]
|
||||||
|
for iname in sorted(issuers_verified):
|
||||||
|
ok = any(p in ("*", "*/*") or "clusterissuers.cert-manager.io" in p and
|
||||||
|
(p.endswith("/*") or p.endswith(f"/{iname}")) for p in pats)
|
||||||
|
check(ok,
|
||||||
|
f"{rel}: approveSignerNames={pats} does not cover ClusterIssuer {iname} — "
|
||||||
|
"requests for it would never be auto-approved")
|
||||||
|
# The chart's own CRD install must be on, else the CRs below cannot even be applied.
|
||||||
|
check((values.get("crds") or {}).get("enabled") is True,
|
||||||
|
f"{rel}: cert-manager values do not enable CRD installation — the Certificate CRD "
|
||||||
|
"would have to be installed by hand")
|
||||||
|
|
||||||
|
# ------------------------------- (8) the whole chain is continuously reconciled from git
|
||||||
|
check(len(FLUX_PATHS) > 0 and len(RENDERED) > 20,
|
||||||
|
f"the kustomize resource graph resolved {len(RENDERED)} manifest(s) from {len(FLUX_PATHS)} "
|
||||||
|
"Flux Kustomization path(s) — too few to be a real graph, so (8) would prove nothing")
|
||||||
|
for rel, _ in cm_releases + CERTS + ISSUERS:
|
||||||
|
check(delivered(rel),
|
||||||
|
f"{rel} is not rendered by any Flux Kustomization path — nothing reconciles it, so a "
|
||||||
|
"deleted Certificate/issuer/controller would stay deleted until a human re-applied it")
|
||||||
|
|
||||||
|
# --------------------------------------------- (9) renewal is not defeated arithmetically
|
||||||
|
def to_seconds(v) -> int | None:
|
||||||
|
m = re.fullmatch(r"\s*(\d+)\s*([smh])\s*", str(v))
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
return int(m.group(1)) * {"s": 1, "m": 60, "h": 3600}[m.group(2)]
|
||||||
|
|
||||||
|
|
||||||
|
renew_pairs = 0
|
||||||
|
for rel, cert in CERTS:
|
||||||
|
spec = cert.get("spec") or {}
|
||||||
|
dur, rb = to_seconds(spec.get("duration")), to_seconds(spec.get("renewBefore"))
|
||||||
|
if dur is None or rb is None:
|
||||||
|
continue
|
||||||
|
renew_pairs += 1
|
||||||
|
check(rb < dur,
|
||||||
|
f"{rel}: Certificate {name_of(cert)} sets renewBefore={spec.get('renewBefore')} >= "
|
||||||
|
f"duration={spec.get('duration')} — the renewal instant never arrives")
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- verdict
|
||||||
|
# NO-OP GUARD: this repo has been bitten by checks that degrade to a successful no-op.
|
||||||
|
# The floor below is a count of assertions that MUST have run for the verdict to mean
|
||||||
|
# anything; it is not a style rule.
|
||||||
|
check(CHECKS >= 20,
|
||||||
|
f"only {CHECKS} assertions ran — the check degenerated to a near-no-op and its pass "
|
||||||
|
"would be meaningless")
|
||||||
|
|
||||||
|
if FAILURES:
|
||||||
|
print(f"CHECK FAIL (tls-lifecycle-automated): {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: tls-lifecycle-automated — {CHECKS} assertions; {len(CERTS)} Certificate(s), "
|
||||||
|
f"{len(ISSUERS)} issuer(s), {len(issuers_verified)} issuer(s) reached from a Certificate "
|
||||||
|
f"across every environment, {listeners_checked} terminating listener(s), "
|
||||||
|
f"{len(solver_creds)} dns01 credential ref(s), {renew_pairs} duration/renewBefore pair(s). "
|
||||||
|
"R-TLS-02 'without manual steps' clause only — issued/renewed/revoked are RUNTIME verbs "
|
||||||
|
"(see l2-behavioral/cert-lifecycle-automated; 'revoked' is NOT proven at any layer).")
|
||||||
|
PY
|
||||||
@@ -68,7 +68,7 @@ spec:
|
|||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --provider=oidc
|
- --provider=oidc
|
||||||
- --oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab
|
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
|
||||||
- --upstream=http://127.0.0.1:8080
|
- --upstream=http://127.0.0.1:8080
|
||||||
- --email-domain=*
|
- --email-domain=*
|
||||||
- --scope=openid email profile
|
- --scope=openid email profile
|
||||||
@@ -76,8 +76,8 @@ spec:
|
|||||||
- --allowed-group=platform-admins
|
- --allowed-group=platform-admins
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://paas-demo.home.keithjackson.dev/oauth2/callback
|
- --redirect-url=https://paas-demo.home.keithjackson.dev/oauth2/callback
|
||||||
- --cookie-domain=.home.keithjackson.dev
|
- --cookie-domain=.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.home.keithjackson.dev
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --pass-access-token=true
|
- --pass-access-token=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
|
|||||||
+3
-3
@@ -91,7 +91,7 @@ spec:
|
|||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --provider=oidc
|
- --provider=oidc
|
||||||
- --oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab
|
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
|
||||||
- --upstream=http://127.0.0.1:8989
|
- --upstream=http://127.0.0.1:8989
|
||||||
- --email-domain=*
|
- --email-domain=*
|
||||||
- --scope=openid email profile
|
- --scope=openid email profile
|
||||||
@@ -99,8 +99,8 @@ spec:
|
|||||||
- --allowed-group=media-admins
|
- --allowed-group=media-admins
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://sonarr.home.keithjackson.dev/oauth2/callback
|
- --redirect-url=https://sonarr.home.keithjackson.dev/oauth2/callback
|
||||||
- --cookie-domain=.home.keithjackson.dev
|
- --cookie-domain=.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.home.keithjackson.dev
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --pass-access-token=true
|
- --pass-access-token=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ spec:
|
|||||||
- --http-address=0.0.0.0:4180
|
- --http-address=0.0.0.0:4180
|
||||||
- --reverse-proxy=true
|
- --reverse-proxy=true
|
||||||
- --provider=oidc
|
- --provider=oidc
|
||||||
- --oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab
|
- --oidc-issuer-url=https://keycloak.${BASE_DOMAIN}/realms/homelab
|
||||||
- --upstream=http://127.0.0.1:8989
|
- --upstream=http://127.0.0.1:8989
|
||||||
- --email-domain=*
|
- --email-domain=*
|
||||||
- --scope=openid email profile
|
- --scope=openid email profile
|
||||||
@@ -99,8 +99,8 @@ spec:
|
|||||||
- --allowed-group=media-admins
|
- --allowed-group=media-admins
|
||||||
- --skip-provider-button=true
|
- --skip-provider-button=true
|
||||||
- --redirect-url=https://sonarr.home.keithjackson.dev/oauth2/callback
|
- --redirect-url=https://sonarr.home.keithjackson.dev/oauth2/callback
|
||||||
- --cookie-domain=.home.keithjackson.dev
|
- --cookie-domain=.${BASE_DOMAIN}
|
||||||
- --whitelist-domain=.home.keithjackson.dev
|
- --whitelist-domain=.${BASE_DOMAIN}
|
||||||
- --cookie-secure=true
|
- --cookie-secure=true
|
||||||
- --pass-access-token=true
|
- --pass-access-token=true
|
||||||
- --set-xauthrequest=true
|
- --set-xauthrequest=true
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L2 BEHAVIORAL — certificate lifecycle is automatic (R-TLS-02)
|
||||||
|
# [RUNNABLE / NOT YET EXECUTED — see STATUS]
|
||||||
|
# Catalog: docs/14 "Trusted TLS (R-TLS-01/02)"
|
||||||
|
# Spec: docs/00 R-TLS-02 — "Certificates MUST be automatically **issued, renewed,
|
||||||
|
# and revoked** without manual steps."
|
||||||
|
# docs/08 §"Renewal & revocation (R-TLS-02)" — "cert-manager renews well
|
||||||
|
# before expiry automatically; no human action." / "Revocation: removing/
|
||||||
|
# rotating a certificate is a declarative change; cert-manager handles
|
||||||
|
# issuance of replacements."
|
||||||
|
# docs/08 acceptance gate — "Certificates renew automatically ahead of expiry
|
||||||
|
# with no manual step (**observed across a renewal**)."
|
||||||
|
# Impl: platform/cert-manager/**, platform/gateway/wildcard-certificate.yaml.
|
||||||
|
# Sibling: tests/l0-static/tls-lifecycle-automated proves the "without manual steps"
|
||||||
|
# clause statically (in the merge gate). This test observes the three VERBS.
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC (R-ENV-02): NOTHING about either environment is written down. The
|
||||||
|
# serving certificate is DISCOVERED — find the Gateway whose listener terminates TLS, follow
|
||||||
|
# its certificateRefs Secret, and read that Secret's `cert-manager.io/certificate-name`
|
||||||
|
# annotation to reach the Certificate. No hostname, no namespace list, no issuer name, no
|
||||||
|
# address: dev proves dev's cert and prod proves prod's from the same authored assertions.
|
||||||
|
#
|
||||||
|
# GIVEN cert-manager reconciled by Flux and a Gateway terminating TLS,
|
||||||
|
# WHEN the serving Certificate's provenance and renewal schedule are read, and a NEW
|
||||||
|
# Certificate is then requested, left to renew itself, and removed,
|
||||||
|
# THEN 1. the serving credential was produced by the CONTROLLER, not a human: its
|
||||||
|
# Secret carries cert-manager's certificate-name annotation, its
|
||||||
|
# CertificateRequest was submitted by cert-manager's own ServiceAccount and
|
||||||
|
# was APPROVED automatically, and its issuer is a Ready ACME ClusterIssuer;
|
||||||
|
# 2. an unattended RENEWAL IS SCHEDULED: status.renewalTime is in the future and
|
||||||
|
# strictly before status.notAfter, so no human is in the renewal path;
|
||||||
|
# 3. ISSUED — a newly declared Certificate becomes Ready and its Secret appears
|
||||||
|
# with certificate material, with no step between declaring and having it;
|
||||||
|
# 4. RENEWED — that certificate, given a renewal window that opens one minute
|
||||||
|
# after issuance, REISSUES ITSELF at least twice (status.revision >= 3) and
|
||||||
|
# the serving bytes actually change, with no human action. This is docs/08's
|
||||||
|
# "observed across a renewal", fast-forwarded;
|
||||||
|
# 5. REMOVED/REVOKED — deleting the Certificate withdraws its serving material.
|
||||||
|
#
|
||||||
|
# WHY A HERMETIC SELF-SIGNED ISSUER FOR STEPS 3-5, AND WHAT THAT COSTS
|
||||||
|
# -------------------------------------------------------------------
|
||||||
|
# Steps 3-5 need a certificate lifecycle that completes in minutes and repeats on every
|
||||||
|
# run. Driving that through the real ACME issuer would fire a public Let's Encrypt order
|
||||||
|
# (and a Cloudflare _acme-challenge write) per conformance run, burning issuance rate
|
||||||
|
# limits for evidence that is not about ACME: cert-manager's renewal scheduler and
|
||||||
|
# ownership logic are issuer-independent. So steps 3-5 use a `selfSigned` Issuer created
|
||||||
|
# in this test's OWN namespace, and the ACME/DNS-01 path is proven elsewhere — step 1-2
|
||||||
|
# read the REAL wildcard certificate the Gateway serves, and l2-behavioral/trusted-tls
|
||||||
|
# proves a publicly-trusted LE certificate is actually served (R-TLS-01/03).
|
||||||
|
# STATED PLAINLY: steps 3-5 do NOT prove the ACME order path renews; they prove the
|
||||||
|
# CONTROLLER's issue/renew/withdraw automation runs unattended in this cluster, while
|
||||||
|
# steps 1-2 prove the real serving certificate is on exactly that automated path.
|
||||||
|
#
|
||||||
|
# STATUS: RUNNABLE, NOT YET EXECUTED. Steps 1-2 are read-only. Steps 3-5 CREATE an
|
||||||
|
# Issuer/Certificate (and DELETE the Certificate they created) inside chainsaw's
|
||||||
|
# per-test namespace — the same class of mutation every other L2 test here performs,
|
||||||
|
# self-cleaning via namespace teardown, touching no platform object. WS-TEST authored
|
||||||
|
# them against live dev/prod read-only inspection under an explicit instruction not to
|
||||||
|
# mutate either cluster, so THE MUTATING STEPS HAVE NOT BEEN RUN. Nothing about the
|
||||||
|
# suite gates on them: ci/conformance.sh only runs L2/L3 when CLUSTER_AVAILABLE=true.
|
||||||
|
#
|
||||||
|
# EXPECTED FAILURE, DELIBERATELY NOT SOFTENED (R-TEST-06)
|
||||||
|
# ------------------------------------------------------
|
||||||
|
# Step 5 WILL FAIL as the platform is authored today. cert-manager only garbage-collects
|
||||||
|
# a Certificate's Secret when the controller runs with `enableCertificateOwnerRef: true`;
|
||||||
|
# platform/cert-manager/helmrelease.yaml does not set it (chart default false), and the
|
||||||
|
# live wildcard Secret on both clusters has NO ownerReferences (verified read-only,
|
||||||
|
# 2026-09-05). So a removed Certificate leaves its credential in place, and "revoked
|
||||||
|
# without manual steps" does not hold. That is a finding about the platform, not a
|
||||||
|
# defect in this test: the assertion stays as the requirement reads and is escalated to
|
||||||
|
# the PM. (ACME revocation proper is not implemented by cert-manager at all.)
|
||||||
|
#
|
||||||
|
# KNOWN, DELIBERATE GAPS (this test claims none of these):
|
||||||
|
# * Not proven: that the CA would refuse a revoked certificate — there is no
|
||||||
|
# revocation request to make, so nothing observable exists to assert.
|
||||||
|
# * Not proven: renewal of the REAL wildcard cert (its window opens 30 days before a
|
||||||
|
# 90-day expiry; a conformance run cannot wait, and forcing it would rotate the
|
||||||
|
# platform's serving credential). Step 2 asserts the schedule; step 4 asserts the
|
||||||
|
# mechanism that will act on it.
|
||||||
|
# * Not proven: certificate expiry ALERTING — docs/08 delegates that to R-OBS-04
|
||||||
|
# (l3-fault-injection/alerting).
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: cert-lifecycle-automated
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-TLS-02: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-TLS-02"
|
||||||
|
conformance.home.k3s/layer: "L2"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Given cert-manager and a TLS-terminating Gateway, when the serving certificate's
|
||||||
|
provenance and renewal schedule are read and a new certificate is requested, left
|
||||||
|
to renew, and removed, then issuance, renewal and withdrawal all happen with no
|
||||||
|
human step — the serving cert was controller-issued and auto-approved, its
|
||||||
|
renewal is scheduled ahead of expiry, and a declared certificate issues and
|
||||||
|
reissues itself unattended.
|
||||||
|
spec:
|
||||||
|
timeouts:
|
||||||
|
apply: 60s
|
||||||
|
assert: 3m
|
||||||
|
exec: 10m
|
||||||
|
steps:
|
||||||
|
# ---------------------------------------------------------------- READ-ONLY HALF
|
||||||
|
- name: given-cert-manager-is-reconciled-and-healthy
|
||||||
|
description: >-
|
||||||
|
The controller that issues and renews must be running, and must have been
|
||||||
|
installed by Flux rather than by hand — otherwise "automatic" has no engine.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
set -u
|
||||||
|
# Discover cert-manager's Deployments by chart label, in whatever namespace
|
||||||
|
# the release lives (nothing about the environment is assumed).
|
||||||
|
sel='app.kubernetes.io/instance=cert-manager'
|
||||||
|
rows="$(kubectl get deploy -A -l "$sel" \
|
||||||
|
-o custom-columns='NS:.metadata.namespace,N:.metadata.name,RDY:.status.readyReplicas' \
|
||||||
|
--no-headers 2>/dev/null || true)"
|
||||||
|
echo "$rows"
|
||||||
|
[ -n "$rows" ] || { echo "FAIL: no cert-manager Deployment found ($sel)"; exit 1; }
|
||||||
|
n=0
|
||||||
|
echo "$rows" | while read -r ns name rdy; do
|
||||||
|
[ "${rdy:-0}" -ge 1 ] || { echo "FAIL: $ns/$name has no ready replica"; exit 1; }
|
||||||
|
done || exit 1
|
||||||
|
n="$(echo "$rows" | wc -l)"
|
||||||
|
# controller + webhook + cainjector: without the webhook a Certificate is
|
||||||
|
# rejected, without cainjector the webhook has no CA — all three or nothing.
|
||||||
|
[ "$n" -ge 3 ] || { echo "FAIL: expected >=3 cert-manager Deployments, saw $n"; exit 1; }
|
||||||
|
# GitOps-managed, not hand-installed: Flux labels what it applies.
|
||||||
|
ns="$(echo "$rows" | head -1 | awk '{print $1}')"
|
||||||
|
lbl="$(kubectl get deploy -n "$ns" -l "$sel" \
|
||||||
|
-o jsonpath='{.items[0].metadata.labels.helm\.toolkit\.fluxcd\.io/name}' 2>/dev/null || true)"
|
||||||
|
[ -n "$lbl" ] || { echo "FAIL: cert-manager is not owned by a Flux HelmRelease"; exit 1; }
|
||||||
|
echo "CERT_MANAGER_HEALTHY_OK ($n deployments, flux HelmRelease $lbl)"
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'CERT_MANAGER_HEALTHY_OK')): true
|
||||||
|
|
||||||
|
- name: then-the-serving-certificate-was-issued-by-the-controller
|
||||||
|
description: >-
|
||||||
|
Follow the Gateway's terminating listener to the Secret it serves, and prove
|
||||||
|
that Secret's provenance is cert-manager — annotation, CertificateRequest
|
||||||
|
requester, automatic approval, and a Ready ACME issuer.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
set -u
|
||||||
|
# --- discover the serving Secret from the Gateway itself -------------------
|
||||||
|
gw="$(kubectl get gateway -A \
|
||||||
|
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' \
|
||||||
|
2>/dev/null | head -1)"
|
||||||
|
[ -n "$gw" ] || { echo "FAIL: no Gateway found — nothing terminates TLS"; exit 1; }
|
||||||
|
gns="${gw% *}"; gname="${gw#* }"
|
||||||
|
sec="$(kubectl -n "$gns" get gateway "$gname" \
|
||||||
|
-o jsonpath='{.spec.listeners[?(@.tls.mode=="Terminate")].tls.certificateRefs[0].name}' \
|
||||||
|
2>/dev/null | awk '{print $1}')"
|
||||||
|
[ -n "$sec" ] || { echo "FAIL: Gateway $gns/$gname terminates no TLS listener"; exit 1; }
|
||||||
|
echo "gateway=$gns/$gname serving-secret=$sec"
|
||||||
|
|
||||||
|
# --- provenance (1): only the controller writes this annotation ------------
|
||||||
|
cert="$(kubectl -n "$gns" get secret "$sec" \
|
||||||
|
-o jsonpath='{.metadata.annotations.cert-manager\.io/certificate-name}' 2>/dev/null || true)"
|
||||||
|
[ -n "$cert" ] || { echo "FAIL: Secret $gns/$sec carries no cert-manager.io/certificate-name annotation — it was not produced by cert-manager"; exit 1; }
|
||||||
|
crt="$(kubectl -n "$gns" get secret "$sec" -o jsonpath='{.data.tls\.crt}' 2>/dev/null || true)"
|
||||||
|
[ -n "$crt" ] || { echo "FAIL: Secret $gns/$sec holds no tls.crt"; exit 1; }
|
||||||
|
|
||||||
|
# --- provenance (2): the Certificate is Ready and has a revision -----------
|
||||||
|
ready="$(kubectl -n "$gns" get certificate "$cert" \
|
||||||
|
-o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
|
||||||
|
rev="$(kubectl -n "$gns" get certificate "$cert" -o jsonpath='{.status.revision}' 2>/dev/null || true)"
|
||||||
|
echo "certificate=$gns/$cert ready=$ready revision=$rev"
|
||||||
|
[ "$ready" = "True" ] || { echo "FAIL: Certificate $cert is not Ready"; exit 1; }
|
||||||
|
[ -n "$rev" ] && [ "$rev" -ge 1 ] || { echo "FAIL: Certificate $cert has no issued revision"; exit 1; }
|
||||||
|
|
||||||
|
# --- provenance (3): the request was made BY THE CONTROLLER and auto-approved
|
||||||
|
# spec.username is the identity that created the CertificateRequest. A human
|
||||||
|
# operator would appear as a user/OIDC identity, not a ServiceAccount.
|
||||||
|
cr="$(kubectl -n "$gns" get certificaterequest \
|
||||||
|
-o jsonpath="{range .items[?(@.metadata.annotations.cert-manager\.io/certificate-name=='${cert}')]}{.metadata.name}{' '}{.spec.username}{'\n'}{end}" \
|
||||||
|
2>/dev/null | tail -1)"
|
||||||
|
[ -n "$cr" ] || { echo "FAIL: no CertificateRequest for $cert — cannot show who requested it"; exit 1; }
|
||||||
|
crname="${cr% *}"; requester="${cr#* }"
|
||||||
|
echo "certificaterequest=$crname requester=$requester"
|
||||||
|
case "$requester" in
|
||||||
|
system:serviceaccount:*) : ;;
|
||||||
|
*) echo "FAIL: CertificateRequest $crname was submitted by '$requester', not a controller ServiceAccount"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
appr="$(kubectl -n "$gns" get certificaterequest "$crname" \
|
||||||
|
-o jsonpath='{.status.conditions[?(@.type=="Approved")].status}' 2>/dev/null || true)"
|
||||||
|
[ "$appr" = "True" ] || { echo "FAIL: CertificateRequest $crname is not Approved=True (approval would be a manual step)"; exit 1; }
|
||||||
|
|
||||||
|
# --- provenance (4): an automated ACME issuer, and it is Ready -------------
|
||||||
|
ikind="$(kubectl -n "$gns" get certificate "$cert" -o jsonpath='{.spec.issuerRef.kind}')"
|
||||||
|
iname="$(kubectl -n "$gns" get certificate "$cert" -o jsonpath='{.spec.issuerRef.name}')"
|
||||||
|
echo "issuer=$ikind/$iname"
|
||||||
|
if [ "$ikind" = "ClusterIssuer" ]; then
|
||||||
|
iready="$(kubectl get clusterissuer "$iname" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
|
||||||
|
iacme="$(kubectl get clusterissuer "$iname" -o jsonpath='{.spec.acme.server}' 2>/dev/null || true)"
|
||||||
|
else
|
||||||
|
iready="$(kubectl -n "$gns" get issuer "$iname" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
|
||||||
|
iacme="$(kubectl -n "$gns" get issuer "$iname" -o jsonpath='{.spec.acme.server}' 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
[ "$iready" = "True" ] || { echo "FAIL: issuer $iname is not Ready — renewal would need a human to fix it"; exit 1; }
|
||||||
|
[ -n "$iacme" ] || { echo "FAIL: issuer $iname is not an ACME issuer"; exit 1; }
|
||||||
|
echo "SERVING_CERT_CONTROLLER_ISSUED_OK"
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'SERVING_CERT_CONTROLLER_ISSUED_OK')): true
|
||||||
|
|
||||||
|
- name: then-an-unattended-renewal-is-already-scheduled
|
||||||
|
description: >-
|
||||||
|
cert-manager writes status.renewalTime only after computing when it will renew.
|
||||||
|
Asserting it is in the future and before expiry proves the renewal is scheduled
|
||||||
|
and that nothing is waiting on a human (docs/08 "renews well before expiry").
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
set -u
|
||||||
|
gw="$(kubectl get gateway -A \
|
||||||
|
-o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' | head -1)"
|
||||||
|
gns="${gw% *}"; gname="${gw#* }"
|
||||||
|
sec="$(kubectl -n "$gns" get gateway "$gname" \
|
||||||
|
-o jsonpath='{.spec.listeners[?(@.tls.mode=="Terminate")].tls.certificateRefs[0].name}' | awk '{print $1}')"
|
||||||
|
cert="$(kubectl -n "$gns" get secret "$sec" \
|
||||||
|
-o jsonpath='{.metadata.annotations.cert-manager\.io/certificate-name}')"
|
||||||
|
renew="$(kubectl -n "$gns" get certificate "$cert" -o jsonpath='{.status.renewalTime}')"
|
||||||
|
after="$(kubectl -n "$gns" get certificate "$cert" -o jsonpath='{.status.notAfter}')"
|
||||||
|
[ -n "$renew" ] || { echo "FAIL: Certificate $cert has no status.renewalTime — no renewal is scheduled"; exit 1; }
|
||||||
|
[ -n "$after" ] || { echo "FAIL: Certificate $cert has no status.notAfter"; exit 1; }
|
||||||
|
now_s="$(date -u +%s)"
|
||||||
|
r_s="$(date -u -d "$renew" +%s)"
|
||||||
|
a_s="$(date -u -d "$after" +%s)"
|
||||||
|
echo "now=$(date -u -d "@$now_s" +%FT%TZ) renewalTime=$renew notAfter=$after"
|
||||||
|
echo "renewal is in $(( (r_s - now_s) / 86400 ))d; expiry in $(( (a_s - now_s) / 86400 ))d"
|
||||||
|
[ "$r_s" -gt "$now_s" ] || { echo "FAIL: renewalTime is in the PAST — the scheduled renewal did not happen"; exit 1; }
|
||||||
|
[ "$r_s" -lt "$a_s" ] || { echo "FAIL: renewalTime is not before notAfter — renewal would come too late"; exit 1; }
|
||||||
|
echo "RENEWAL_SCHEDULED_OK"
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'RENEWAL_SCHEDULED_OK')): true
|
||||||
|
|
||||||
|
# ------------------------------------------------- MUTATING HALF (hermetic namespace)
|
||||||
|
- name: when-a-certificate-is-declared-then-it-is-issued-with-no-human-step
|
||||||
|
description: >-
|
||||||
|
Declare an Issuer and a Certificate in this test's own namespace, with a renewal
|
||||||
|
window that opens one minute after issuance. Nothing else is done to them for the
|
||||||
|
rest of the test — every later transition is the controller acting on its own.
|
||||||
|
try:
|
||||||
|
- apply:
|
||||||
|
resource:
|
||||||
|
# selfSigned: no external CA, no rate limit, no DNS write. See the header for
|
||||||
|
# what this proves (controller automation) and what it does not (the ACME path).
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Issuer
|
||||||
|
metadata:
|
||||||
|
name: conformance-lifecycle
|
||||||
|
spec:
|
||||||
|
selfSigned: {}
|
||||||
|
- apply:
|
||||||
|
resource:
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: conformance-lifecycle
|
||||||
|
spec:
|
||||||
|
secretName: conformance-lifecycle-tls
|
||||||
|
# 1h is cert-manager's minimum duration; renewing 59m before expiry puts the
|
||||||
|
# renewal instant ~1 minute after each issuance, so an unattended renewal is
|
||||||
|
# observable inside a test run instead of 30 days from now.
|
||||||
|
duration: 1h
|
||||||
|
renewBefore: 3540s
|
||||||
|
commonName: conformance-lifecycle.invalid
|
||||||
|
dnsNames:
|
||||||
|
- conformance-lifecycle.invalid
|
||||||
|
privateKey:
|
||||||
|
rotationPolicy: Always
|
||||||
|
issuerRef:
|
||||||
|
kind: Issuer
|
||||||
|
name: conformance-lifecycle
|
||||||
|
- assert:
|
||||||
|
resource:
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
metadata:
|
||||||
|
name: conformance-lifecycle
|
||||||
|
status:
|
||||||
|
conditions:
|
||||||
|
- type: Ready
|
||||||
|
status: "True"
|
||||||
|
revision: 1
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
set -u
|
||||||
|
crt="$(kubectl -n "$NAMESPACE" get secret conformance-lifecycle-tls \
|
||||||
|
-o jsonpath='{.data.tls\.crt}' 2>/dev/null || true)"
|
||||||
|
key="$(kubectl -n "$NAMESPACE" get secret conformance-lifecycle-tls \
|
||||||
|
-o jsonpath='{.data.tls\.key}' 2>/dev/null || true)"
|
||||||
|
[ -n "$crt" ] && [ -n "$key" ] || { echo "FAIL: no certificate material was issued into conformance-lifecycle-tls"; exit 1; }
|
||||||
|
# Record the serving bytes so the renewal step can prove they CHANGED.
|
||||||
|
echo "$crt" | cksum > /tmp/conformance-cert-r1.cksum
|
||||||
|
echo "revision-1 cert fingerprint: $(cat /tmp/conformance-cert-r1.cksum)"
|
||||||
|
echo "CERT_ISSUED_OK"
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'CERT_ISSUED_OK')): true
|
||||||
|
|
||||||
|
- name: then-it-renews-itself-before-expiry-with-no-human-step
|
||||||
|
description: >-
|
||||||
|
Nothing touches the Certificate here. Poll until the controller has reissued it
|
||||||
|
at least twice on its own and the serving bytes differ from revision 1 — docs/08's
|
||||||
|
"observed across a renewal", and every request auto-approved.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
set -u
|
||||||
|
want=3 # revision 1 = issuance; >=3 means TWO unattended renewals
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
rev="$(kubectl -n "$NAMESPACE" get certificate conformance-lifecycle \
|
||||||
|
-o jsonpath='{.status.revision}' 2>/dev/null || echo 0)"
|
||||||
|
echo "poll $i: revision=${rev:-0}"
|
||||||
|
[ "${rev:-0}" -ge "$want" ] && break
|
||||||
|
sleep 20
|
||||||
|
done
|
||||||
|
rev="$(kubectl -n "$NAMESPACE" get certificate conformance-lifecycle \
|
||||||
|
-o jsonpath='{.status.revision}' 2>/dev/null || echo 0)"
|
||||||
|
[ "${rev:-0}" -ge "$want" ] || { echo "FAIL: revision reached only ${rev:-0} — cert-manager did not renew unattended"; exit 1; }
|
||||||
|
|
||||||
|
# The serving material must actually have rotated, not just the revision count.
|
||||||
|
now="$(kubectl -n "$NAMESPACE" get secret conformance-lifecycle-tls \
|
||||||
|
-o jsonpath='{.data.tls\.crt}' | cksum)"
|
||||||
|
was="$(cat /tmp/conformance-cert-r1.cksum)"
|
||||||
|
echo "r1=$was now=$now"
|
||||||
|
[ "$now" != "$was" ] || { echo "FAIL: the Secret still holds the revision-1 certificate — nothing was actually reissued"; exit 1; }
|
||||||
|
|
||||||
|
# Every renewal request was approved by the controller, never by a person.
|
||||||
|
bad=0
|
||||||
|
for cr in $(kubectl -n "$NAMESPACE" get certificaterequest -o name 2>/dev/null); do
|
||||||
|
st="$(kubectl -n "$NAMESPACE" get "$cr" \
|
||||||
|
-o jsonpath='{.status.conditions[?(@.type=="Approved")].status}' 2>/dev/null || true)"
|
||||||
|
who="$(kubectl -n "$NAMESPACE" get "$cr" -o jsonpath='{.spec.username}' 2>/dev/null || true)"
|
||||||
|
echo " $cr approved=$st requester=$who"
|
||||||
|
[ "$st" = "True" ] || bad=1
|
||||||
|
case "$who" in system:serviceaccount:*) : ;; *) bad=1 ;; esac
|
||||||
|
done
|
||||||
|
[ "$bad" = 0 ] || { echo "FAIL: a CertificateRequest was not auto-approved, or was not submitted by a controller"; exit 1; }
|
||||||
|
echo "CERT_RENEWED_UNATTENDED_OK (revision=$rev)"
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'CERT_RENEWED_UNATTENDED_OK')): true
|
||||||
|
|
||||||
|
- name: then-removing-the-certificate-withdraws-the-serving-material
|
||||||
|
description: >-
|
||||||
|
docs/08 reads revocation as "removing/rotating a certificate is a declarative
|
||||||
|
change". The declarative change is the deletion; withdrawal of the credential
|
||||||
|
must follow with no manual cleanup. EXPECTED TO FAIL TODAY — cert-manager needs
|
||||||
|
`enableCertificateOwnerRef: true` for the Secret to be garbage-collected, and the
|
||||||
|
tracked HelmRelease does not set it. Kept as the requirement reads (R-TEST-06).
|
||||||
|
try:
|
||||||
|
- delete:
|
||||||
|
ref:
|
||||||
|
apiVersion: cert-manager.io/v1
|
||||||
|
kind: Certificate
|
||||||
|
name: conformance-lifecycle
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
set -u
|
||||||
|
for i in $(seq 1 15); do
|
||||||
|
if ! kubectl -n "$NAMESPACE" get secret conformance-lifecycle-tls >/dev/null 2>&1; then
|
||||||
|
echo "CERT_WITHDRAWN_OK (serving material gone after $((i * 4))s)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 4
|
||||||
|
done
|
||||||
|
owners="$(kubectl -n "$NAMESPACE" get secret conformance-lifecycle-tls \
|
||||||
|
-o jsonpath='{.metadata.ownerReferences}' 2>/dev/null || true)"
|
||||||
|
echo "FAIL: the Certificate was deleted but Secret conformance-lifecycle-tls still"
|
||||||
|
echo " holds usable certificate material (ownerReferences=${owners:-none})."
|
||||||
|
echo " Removing a certificate therefore does NOT withdraw the credential, so"
|
||||||
|
echo " R-TLS-02's 'revoked ... without manual steps' does not hold. FIX:"
|
||||||
|
echo " set enableCertificateOwnerRef: true in platform/cert-manager values"
|
||||||
|
echo " (owner decision — WS-TEST does not weaken the assertion instead)."
|
||||||
|
exit 1
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'CERT_WITHDRAWN_OK')): true
|
||||||
|
finally:
|
||||||
|
# Namespace teardown removes both objects anyway; explicit so a leftover Secret
|
||||||
|
# from the expected failure above is never left holding key material.
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
kubectl -n "$NAMESPACE" delete secret conformance-lifecycle-tls --ignore-not-found --wait=false || true
|
||||||
|
kubectl -n "$NAMESPACE" delete certificate conformance-lifecycle --ignore-not-found --wait=false || true
|
||||||
|
kubectl -n "$NAMESPACE" delete issuer conformance-lifecycle --ignore-not-found --wait=false || true
|
||||||
|
rm -f /tmp/conformance-cert-r1.cksum || true
|
||||||
@@ -153,3 +153,20 @@ spec:
|
|||||||
finally:
|
finally:
|
||||||
- script:
|
- script:
|
||||||
content: 'echo "cleanup handled by chainsaw namespace teardown"'
|
content: 'echo "cleanup handled by chainsaw namespace teardown"'
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
# PROOF-OF-PASS SENTINEL (R-SEC-03). Every step above is an assert/Job-status gate, so
|
||||||
|
# chainsaw already fails the test before reaching this step if any of them fails — but a
|
||||||
|
# green Chainsaw JUnit carries NO stdout, and the L2/L3 tests in this suite can self-skip
|
||||||
|
# GREEN, so lib/record_run.py refuses to treat a bare `passed` as evidence and records
|
||||||
|
# `unknown-no-sentinel-declared` instead. Without a declared token this test could pass
|
||||||
|
# forever and NEVER become a recorded proof of R-NET-01 — the isolation half of docs/07's audit proof list
|
||||||
|
# — which is precisely what docs/07 asks for ("These proofs are part of the platform
|
||||||
|
# acceptance suite, not one-time checks"). This step exists only to emit that token, and it
|
||||||
|
# is reached only when everything above has passed. Asserted by
|
||||||
|
# tests/l0-static/auditability-proofs-producible.
|
||||||
|
- name: then-emit-the-proof-of-pass-sentinel
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: echo DEFAULT_DENY_PROVEN_OK
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'DEFAULT_DENY_PROVEN_OK')): true
|
||||||
@@ -3,11 +3,18 @@
|
|||||||
# Catalog: docs/14 "DNS/exposure (R-DNS-*)"
|
# Catalog: docs/14 "DNS/exposure (R-DNS-*)"
|
||||||
# Spec: docs/09 (ExternalDNS auto-manages records; split-horizon foundation)
|
# Spec: docs/09 (ExternalDNS auto-manages records; split-horizon foundation)
|
||||||
#
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC (R-ENV-02): the hostname is built from the ${BASE_DOMAIN} of the CLUSTER
|
||||||
|
# UNDER TEST (read from flux-system/cluster-vars via a test-level chainsaw binding), and the
|
||||||
|
# expected answer is the LB IP that THIS Service was actually assigned (read back from its own
|
||||||
|
# status) — so neither the name nor the address is authored per environment, and no run-time flag
|
||||||
|
# can be forgotten. Requesting a record under the cluster's own suffix is also what keeps the two
|
||||||
|
# environments from ever describing the same public name (the 2026-09-04 incident precondition).
|
||||||
|
#
|
||||||
# GIVEN a LoadBalancer Service annotated with an external-dns hostname,
|
# GIVEN a LoadBalancer Service annotated with an external-dns hostname,
|
||||||
# WHEN ExternalDNS reconciles (source=service, provider=cloudflare, policy=sync),
|
# WHEN ExternalDNS reconciles (source=service, provider=cloudflare, policy=sync),
|
||||||
# THEN a PUBLIC DNS record for that hostname is auto-created, pointing at the
|
# THEN a PUBLIC DNS record for that hostname is auto-created, pointing at the
|
||||||
# Service's Cilium-assigned LB IP (R-DNS-02). The record resolves to the
|
# Service's Cilium-assigned LB IP (R-DNS-02) — asserted as an EXACT match against that
|
||||||
# cluster LB IP (Cilium L2 `shared` pool 192.168.1.53-.60), i.e. clients reach the cluster endpoint.
|
# Service's own `status.loadBalancer.ingress[0].ip`, i.e. clients reach the cluster endpoint.
|
||||||
#
|
#
|
||||||
# CLEANUP: chainsaw deletes the per-test namespace (Service included) at teardown;
|
# CLEANUP: chainsaw deletes the per-test namespace (Service included) at teardown;
|
||||||
# ExternalDNS policy=sync then REMOVES the record from Cloudflare on its next pass.
|
# ExternalDNS policy=sync then REMOVES the record from Cloudflare on its next pass.
|
||||||
@@ -38,6 +45,13 @@ spec:
|
|||||||
apply: 90s
|
apply: 90s
|
||||||
assert: 5m
|
assert: 5m
|
||||||
exec: 6m
|
exec: 6m
|
||||||
|
# -- R-ENV-02: the environment's hostname suffix is READ FROM THE CLUSTER UNDER TEST.
|
||||||
|
# flux-system/cluster-vars is the ConfigMap Flux substitutes ${BASE_DOMAIN} from, so this
|
||||||
|
# binding is the same source of truth the platform is rendered from — dev asks dev, prod
|
||||||
|
# asks prod, and there is no per-environment fork and no flag to forget.
|
||||||
|
bindings:
|
||||||
|
- name: baseDomain
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.BASE_DOMAIN)
|
||||||
steps:
|
steps:
|
||||||
- name: given-a-service-requesting-a-dns-name
|
- name: given-a-service-requesting-a-dns-name
|
||||||
try:
|
try:
|
||||||
@@ -48,7 +62,8 @@ spec:
|
|||||||
metadata:
|
metadata:
|
||||||
name: dns-probe-svc
|
name: dns-probe-svc
|
||||||
annotations:
|
annotations:
|
||||||
external-dns.alpha.kubernetes.io/hostname: conformance-dns.home.keithjackson.dev
|
# conformance-dns.<this cluster's BASE_DOMAIN> — never a literal apex.
|
||||||
|
external-dns.alpha.kubernetes.io/hostname: (join('.', ['conformance-dns', $baseDomain]))
|
||||||
spec:
|
spec:
|
||||||
type: LoadBalancer
|
type: LoadBalancer
|
||||||
# No real backend needed — Cilium LB-IPAM assigns a VIP regardless, and
|
# No real backend needed — Cilium LB-IPAM assigns a VIP regardless, and
|
||||||
@@ -59,7 +74,27 @@ spec:
|
|||||||
- name: http
|
- name: http
|
||||||
port: 80
|
port: 80
|
||||||
targetPort: 80
|
targetPort: 80
|
||||||
|
# Wait for Cilium LB-IPAM to actually assign the VIP: it is the value ExternalDNS will
|
||||||
|
# publish and the value the probe below asserts EXACTLY. `|| ` + empty-array literal
|
||||||
|
# keeps the expression nil-safe while the address is still absent, so the assert POLLS
|
||||||
|
# (fails and retries) instead of erroring out on the first evaluation.
|
||||||
|
- assert:
|
||||||
|
resource:
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: dns-probe-svc
|
||||||
|
status:
|
||||||
|
(length(loadBalancer.ingress || `[]`)): 1
|
||||||
- name: then-externaldns-creates-the-public-record
|
- name: then-externaldns-creates-the-public-record
|
||||||
|
# Step-scoped binding: read back the LB IP THIS Service was assigned (the previous step's
|
||||||
|
# assert guarantees it exists by now — step bindings are evaluated when the step runs).
|
||||||
|
# Expecting that exact address is both stricter than the old hardcoded 192.168.1.53-.60
|
||||||
|
# range and correct in every environment: dev's pool is 192.168.1.53-.60, prod's is
|
||||||
|
# 192.168.2.20-.30, and neither is written down here.
|
||||||
|
bindings:
|
||||||
|
- name: lbIp
|
||||||
|
value: (x_k8s_get($client, 'v1', 'Service', $namespace, 'dns-probe-svc').status.loadBalancer.ingress[0].ip)
|
||||||
try:
|
try:
|
||||||
- apply:
|
- apply:
|
||||||
resource:
|
resource:
|
||||||
@@ -76,20 +111,27 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: probe
|
- name: probe
|
||||||
image: busybox:1.36.1
|
image: busybox:1.36.1
|
||||||
|
# Both values come from the cluster under test: the suffix from
|
||||||
|
# flux-system/cluster-vars, the address from this Service's own status.
|
||||||
|
env:
|
||||||
|
- name: BASE_DOMAIN
|
||||||
|
value: ($baseDomain)
|
||||||
|
- name: LB_IP
|
||||||
|
value: ($lbIp)
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
set -u
|
set -u
|
||||||
host="conformance-dns.home.keithjackson.dev"
|
host="conformance-dns.${BASE_DOMAIN}"
|
||||||
# Poll PUBLIC DNS (1.1.1.1) until the auto-created A record appears and
|
# Poll PUBLIC DNS (1.1.1.1) until the auto-created A record appears and
|
||||||
# points into the cluster LB range (Cilium L2 `shared` pool 192.168.1.53-.60). Proves ExternalDNS created it.
|
# points at THIS Service's assigned LB IP. Proves ExternalDNS created it.
|
||||||
for i in $(seq 1 28); do
|
for i in $(seq 1 28); do
|
||||||
ip="$(nslookup "$host" 1.1.1.1 2>/dev/null | grep -oE '192\.168\.1\.(5[3-9]|60)' | head -1)"
|
ip="$(nslookup "$host" 1.1.1.1 2>/dev/null | awk '/^Address: /{print $2}' | grep -Fx "${LB_IP}" | head -1)"
|
||||||
if [ -n "$ip" ]; then echo "RECORD_FOUND $host -> $ip"; exit 0; fi
|
if [ -n "$ip" ]; then echo "RECORD_FOUND $host -> $ip"; exit 0; fi
|
||||||
echo "poll $i: record not yet published"; sleep 10
|
echo "poll $i: record not yet published as ${LB_IP}"; sleep 10
|
||||||
done
|
done
|
||||||
echo "FAIL: ExternalDNS did not publish the record within timeout"; exit 1
|
echo "FAIL: ExternalDNS did not publish ${host} -> ${LB_IP} within timeout"; exit 1
|
||||||
- assert:
|
- assert:
|
||||||
resource:
|
resource:
|
||||||
apiVersion: batch/v1
|
apiVersion: batch/v1
|
||||||
|
|||||||
@@ -2,17 +2,24 @@
|
|||||||
# L2 BEHAVIORAL — split-horizon INTERNAL resolution (R-DNS-01 / R-DNS-03) [RUNNABLE]
|
# L2 BEHAVIORAL — split-horizon INTERNAL resolution (R-DNS-01 / R-DNS-03) [RUNNABLE]
|
||||||
# Catalog: docs/14 "DNS/exposure (R-DNS-*)"
|
# Catalog: docs/14 "DNS/exposure (R-DNS-*)"
|
||||||
# Spec: docs/09 "Split-horizon resolution (R-DNS-03)" — internal clients resolve
|
# Spec: docs/09 "Split-horizon resolution (R-DNS-03)" — internal clients resolve
|
||||||
# <svc>.home.keithjackson.dev to the INTERNAL Gateway VIP, not the public hairpin.
|
# <svc>.${BASE_DOMAIN} to the INTERNAL Gateway VIP, not the public hairpin.
|
||||||
# Impl: platform/coredns/coredns-custom.yaml (k3s CoreDNS `import *.server`; template plugin
|
# Impl: platform/coredns/coredns-custom.yaml (k3s CoreDNS `import *.server`; template plugin
|
||||||
# answers the whole home.keithjackson.dev zone with ${GATEWAY_VIP}).
|
# answers the whole ${BASE_DOMAIN} zone with ${GATEWAY_VIP}).
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC (R-ENV-02): this test asks the CLUSTER IT IS POINTED AT for its own
|
||||||
|
# ${BASE_DOMAIN} and ${GATEWAY_VIP} — the same flux-system/cluster-vars ConfigMap Flux
|
||||||
|
# substitutes from — via test-level chainsaw bindings (see `spec.bindings`). Nothing here is
|
||||||
|
# per-environment and no flag is passed at run time, so the suite cannot be run against the
|
||||||
|
# wrong expectation: dev asserts dev's zone/VIP, prod asserts prod's.
|
||||||
#
|
#
|
||||||
# GIVEN the split-horizon coredns-custom ConfigMap is reconciled into k3s CoreDNS,
|
# GIVEN the split-horizon coredns-custom ConfigMap is reconciled into k3s CoreDNS,
|
||||||
# WHEN an in-cluster pod resolves a platform hostname over its PUBLIC name
|
# WHEN an in-cluster pod resolves a platform hostname over its PUBLIC name
|
||||||
# (keycloak.home.keithjackson.dev) using the default cluster resolver (CoreDNS 10.43.0.10),
|
# (keycloak.${BASE_DOMAIN}) using the default cluster resolver (CoreDNS 10.43.0.10),
|
||||||
# THEN the answer is the in-cluster Gateway VIP (the Cilium L2 `shared` gateway pool 192.168.1.53-.60) — NOT a
|
# THEN the answer is EXACTLY that cluster's ${GATEWAY_VIP} (the address coredns-custom is
|
||||||
# public/off-cluster IP. This is what lets pod->public-hostname traffic hairpin through the
|
# templated with, and the `shared` Gateway's own status address) — NOT a public/off-cluster
|
||||||
# Gateway with the correct SNI (*.home.keithjackson.dev) so the wildcard cert validates
|
# IP. This is what lets pod->public-hostname traffic hairpin through the
|
||||||
# (R-DNS-01 "services reachable at <svc>.home..."; R-DNS-03 split-horizon).
|
# Gateway with the correct SNI (*.${BASE_DOMAIN}) so the wildcard cert validates
|
||||||
|
# (R-DNS-01 "services reachable at <svc>.${BASE_DOMAIN}"; R-DNS-03 split-horizon).
|
||||||
#
|
#
|
||||||
# Complements dns-exposure (R-DNS-02, the EXTERNAL/public-record side). Together they cover the
|
# Complements dns-exposure (R-DNS-02, the EXTERNAL/public-record side). Together they cover the
|
||||||
# split-horizon pair: external answer = public record (R-DNS-02); internal answer = Gateway VIP
|
# split-horizon pair: external answer = public record (R-DNS-02); internal answer = Gateway VIP
|
||||||
@@ -34,13 +41,23 @@ metadata:
|
|||||||
conformance.home.k3s/safety-critical: "false"
|
conformance.home.k3s/safety-critical: "false"
|
||||||
conformance.home.k3s/executable: "true"
|
conformance.home.k3s/executable: "true"
|
||||||
conformance.home.k3s/intent: >-
|
conformance.home.k3s/intent: >-
|
||||||
An in-cluster pod resolving keycloak.home.keithjackson.dev via cluster DNS gets the internal
|
An in-cluster pod resolving keycloak.${BASE_DOMAIN} via cluster DNS gets that cluster's
|
||||||
Gateway VIP (192.168.1.53-.60), not the public hairpin — split-horizon in-cluster resolution.
|
internal Gateway VIP, not the public hairpin — split-horizon in-cluster resolution.
|
||||||
spec:
|
spec:
|
||||||
timeouts:
|
timeouts:
|
||||||
apply: 60s
|
apply: 60s
|
||||||
assert: 2m
|
assert: 2m
|
||||||
exec: 2m
|
exec: 2m
|
||||||
|
# -- R-ENV-02: the environment's identity is READ FROM THE CLUSTER UNDER TEST, never authored
|
||||||
|
# here. flux-system/cluster-vars is the ConfigMap Flux itself substitutes from
|
||||||
|
# (clusters/base/cluster-vars.yaml + the dev/prod overlay patch), so these bindings are the
|
||||||
|
# same source of truth the platform is rendered from. Test-level => in scope for every step
|
||||||
|
# (unlike step `outputs`, which are step-scoped).
|
||||||
|
bindings:
|
||||||
|
- name: baseDomain
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.BASE_DOMAIN)
|
||||||
|
- name: gatewayVip
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.GATEWAY_VIP)
|
||||||
steps:
|
steps:
|
||||||
- name: then-cluster-dns-answers-with-the-internal-gateway-vip
|
- name: then-cluster-dns-answers-with-the-internal-gateway-vip
|
||||||
try:
|
try:
|
||||||
@@ -59,23 +76,32 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: probe
|
- name: probe
|
||||||
image: busybox:1.36.1
|
image: busybox:1.36.1
|
||||||
|
# The cluster's OWN identity, resolved from flux-system/cluster-vars by the
|
||||||
|
# test-level bindings — never a literal (R-ENV-02).
|
||||||
|
env:
|
||||||
|
- name: BASE_DOMAIN
|
||||||
|
value: ($baseDomain)
|
||||||
|
- name: GATEWAY_VIP
|
||||||
|
value: ($gatewayVip)
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
set -u
|
set -u
|
||||||
host="keycloak.home.keithjackson.dev"
|
host="keycloak.${BASE_DOMAIN}"
|
||||||
# Resolve via the DEFAULT cluster resolver (CoreDNS, /etc/resolv.conf ->
|
# Resolve via the DEFAULT cluster resolver (CoreDNS, /etc/resolv.conf ->
|
||||||
# 10.43.0.10). Split-horizon must answer with the in-cluster Gateway VIP
|
# 10.43.0.10). Split-horizon must answer with THIS cluster's Gateway VIP
|
||||||
# (Cilium L2 `shared` pool 192.168.1.53-.60), proving pods hairpin through the Gateway.
|
# (the address coredns-custom is templated with), proving pods hairpin
|
||||||
|
# through the Gateway. Exact match on ${GATEWAY_VIP}: stricter than the
|
||||||
|
# old hardcoded 192.168.1.53-.60 range AND correct in both environments.
|
||||||
out="$(nslookup "$host" 2>/dev/null)"
|
out="$(nslookup "$host" 2>/dev/null)"
|
||||||
echo "$out"
|
echo "$out"
|
||||||
ip="$(echo "$out" | awk '/^Address: /{print $2}' | grep -E '^192\.168\.1\.(5[3-9]|60)$' | head -1)"
|
ip="$(echo "$out" | awk '/^Address: /{print $2}' | grep -Fx "${GATEWAY_VIP}" | head -1)"
|
||||||
if [ -n "$ip" ]; then
|
if [ -n "$ip" ]; then
|
||||||
echo "INTERNAL_RESOLUTION_OK $host -> $ip (in-cluster Gateway VIP)"
|
echo "INTERNAL_RESOLUTION_OK $host -> $ip (in-cluster Gateway VIP)"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo "FAIL: $host did not resolve to the in-cluster Gateway VIP (192.168.1.53-.60);"
|
echo "FAIL: $host did not resolve to this cluster's Gateway VIP (${GATEWAY_VIP});"
|
||||||
echo " split-horizon coredns-custom not in effect (got a public/off-cluster answer)."
|
echo " split-horizon coredns-custom not in effect (got a public/off-cluster answer)."
|
||||||
exit 1
|
exit 1
|
||||||
- assert:
|
- assert:
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L2 BEHAVIORAL — the default egress path is the WAN; the VPN is opt-in (R-NET-04) [RUNNABLE]
|
||||||
|
# Catalog: docs/14 L2 row ("real runtime behavior, probes against a cluster")
|
||||||
|
# Spec: docs/00 R-NET-04 ("The platform MUST support **multiple egress paths** selected by
|
||||||
|
# routing rules ... realized as two paths — the local `internet` path and the
|
||||||
|
# in-cluster `vpn` path").
|
||||||
|
# docs/03 §"Egress routing: two paths, one physical WAN": "the `internet` path is
|
||||||
|
# simply the default route out".
|
||||||
|
# docs/03 §"VPN egress model": "**Default is the WAN; the VPN is strictly opt-in.**
|
||||||
|
# Every pod — and the nodes themselves — egress the local/private WAN by default and
|
||||||
|
# **never** the VPN. Only services labeled `egress-class=vpn` are steered to the
|
||||||
|
# tunnel"; and the tunnel "is **split-tunnel**: ... only what Cilium points at `tun0`
|
||||||
|
# uses the VPN."
|
||||||
|
#
|
||||||
|
# WHAT THIS PROVES THAT tests/l2-behavioral/egress-class DOES NOT
|
||||||
|
# --------------------------------------------------------------
|
||||||
|
# That test (R-NET-05/07) compares two LABELLED pods — `internet` vs `vpn` — and shows their
|
||||||
|
# public IPs differ, then flips a label. Both of its pods declared a class, so it says nothing
|
||||||
|
# about the case R-NET-04 actually turns on: a pod that NO routing rule selects. This test adds
|
||||||
|
# that pod, and asserts the two claims that make a rule-selected path set complete:
|
||||||
|
#
|
||||||
|
# TOTALITY — an UNLABELLED pod egresses the WAN, i.e. the same public IP as the
|
||||||
|
# explicitly-`internet` pod. Absence of a matching rule IS a path, and it is
|
||||||
|
# the local one. If the steering rule ever widened (an empty selector, a
|
||||||
|
# matchExpression, a second rule), this pod's IP would move to the tunnel and
|
||||||
|
# this assertion is what would notice.
|
||||||
|
# COEXISTENCE — the WAN path and the VPN path are live AT THE SAME TIME, observed in one
|
||||||
|
# pass: three pods, two distinct public IPs. "Multiple egress paths" is a
|
||||||
|
# simultaneity claim, not a claim that one switch has two positions.
|
||||||
|
# PER-DESTINATION SELECTION — the vpn-class pod still reaches an in-cluster/RFC1918
|
||||||
|
# destination over the LOCAL path while its public traffic is tunnelled. The
|
||||||
|
# tunnel is destination-scoped (excludedCIDRs), not a default route, so ONE pod
|
||||||
|
# uses BOTH paths at once.
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC (R-ENV-02): no address, hostname or range appears. Every assertion is a
|
||||||
|
# RELATION between observed values (equal / not-equal), so it holds against dev and prod alike
|
||||||
|
# and survives the WAN or the VPN provider rotating addresses.
|
||||||
|
#
|
||||||
|
# GIVEN one unlabelled, one `internet`-class and one `vpn`-class pod — the same workload three
|
||||||
|
# times, differing only in the class they declare (or do not),
|
||||||
|
# WHEN each asks an external echo for the public IP it egressed from, and the vpn-class pod
|
||||||
|
# also resolves a name through the in-cluster resolver,
|
||||||
|
# THEN the unlabelled pod's public IP EQUALS the internet-class pod's (the default route out
|
||||||
|
# is the WAN, and the VPN is strictly opt-in);
|
||||||
|
# AND the vpn-class pod's public IP DIFFERS from both (a second, simultaneously-live path);
|
||||||
|
# AND the vpn-class pod still resolves in-cluster (its private-destination traffic was not
|
||||||
|
# swallowed by the tunnel), so the same pod is using both paths at once.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this test as claiming them):
|
||||||
|
# - THAT NO OTHER RULE EXISTS. Three pods cannot enumerate a rule set. That exactly one
|
||||||
|
# steering rule is authored, that it selects only the vpn class, that no `ip rule` is
|
||||||
|
# unscoped and no nftables DROP unqualified is the L0 half, in
|
||||||
|
# tests/l0-static/egress-paths-selected-by-rules (same requirement label).
|
||||||
|
# - WHICH INTERFACE the packets left by. The observation is the public IP an external echo
|
||||||
|
# reports; it distinguishes the paths without naming them. Asserting "out tun0" would need
|
||||||
|
# host-level datapath inspection (Hubble/tcpdump on the gateway node), which no test in
|
||||||
|
# this suite does.
|
||||||
|
# - THE KILL-SWITCH. Tunnel-down behaviour is R-NET-06's, at L3 (vpn-killswitch): `vpn`
|
||||||
|
# fails closed. Its mirror — that `internet` is never blackholed — is asserted statically
|
||||||
|
# by the L0 half, not here.
|
||||||
|
# - NODE traffic. docs/03 also says "the nodes themselves" egress the WAN by default. That
|
||||||
|
# is a host-level fact this test cannot see from a pod.
|
||||||
|
# - EXTERNAL DEPENDENCY, stated plainly: the public-IP echo (api.ipify.org) is off-cluster,
|
||||||
|
# so a WAN outage or the echo being down fails this test. That is the same dependency
|
||||||
|
# tests/l2-behavioral/egress-class already carries; the alternative — trusting the
|
||||||
|
# cluster's own account of which path it used — would not be a behavioural test at all.
|
||||||
|
#
|
||||||
|
# CLEANUP: chainsaw deletes the per-test namespace (all three pods with it). Nothing outside
|
||||||
|
# that namespace is created or changed — no label on a platform object, no policy edit.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: egress-default-path
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-NET-04: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-NET-04"
|
||||||
|
conformance.home.k3s/layer: "L2"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Given an unlabelled, an internet-class and a vpn-class pod, when each observes the public
|
||||||
|
IP it egressed from, then the unlabelled pod's IP equals the internet-class pod's (the
|
||||||
|
default route out is the WAN, the VPN is opt-in) while the vpn-class pod's differs — two
|
||||||
|
paths live at once — and the vpn-class pod still reaches in-cluster destinations over the
|
||||||
|
local path, so one pod uses both paths simultaneously.
|
||||||
|
spec:
|
||||||
|
timeouts:
|
||||||
|
apply: 60s
|
||||||
|
assert: 2m
|
||||||
|
exec: 3m
|
||||||
|
steps:
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
- name: given-three-pods-differing-only-in-their-declared-egress-class
|
||||||
|
try:
|
||||||
|
- apply:
|
||||||
|
file: fixtures/three-egress-pods.yaml
|
||||||
|
# Bare pods (restartPolicy: Never) — Ready == Running here.
|
||||||
|
- assert:
|
||||||
|
resource:
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata: { name: egress-unlabelled }
|
||||||
|
status: { phase: Running }
|
||||||
|
- assert:
|
||||||
|
resource:
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata: { name: egress-internet }
|
||||||
|
status: { phase: Running }
|
||||||
|
- assert:
|
||||||
|
resource:
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata: { name: egress-vpn }
|
||||||
|
status: { phase: Running }
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
- name: then-the-unlabelled-pod-takes-the-wan-and-the-vpn-path-is-live-too
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
set -eu
|
||||||
|
ns="$NAMESPACE"
|
||||||
|
get_ip() {
|
||||||
|
# Echo the pod's observed public IP, or empty on failure.
|
||||||
|
kubectl -n "$ns" exec "$1" -c curl -- \
|
||||||
|
curl -fsS --max-time 10 https://api.ipify.org 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Poll until all three answer AND the vpn class has diverged from the WAN:
|
||||||
|
# Cilium may still be programming the egress datapath just after the pods go
|
||||||
|
# Ready. Bounded by the loop, never a bare sleep-and-hope.
|
||||||
|
none="" inet="" vpn=""
|
||||||
|
for i in $(seq 1 12); do
|
||||||
|
[ -n "$none" ] || none="$(get_ip egress-unlabelled)"
|
||||||
|
[ -n "$inet" ] || inet="$(get_ip egress-internet)"
|
||||||
|
[ -n "$vpn" ] || vpn="$(get_ip egress-vpn)"
|
||||||
|
if [ -n "$none" ] && [ -n "$inet" ] && [ -n "$vpn" ]; then
|
||||||
|
[ "$vpn" != "$inet" ] && break
|
||||||
|
# Steering may not have converged; re-read the vpn pod only.
|
||||||
|
vpn=""
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
echo "unlabelled_ip=$none internet_ip=$inet vpn_ip=$vpn"
|
||||||
|
|
||||||
|
[ -n "$none" ] || { echo "FAIL: the UNLABELLED pod never returned a public IP — a pod that declares no egress class must still reach the internet over the default WAN path (docs/03: 'the internet path is simply the default route out'); no path at all means the default was blackholed or steered into a dead tunnel" >&2; exit 1; }
|
||||||
|
[ -n "$inet" ] || { echo "FAIL: the internet-class pod never returned a public IP — the declared default path is down, so no comparison is possible" >&2; exit 1; }
|
||||||
|
[ -n "$vpn" ] || { echo "FAIL: the vpn-class pod never returned a public IP — the second egress path is not carrying traffic, so the platform is not offering MULTIPLE paths (R-NET-04)" >&2; exit 1; }
|
||||||
|
|
||||||
|
# TOTALITY: no rule selects the unlabelled pod, so it must land on the WAN —
|
||||||
|
# the same WAN the explicitly-internet pod uses.
|
||||||
|
if [ "$none" != "$inet" ]; then
|
||||||
|
echo "FAIL: the UNLABELLED pod egressed $none but the internet-class pod egressed $inet. They must be the same path: docs/03 — 'Default is the WAN; the VPN is strictly opt-in. Every pod ... egress the local/private WAN by default and never the VPN.' A different IP means some rule is selecting a pod that never opted in." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# COEXISTENCE: and the vpn path is a genuinely different path, live right now.
|
||||||
|
if [ "$vpn" = "$inet" ]; then
|
||||||
|
echo "FAIL: the vpn-class pod egressed the same public IP as the WAN ($vpn) — there is only ONE live egress path, so 'multiple egress paths selected by routing rules' (R-NET-04) does not hold" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "EGRESS_DEFAULT_PATH_IS_WAN_OK"
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'EGRESS_DEFAULT_PATH_IS_WAN_OK')): true
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
- name: then-the-vpn-class-pod-still-reaches-private-destinations-locally
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
# PER-DESTINATION SELECTION. The steering rule tunnels 0.0.0.0/0 MINUS all
|
||||||
|
# RFC1918 space, so a vpn-class pod's private-destination traffic must stay on the
|
||||||
|
# local datapath. The cluster resolver is an in-cluster (RFC1918) address reached
|
||||||
|
# from the pod's own /etc/resolv.conf — no literal is written here — so a
|
||||||
|
# successful lookup from the vpn-class pod proves the tunnel did not swallow its
|
||||||
|
# local traffic, i.e. ONE pod is using BOTH paths at the same time.
|
||||||
|
content: |
|
||||||
|
set -eu
|
||||||
|
ns="$NAMESPACE"
|
||||||
|
resolver="$(kubectl -n "$ns" exec egress-vpn -c curl -- \
|
||||||
|
sh -c 'awk "/^nameserver/{print \$2; exit}" /etc/resolv.conf' 2>/dev/null || true)"
|
||||||
|
[ -n "$resolver" ] || { echo "FAIL: the vpn-class pod has no nameserver in /etc/resolv.conf, so its local-path reachability cannot be observed" >&2; exit 1; }
|
||||||
|
echo "vpn_pod_resolver=$resolver (in-cluster, RFC1918 — excluded from the tunnel)"
|
||||||
|
|
||||||
|
# A cluster-internal name, resolvable only by the in-cluster resolver: reaching
|
||||||
|
# it proves the private-destination path works from a vpn-class pod.
|
||||||
|
out=""
|
||||||
|
for i in $(seq 1 6); do
|
||||||
|
out="$(kubectl -n "$ns" exec egress-vpn -c curl -- \
|
||||||
|
nslookup kubernetes.default.svc.cluster.local 2>/dev/null || true)"
|
||||||
|
case "$out" in *"Address"*) break ;; esac
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
case "$out" in
|
||||||
|
*Address*) : ;;
|
||||||
|
*) echo "FAIL: the vpn-class pod could not resolve kubernetes.default.svc.cluster.local via its in-cluster resolver ($resolver). Its RFC1918 traffic is being steered into the tunnel (or blackholed) instead of taking the local path — the two paths are not coexisting per destination, and the tunnel is behaving as a default route (docs/03: split-tunnel, 'only what Cilium points at tun0 uses the VPN')" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
echo "EGRESS_PATHS_COEXIST_PER_DESTINATION_OK"
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'EGRESS_PATHS_COEXIST_PER_DESTINATION_OK')): true
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Three pods for the R-NET-04 default-path test — the SAME workload three times, differing
|
||||||
|
# ONLY in how (or whether) they declare an egress class:
|
||||||
|
#
|
||||||
|
# egress-unlabelled NO egress-class label at all -> selected by NO routing rule
|
||||||
|
# egress-internet egress-class: internet -> the declared default path
|
||||||
|
# egress-vpn egress-class: vpn -> the only class any rule steers
|
||||||
|
#
|
||||||
|
# WHY AN UNLABELLED POD IS THE POINT. docs/03: "Every pod — and the nodes themselves — egress
|
||||||
|
# the local/private WAN by default and **never** the VPN." The unlabelled pod is the case no
|
||||||
|
# other test covers: nothing selects it, so the DATAPATH's default must carry it, and it must
|
||||||
|
# come out the same WAN as the explicitly-`internet` pod. That is the totality half of
|
||||||
|
# R-NET-04's "selected by routing rules" — absence of a rule is itself a path.
|
||||||
|
#
|
||||||
|
# The unlabelled pod is admissible here only because chainsaw's per-test namespace is not an
|
||||||
|
# app namespace: Kyverno's require-egress-class-label (R-NET-05) rejects an unlabelled pod in
|
||||||
|
# a namespace labelled homelab.keithjackson.dev/app-namespace=true. That admission guarantee
|
||||||
|
# is R-NET-05's, and it is exactly why this test must run outside an app namespace to observe
|
||||||
|
# the datapath's own default at all.
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: egress-unlabelled
|
||||||
|
labels:
|
||||||
|
app: egress-default-path-test
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: curl
|
||||||
|
image: curlimages/curl:8.11.1
|
||||||
|
command: ["sleep", "3600"]
|
||||||
|
resources:
|
||||||
|
requests: { cpu: 10m, memory: 16Mi }
|
||||||
|
limits: { memory: 32Mi }
|
||||||
|
restartPolicy: Never
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: egress-internet
|
||||||
|
labels:
|
||||||
|
app: egress-default-path-test
|
||||||
|
homelab.keithjackson.dev/egress-class: internet
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: curl
|
||||||
|
image: curlimages/curl:8.11.1
|
||||||
|
command: ["sleep", "3600"]
|
||||||
|
resources:
|
||||||
|
requests: { cpu: 10m, memory: 16Mi }
|
||||||
|
limits: { memory: 32Mi }
|
||||||
|
restartPolicy: Never
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: egress-vpn
|
||||||
|
labels:
|
||||||
|
app: egress-default-path-test
|
||||||
|
homelab.keithjackson.dev/egress-class: vpn
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: curl
|
||||||
|
image: curlimages/curl:8.11.1
|
||||||
|
command: ["sleep", "3600"]
|
||||||
|
resources:
|
||||||
|
requests: { cpu: 10m, memory: 16Mi }
|
||||||
|
limits: { memory: 32Mi }
|
||||||
|
restartPolicy: Never
|
||||||
@@ -68,6 +68,15 @@ spec:
|
|||||||
timeouts:
|
timeouts:
|
||||||
apply: 60s
|
apply: 60s
|
||||||
assert: 180s
|
assert: 180s
|
||||||
|
# -- R-ENV-02: the fixture's route hostnames MUST fall under the Gateway's wildcard listener
|
||||||
|
# (*.${BASE_DOMAIN}) or they never attach — so the suffix is READ FROM THE CLUSTER UNDER TEST
|
||||||
|
# (flux-system/cluster-vars, the ConfigMap Flux substitutes ${BASE_DOMAIN} from) rather than
|
||||||
|
# authored here. Test-level => in scope for the fixture apply AND every probe step below
|
||||||
|
# (step `outputs`, by contrast, are step-scoped — see the gwvip re-capture comments).
|
||||||
|
# fixtures/app-and-probe.yaml consumes it via chainsaw resource templating.
|
||||||
|
bindings:
|
||||||
|
- name: baseDomain
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.BASE_DOMAIN)
|
||||||
steps:
|
steps:
|
||||||
# -- GIVEN: the exposed-app fixture (default-deny + gateway-ingress allow + backend +
|
# -- GIVEN: the exposed-app fixture (default-deny + gateway-ingress allow + backend +
|
||||||
# Service + declared/undeclared HTTPRoutes) and a probe namespace. Assert the shared
|
# Service + declared/undeclared HTTPRoutes) and a probe namespace. Assert the shared
|
||||||
@@ -139,12 +148,14 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: GWVIP
|
- name: GWVIP
|
||||||
value: ($gwvip)
|
value: ($gwvip)
|
||||||
|
- name: BASE_DOMAIN
|
||||||
|
value: ($baseDomain)
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
set -u
|
set -u
|
||||||
host="gw-declared-conformance.home.keithjackson.dev"
|
host="gw-declared-conformance.${BASE_DOMAIN}"
|
||||||
last=000
|
last=000
|
||||||
i=0
|
i=0
|
||||||
while [ "$i" -lt 30 ]; do
|
while [ "$i" -lt 30 ]; do
|
||||||
@@ -207,12 +218,14 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: GWVIP
|
- name: GWVIP
|
||||||
value: ($gwvip)
|
value: ($gwvip)
|
||||||
|
- name: BASE_DOMAIN
|
||||||
|
value: ($baseDomain)
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
set -u
|
set -u
|
||||||
host="gw-undeclared-conformance.home.keithjackson.dev"
|
host="gw-undeclared-conformance.${BASE_DOMAIN}"
|
||||||
reached_gw=no
|
reached_gw=no
|
||||||
i=0
|
i=0
|
||||||
while [ "$i" -lt 8 ]; do
|
while [ "$i" -lt 8 ]; do
|
||||||
@@ -300,3 +313,20 @@ spec:
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Namespace
|
kind: Namespace
|
||||||
name: conformance-gw-probe
|
name: conformance-gw-probe
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
# PROOF-OF-PASS SENTINEL (R-SEC-03). Every step above is an assert/Job-status gate, so
|
||||||
|
# chainsaw already fails the test before reaching this step if any of them fails — but a
|
||||||
|
# green Chainsaw JUnit carries NO stdout, and the L2/L3 tests in this suite can self-skip
|
||||||
|
# GREEN, so lib/record_run.py refuses to treat a bare `passed` as evidence and records
|
||||||
|
# `unknown-no-sentinel-declared` instead. Without a declared token this test could pass
|
||||||
|
# forever and NEVER become a recorded proof of R-NET-01/02/03 — north-south reachability under default-deny, part of the isolation proof
|
||||||
|
# — which is precisely what docs/07 asks for ("These proofs are part of the platform
|
||||||
|
# acceptance suite, not one-time checks"). This step exists only to emit that token, and it
|
||||||
|
# is reached only when everything above has passed. Asserted by
|
||||||
|
# tests/l0-static/auditability-proofs-producible.
|
||||||
|
- name: then-emit-the-proof-of-pass-sentinel
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: echo GATEWAY_INGRESS_ISOLATION_PROVEN_OK
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'GATEWAY_INGRESS_ISOLATION_PROVEN_OK')): true
|
||||||
+11
-2
@@ -10,6 +10,13 @@
|
|||||||
# The namespace opts into the shared Gateway (gateway-access: shared) exactly like a
|
# The namespace opts into the shared Gateway (gateway-access: shared) exactly like a
|
||||||
# rendered app namespace, so its HTTPRoutes attach to the real platform Gateway and the
|
# rendered app namespace, so its HTTPRoutes attach to the real platform Gateway and the
|
||||||
# probe exercises the true north-south path (Gateway Envoy -> backend), not a stand-in.
|
# probe exercises the true north-south path (Gateway Envoy -> backend), not a stand-in.
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC HOSTNAMES (R-ENV-02): the two HTTPRoute hostnames are NOT literals. They
|
||||||
|
# are chainsaw-templated from the `baseDomain` binding declared in ../chainsaw-test.yaml, which
|
||||||
|
# reads BASE_DOMAIN out of the flux-system/cluster-vars ConfigMap of the cluster under test — the
|
||||||
|
# same ConfigMap Flux substitutes ${BASE_DOMAIN} from. They MUST fall under the shared Gateway's
|
||||||
|
# wildcard listener (*.${BASE_DOMAIN}) or the routes never attach, which is precisely why a
|
||||||
|
# hardcoded suffix silently broke this test on any cluster that did not hold the apex.
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Namespace
|
kind: Namespace
|
||||||
@@ -134,7 +141,8 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- gw-declared-conformance.home.keithjackson.dev
|
# gw-declared-conformance.<this cluster's BASE_DOMAIN> — see the header.
|
||||||
|
- (join('.', ['gw-declared-conformance', $baseDomain]))
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
@@ -158,7 +166,8 @@ spec:
|
|||||||
namespace: gateway
|
namespace: gateway
|
||||||
sectionName: https
|
sectionName: https
|
||||||
hostnames:
|
hostnames:
|
||||||
- gw-undeclared-conformance.home.keithjackson.dev
|
# gw-undeclared-conformance.<this cluster's BASE_DOMAIN> — see the header.
|
||||||
|
- (join('.', ['gw-undeclared-conformance', $baseDomain]))
|
||||||
rules:
|
rules:
|
||||||
- matches:
|
- matches:
|
||||||
- path:
|
- path:
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L2 BEHAVIORAL — the cluster under test IS an HA k3s control plane (R-PLAT-01) [RUNNABLE]
|
||||||
|
# Catalog: docs/14 L2 row ("real runtime behavior, probes against a cluster")
|
||||||
|
# Spec: docs/00 R-PLAT-01 ("The platform MUST run on k3s across 3 nodes with
|
||||||
|
# production-grade HA control plane in prod; dev/test MAY run fewer nodes but MUST
|
||||||
|
# exercise the same multi-node topology.")
|
||||||
|
# docs/01 "Kubernetes distro | **k3s** (HA, embedded etcd) | R-PLAT-01/02"
|
||||||
|
# docs/02 table: "Control plane | HA embedded etcd (k3s server on all 3) | HA
|
||||||
|
# embedded etcd (k3s server on all 3)"
|
||||||
|
# "Node count | 3 VMs (mirror prod topology) | 3 physical nodes"
|
||||||
|
# docs/02 §"k3s topology decisions": "HA control plane with **embedded etcd** across
|
||||||
|
# all 3 nodes (no external datastore)"; "Nodes are both control-plane and workload
|
||||||
|
# (3-node converged cluster)".
|
||||||
|
#
|
||||||
|
# WHY L2 — AND WHAT L0 ALREADY DID
|
||||||
|
# --------------------------------
|
||||||
|
# tests/l0-static/ha-control-plane-as-code (same requirement label) proves the AUTHORED
|
||||||
|
# half offline: the repo builds a 3-server embedded-etcd control plane in both environments,
|
||||||
|
# one founder, no external datastore, a leader-elected control-plane-only VIP in the serving
|
||||||
|
# cert's SANs. It cannot prove that any of it is TRUE OF A RUNNING CLUSTER — an inventory
|
||||||
|
# entry is not a node, and a templated DaemonSet is not a floating VIP. That is this test:
|
||||||
|
# every assertion below is read from the cluster the suite is pointed at.
|
||||||
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC (R-ENV-02): no address, hostname, node count or range is written
|
||||||
|
# here. The expected node count comes from the cluster's OWN declared identity
|
||||||
|
# (flux-system/cluster-vars CLUSTER_ENV, via a test-level chainsaw binding — the same
|
||||||
|
# ConfigMap Flux substitutes from); the control-plane VIP is read out of the kube-vip
|
||||||
|
# DaemonSet the cluster is actually running; the server addresses come from the Node
|
||||||
|
# objects; and the LoadBalancer pool the VIP must stay outside of is read from cluster-vars
|
||||||
|
# too. R-PLAT-01 itself distinguishes prod from dev/test ("3 nodes ... in prod; dev/test MAY
|
||||||
|
# run fewer"), so the assertion is necessarily environment-AWARE — but it takes the
|
||||||
|
# environment from the cluster, never from a flag or a literal, which is precisely the
|
||||||
|
# mechanism the tests/README.md convention prescribes.
|
||||||
|
#
|
||||||
|
# GIVEN the cluster this suite is pointed at,
|
||||||
|
# WHEN its Nodes, its kube-vip DaemonSet and its kube-apiserver endpoints are inspected,
|
||||||
|
# THEN DISTRO: every node's kubelet is a k3s build (`+k3s`) — the distro really is k3s;
|
||||||
|
# SERVERS: every Ready node is BOTH control-plane AND etcd (the converged 3-node
|
||||||
|
# topology docs/02 fixes — no agent-only node, no control-plane-only node),
|
||||||
|
# the count is ODD and quorum-capable (tolerates >= 1 loss), and it is
|
||||||
|
# EXACTLY three when the cluster declares itself prod;
|
||||||
|
# ETCD: every one of those nodes is a REGISTERED embedded-etcd member (k3s stamps
|
||||||
|
# `etcd.k3s.cattle.io/node-name` on a node that joined the etcd cluster), so
|
||||||
|
# the datastore is embedded and replicated across all of them;
|
||||||
|
# VIP: kube-vip is Ready on EVERY control-plane node, is control-plane-only
|
||||||
|
# (svc_enable=false — it must not contend with Cilium LB-IPAM for ARP), and
|
||||||
|
# the address it floats is neither a node address, nor the ingress Gateway
|
||||||
|
# VIP, nor inside the Cilium LoadBalancer pool;
|
||||||
|
# ENDPOINT: the kube-apiserver answers `/readyz` THROUGH THAT VIP over TLS that
|
||||||
|
# validates against the cluster CA for the VIP itself — i.e. the VIP really
|
||||||
|
# fronts the API and the serving cert really carries it;
|
||||||
|
# EVERY SERVER SERVES: the apiserver also answers `/readyz` on EVERY server's own
|
||||||
|
# address, again with a CA-valid cert for that address. This is the assertion
|
||||||
|
# that separates a real HA control plane from one server plus two joiners:
|
||||||
|
# if only the founder ran an apiserver, the other addresses would refuse.
|
||||||
|
#
|
||||||
|
# THE PROBE RUNS IN-CLUSTER, DELIBERATELY. An earlier draft dialled the VIP and the node
|
||||||
|
# addresses from the RUNNER. That works from a host on the node LAN (verified against prod)
|
||||||
|
# and FAILS against dev, whose nodes sit behind a NAT the runner reaches only via a
|
||||||
|
# forwarded port — i.e. it would have made the test a property of where it was launched
|
||||||
|
# from. A Job on the cluster is on the node network in both environments, so the probe is
|
||||||
|
# the same probe everywhere. The Job is created imperatively (kubectl heredoc) rather than
|
||||||
|
# as a fixture file because its address list is DISCOVERED from the cluster at run time —
|
||||||
|
# writing it into a fixture is exactly the per-environment literal R-ENV-02 forbids.
|
||||||
|
#
|
||||||
|
# WHAT THIS DOES *NOT* PROVE (honest gaps — do not read this test as claiming them):
|
||||||
|
# - FAILOVER / SURVIVAL. Nothing here removes a node. That etcd keeps quorum, that the VIP
|
||||||
|
# migrates, and that the API stays reachable when a server dies is an L3 fault-injection
|
||||||
|
# claim and is proven NOWHERE in this suite yet (tests/l3-fault-injection/node-loss
|
||||||
|
# drains a node for R-STATE-02's data-availability claim and asserts nothing about the
|
||||||
|
# control plane). This is the largest remaining R-PLAT-01 gap.
|
||||||
|
# - THE NEXT environment. That the repo would build an HA control plane again is the L0
|
||||||
|
# half (tests/l0-static/ha-control-plane-as-code).
|
||||||
|
# - "PRODUCTION-GRADE" as a capacity/performance judgement (operator's, docs/02 + §2 of
|
||||||
|
# infra/PROD-PROVISIONING.md).
|
||||||
|
# - THAT THE RUNNING cilium-agents ACTUALLY DIAL THIS VIP. As of 2026-09-06 the repository
|
||||||
|
# authors `K3S_API_HOST` = the control-plane VIP in both environments (asserted at L0 by
|
||||||
|
# tests/l0-static/ha-control-plane-as-code, on both sides of the [parity] contract), but
|
||||||
|
# this test does not read the agents' live `KUBERNETES_SERVICE_HOST`. Until the operator
|
||||||
|
# applies that change the LIVE agents still carry the PRIMARY NODE's address — measured
|
||||||
|
# 2026-09-06: all three prod agents on 192.168.2.10 while kube-vip held 192.168.2.9. An
|
||||||
|
# in-cluster assertion on the agents' endpoint belongs here and is NOT yet written; it is
|
||||||
|
# the obvious follow-up once the change is applied and would catch drift the L0 check
|
||||||
|
# cannot see (a hand-edited DaemonSet, a Helm value overridden out of band).
|
||||||
|
# - etcd health beyond membership registration (raft leader, DB size, alarms) — k3s exposes
|
||||||
|
# it only on the node-local etcd metrics port, which is not reachable through the
|
||||||
|
# Kubernetes API this test is allowed to use.
|
||||||
|
#
|
||||||
|
# CLEANUP: the probe Job lives in chainsaw's per-test namespace and is deleted with it
|
||||||
|
# (config/chainsaw-config.yaml skipDelete: false). Everything else is read-only: this test
|
||||||
|
# creates nothing outside its own namespace and mutates no platform object.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: ha-control-plane
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-PLAT-01: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-PLAT-01"
|
||||||
|
conformance.home.k3s/layer: "L2"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Given the cluster under test, when its Nodes, kube-vip DaemonSet and apiserver
|
||||||
|
endpoints are probed, then every node is a k3s server that is both control-plane and a
|
||||||
|
registered embedded-etcd member, the count is odd and quorum-capable (exactly three
|
||||||
|
when the cluster declares itself prod), kube-vip floats a control-plane-only VIP that
|
||||||
|
collides with no allocator, and the apiserver answers /readyz through that VIP AND on
|
||||||
|
every server's own address with a CA-valid cert.
|
||||||
|
spec:
|
||||||
|
timeouts:
|
||||||
|
apply: 60s
|
||||||
|
assert: 2m
|
||||||
|
exec: 3m
|
||||||
|
# -- R-ENV-02: the environment's identity and its address allocations are READ FROM THE
|
||||||
|
# CLUSTER UNDER TEST. flux-system/cluster-vars is the ConfigMap Flux itself substitutes
|
||||||
|
# from, so these are the same values the platform was rendered with. Test-level, so
|
||||||
|
# they are in scope for every step (step outputs/bindings are step-scoped).
|
||||||
|
bindings:
|
||||||
|
- name: clusterEnv
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.CLUSTER_ENV)
|
||||||
|
- name: gatewayVip
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.GATEWAY_VIP)
|
||||||
|
- name: lbPoolStart
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.CILIUM_LB_POOL_START)
|
||||||
|
- name: lbPoolStop
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.CILIUM_LB_POOL_STOP)
|
||||||
|
steps:
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
- name: then-the-node-set-is-a-quorum-capable-converged-k3s-control-plane
|
||||||
|
description: >-
|
||||||
|
Read the Node objects: k3s on every node, every node both control-plane and a
|
||||||
|
registered embedded-etcd member, an odd quorum-capable count, exactly three in prod.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
env:
|
||||||
|
- name: CLUSTER_ENV
|
||||||
|
value: ($clusterEnv)
|
||||||
|
content: |
|
||||||
|
set -eu
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
total="$(kubectl get nodes -o name | grep -c . || true)"
|
||||||
|
[ "$total" -ge 1 ] || fail "the cluster reports NO nodes at all"
|
||||||
|
|
||||||
|
# --- k3s, on every node (R-PLAT-01 names the distro; docs/01 ADR-0001) -----
|
||||||
|
# A non-k3s kubelet here would mean the platform is not running on k3s.
|
||||||
|
notk3s="$(kubectl get nodes \
|
||||||
|
-o jsonpath='{range .items[*]}{.metadata.name}{"="}{.status.nodeInfo.kubeletVersion}{"\n"}{end}' \
|
||||||
|
| grep -v '+k3s' | grep -c . || true)"
|
||||||
|
[ "$notk3s" = "0" ] \
|
||||||
|
|| fail "$notk3s node(s) do not run a k3s kubelet — R-PLAT-01 requires the platform to run on k3s"
|
||||||
|
|
||||||
|
# --- every node is BOTH control-plane AND etcd (docs/02 converged topology) -
|
||||||
|
# Counted by label EXISTENCE, so it does not matter whether k3s writes "true"
|
||||||
|
# or "". A control-plane node without the etcd role would be a server with an
|
||||||
|
# external/absent datastore; an etcd node without control-plane would be an
|
||||||
|
# isolated datastore member. Both are excluded by requiring all three counts
|
||||||
|
# to be equal.
|
||||||
|
cp="$(kubectl get nodes -l node-role.kubernetes.io/control-plane -o name | grep -c . || true)"
|
||||||
|
etcd="$(kubectl get nodes -l node-role.kubernetes.io/etcd -o name | grep -c . || true)"
|
||||||
|
echo "nodes_total=$total control_plane=$cp etcd=$etcd cluster_env=${CLUSTER_ENV:-<unset>}"
|
||||||
|
[ "$cp" = "$total" ] \
|
||||||
|
|| fail "$cp of $total nodes carry node-role.kubernetes.io/control-plane — docs/02 fixes a converged cluster where every node is a control-plane member ('k3s server on all 3')"
|
||||||
|
[ "$etcd" = "$total" ] \
|
||||||
|
|| fail "$etcd of $total nodes carry node-role.kubernetes.io/etcd — docs/02 requires embedded etcd 'across all 3 nodes (no external datastore)'"
|
||||||
|
|
||||||
|
# --- every node is a REGISTERED embedded-etcd member -----------------------
|
||||||
|
# k3s stamps etcd.k3s.cattle.io/node-name on a node that actually joined the
|
||||||
|
# embedded-etcd cluster. The role label alone is a label; this annotation is
|
||||||
|
# the datastore's own record of membership.
|
||||||
|
members="$(kubectl get nodes \
|
||||||
|
-o jsonpath='{range .items[*]}{.metadata.annotations.etcd\.k3s\.cattle\.io/node-name}{"\n"}{end}' \
|
||||||
|
| grep -c . || true)"
|
||||||
|
[ "$members" = "$total" ] \
|
||||||
|
|| fail "$members of $total nodes are registered embedded-etcd members (etcd.k3s.cattle.io/node-name) — the datastore is not replicated across every server"
|
||||||
|
|
||||||
|
# --- Ready ---------------------------------------------------------------
|
||||||
|
notready="$(kubectl get nodes \
|
||||||
|
-o jsonpath='{range .items[*]}{.metadata.name}{"="}{range .status.conditions[?(@.type=="Ready")]}{.status}{end}{"\n"}{end}' \
|
||||||
|
| grep -v '=True$' | grep -c . || true)"
|
||||||
|
[ "$notready" = "0" ] \
|
||||||
|
|| fail "$notready node(s) are not Ready — a control plane missing a member is not the HA topology under test"
|
||||||
|
|
||||||
|
# --- quorum arithmetic ---------------------------------------------------
|
||||||
|
# Embedded etcd needs floor(N/2)+1 members to commit. Fault tolerance is
|
||||||
|
# N - quorum; 'production-grade HA' must survive at least one loss, which
|
||||||
|
# rules out 1 and 2 members. An EVEN count buys no extra tolerance and adds
|
||||||
|
# a tie, so it is also rejected.
|
||||||
|
quorum=$(( total / 2 + 1 ))
|
||||||
|
tolerance=$(( total - quorum ))
|
||||||
|
echo "quorum=$quorum tolerance=$tolerance"
|
||||||
|
[ "$tolerance" -ge 1 ] \
|
||||||
|
|| fail "$total etcd member(s) give quorum $quorum and tolerate $tolerance loss(es) — an HA control plane must survive losing a server"
|
||||||
|
[ $(( total % 2 )) -eq 1 ] \
|
||||||
|
|| fail "$total etcd members is EVEN: quorum is the same as for $(( total - 1 )) members, so the extra member adds a tie instead of tolerance"
|
||||||
|
|
||||||
|
# --- the count R-PLAT-01 itself fixes, per environment --------------------
|
||||||
|
# The environment is the cluster's OWN CLUSTER_ENV (cluster-vars), never a
|
||||||
|
# flag: 'k3s across 3 nodes ... in prod; dev/test MAY run fewer nodes but MUST
|
||||||
|
# exercise the same multi-node topology'.
|
||||||
|
case "${CLUSTER_ENV:-}" in
|
||||||
|
prod)
|
||||||
|
[ "$total" = "3" ] \
|
||||||
|
|| fail "this cluster declares CLUSTER_ENV=prod and has $total nodes — R-PLAT-01 requires 'k3s across 3 nodes ... in prod' (docs/02: '3 physical nodes')" ;;
|
||||||
|
"")
|
||||||
|
fail "flux-system/cluster-vars has no CLUSTER_ENV — the cluster does not declare which environment it is, so R-PLAT-01's prod clause has no referent (and no literal may be substituted for it)" ;;
|
||||||
|
*)
|
||||||
|
[ "$total" -ge 2 ] \
|
||||||
|
|| fail "this cluster declares CLUSTER_ENV=${CLUSTER_ENV} and has $total node(s) — dev/test may run fewer nodes than prod but MUST 'exercise the same multi-node topology'" ;;
|
||||||
|
esac
|
||||||
|
echo "HA_TOPOLOGY_OK"
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'HA_TOPOLOGY_OK')): true
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
- name: then-a-control-plane-only-vip-is-floated-on-every-server
|
||||||
|
description: >-
|
||||||
|
Read the running kube-vip DaemonSet: Ready on every control-plane node,
|
||||||
|
control-plane-only, and floating an address that collides with no other allocator.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
env:
|
||||||
|
- name: GATEWAY_VIP
|
||||||
|
value: ($gatewayVip)
|
||||||
|
- name: LB_POOL_START
|
||||||
|
value: ($lbPoolStart)
|
||||||
|
- name: LB_POOL_STOP
|
||||||
|
value: ($lbPoolStop)
|
||||||
|
content: |
|
||||||
|
set -eu
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
# dotted quad -> integer, for range comparison without ipcalc/python.
|
||||||
|
n() { echo "$1" | awk -F. '{printf "%d", ($1*16777216)+($2*65536)+($3*256)+$4}'; }
|
||||||
|
|
||||||
|
# The DaemonSet is found by the label roles/kube_vip stamps on it, not by a
|
||||||
|
# hardcoded name in a namespace this test guessed.
|
||||||
|
ds="$(kubectl -n kube-system get ds -l app.kubernetes.io/name=kube-vip-ds -o name | head -1)"
|
||||||
|
[ -n "$ds" ] \
|
||||||
|
|| fail "no kube-vip DaemonSet is running in kube-system — the kube-apiserver has no floating endpoint, so losing the founder takes the API endpoint with it"
|
||||||
|
|
||||||
|
desired="$(kubectl -n kube-system get "$ds" -o jsonpath='{.status.desiredNumberScheduled}')"
|
||||||
|
ready="$(kubectl -n kube-system get "$ds" -o jsonpath='{.status.numberReady}')"
|
||||||
|
cp="$(kubectl get nodes -l node-role.kubernetes.io/control-plane -o name | grep -c . || true)"
|
||||||
|
vip="$(kubectl -n kube-system get "$ds" \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="address")].value}')"
|
||||||
|
cp_enable="$(kubectl -n kube-system get "$ds" \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="cp_enable")].value}')"
|
||||||
|
svc_enable="$(kubectl -n kube-system get "$ds" \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="svc_enable")].value}')"
|
||||||
|
echo "kube_vip=$ds ready=$ready/$desired control_plane_nodes=$cp vip=$vip cp_enable=$cp_enable svc_enable=$svc_enable"
|
||||||
|
|
||||||
|
[ -n "$vip" ] \
|
||||||
|
|| fail "the kube-vip DaemonSet announces no address — nothing is floated"
|
||||||
|
[ "$cp_enable" = "true" ] \
|
||||||
|
|| fail "kube-vip runs with cp_enable=$cp_enable — control-plane mode is off, so it floats no API VIP"
|
||||||
|
[ "$svc_enable" = "false" ] \
|
||||||
|
|| fail "kube-vip runs with svc_enable=$svc_enable — it would also serve Service LoadBalancers and contend with Cilium LB-IPAM for ARP on the same addresses (ADR-0002 gives Service LB to Cilium)"
|
||||||
|
[ "$desired" = "$cp" ] \
|
||||||
|
|| fail "kube-vip is scheduled on $desired node(s) but there are $cp control-plane nodes — the VIP must be able to move to ANY server, or its failover set is smaller than the control plane"
|
||||||
|
[ "$ready" = "$desired" ] \
|
||||||
|
|| fail "kube-vip is Ready on $ready of $desired nodes — a VIP holder that is not running cannot take the address over"
|
||||||
|
|
||||||
|
# --- the VIP collides with nothing --------------------------------------
|
||||||
|
node_ips="$(kubectl get nodes \
|
||||||
|
-o jsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{"\n"}{end}' \
|
||||||
|
| grep -c "^${vip}$" || true)"
|
||||||
|
[ "$node_ips" = "0" ] \
|
||||||
|
|| fail "the floated VIP $vip is ALSO a node's own InternalIP — a VIP that is a node's address cannot float off that node"
|
||||||
|
[ "$vip" != "${GATEWAY_VIP:-}" ] \
|
||||||
|
|| fail "the control-plane VIP and the ingress Gateway VIP are the same address ($vip) — kube-vip and Cilium would both ARP for it"
|
||||||
|
if [ -n "${LB_POOL_START:-}" ] && [ -n "${LB_POOL_STOP:-}" ]; then
|
||||||
|
v="$(n "$vip")"; lo="$(n "$LB_POOL_START")"; hi="$(n "$LB_POOL_STOP")"
|
||||||
|
[ "$lo" -le "$hi" ] \
|
||||||
|
|| fail "the cluster's Cilium LB pool ${LB_POOL_START}-${LB_POOL_STOP} is inverted, so the VIP cannot be proven to be outside it"
|
||||||
|
if [ "$v" -ge "$lo" ] && [ "$v" -le "$hi" ]; then
|
||||||
|
fail "the control-plane VIP $vip lies INSIDE this cluster's Cilium LoadBalancer pool ${LB_POOL_START}-${LB_POOL_STOP} — LB-IPAM could hand the kube-API's VIP to a Service"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
fail "flux-system/cluster-vars declares no CILIUM_LB_POOL_START/STOP — the separation between the control-plane VIP and the Service-LB allocator cannot be checked, and no literal range may be substituted for it (R-ENV-02)"
|
||||||
|
fi
|
||||||
|
echo "HA_VIP_CONTROL_PLANE_ONLY_OK"
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'HA_VIP_CONTROL_PLANE_ONLY_OK')): true
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
- name: then-the-apiserver-answers-through-the-vip-and-on-every-server
|
||||||
|
description: >-
|
||||||
|
From inside the cluster, probe /readyz on the floated VIP and on every server's own
|
||||||
|
address, validating TLS against the cluster CA for the address dialled.
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
# The address list is DISCOVERED here (Nodes + the kube-vip DaemonSet), so the
|
||||||
|
# Job cannot be a static fixture — writing the addresses down is the
|
||||||
|
# per-environment literal R-ENV-02 forbids. Hence the imperative heredoc, the
|
||||||
|
# same pattern tests/l3-fault-injection/dr-restore uses for run-time-shaped
|
||||||
|
# fixtures. The Job lives in chainsaw's per-test namespace and is torn down with
|
||||||
|
# it; nothing outside that namespace is created or changed.
|
||||||
|
content: |
|
||||||
|
set -eu
|
||||||
|
ns="$NAMESPACE"
|
||||||
|
fail() { echo "FAIL: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
ds="$(kubectl -n kube-system get ds -l app.kubernetes.io/name=kube-vip-ds -o name | head -1)"
|
||||||
|
vip="$(kubectl -n kube-system get "$ds" \
|
||||||
|
-o jsonpath='{.spec.template.spec.containers[0].env[?(@.name=="address")].value}')"
|
||||||
|
servers="$(kubectl get nodes -l node-role.kubernetes.io/control-plane \
|
||||||
|
-o jsonpath='{range .items[*]}{.status.addresses[?(@.type=="InternalIP")].address}{" "}{end}')"
|
||||||
|
[ -n "$vip" ] || fail "no floated VIP to probe"
|
||||||
|
[ -n "$servers" ] || fail "no control-plane node addresses to probe"
|
||||||
|
echo "probing vip=$vip servers=$servers"
|
||||||
|
|
||||||
|
# The probe pod authenticates with its own ServiceAccount token and validates
|
||||||
|
# TLS against the cluster CA, both from the projected SA volume. /readyz is
|
||||||
|
# authorized for any authenticated identity via the built-in
|
||||||
|
# system:public-info-viewer role — anonymous access is NOT relied on (verified:
|
||||||
|
# an unauthenticated /readyz on this platform answers 401).
|
||||||
|
kubectl -n "$ns" apply -f - <<EOF
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: ha-api-endpoint-probe
|
||||||
|
spec:
|
||||||
|
backoffLimit: 1
|
||||||
|
activeDeadlineSeconds: 150
|
||||||
|
template:
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: probe
|
||||||
|
image: curlimages/curl:8.11.1
|
||||||
|
env:
|
||||||
|
- name: VIP
|
||||||
|
value: "${vip}"
|
||||||
|
- name: SERVERS
|
||||||
|
value: "${servers}"
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
set -u
|
||||||
|
SA=/var/run/secrets/kubernetes.io/serviceaccount
|
||||||
|
probe() { # \$1 = address to dial
|
||||||
|
curl -sS --max-time 15 \\
|
||||||
|
--cacert "\$SA/ca.crt" \\
|
||||||
|
-H "Authorization: Bearer \$(cat \$SA/token)" \\
|
||||||
|
"https://\$1:6443/readyz"
|
||||||
|
}
|
||||||
|
rc=0
|
||||||
|
out="\$(probe "\$VIP" 2>&1)" || rc=1
|
||||||
|
echo "vip \$VIP -> [\$out]"
|
||||||
|
if [ "\$rc" != "0" ] || [ "\$out" != "ok" ]; then
|
||||||
|
echo "FAIL: the kube-apiserver did not answer /readyz through the floated VIP \$VIP with a CA-valid cert for that address — the VIP does not front the API (or it is missing from the serving cert's SANs)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
for s in \$SERVERS; do
|
||||||
|
rc=0
|
||||||
|
out="\$(probe "\$s" 2>&1)" || rc=1
|
||||||
|
echo "server \$s -> [\$out]"
|
||||||
|
if [ "\$rc" != "0" ] || [ "\$out" != "ok" ]; then
|
||||||
|
echo "FAIL: server \$s does not answer /readyz with a CA-valid cert for its own address — it is not running a kube-apiserver, so the control plane is not HA across every node"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "HA_API_ENDPOINT_OK"
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Bounded wait; on failure surface the probe's own output before failing.
|
||||||
|
if ! kubectl -n "$ns" wait --for=condition=complete --timeout=140s job/ha-api-endpoint-probe; then
|
||||||
|
kubectl -n "$ns" logs job/ha-api-endpoint-probe --tail=40 || true
|
||||||
|
fail "the in-cluster API-endpoint probe did not complete — see its log above"
|
||||||
|
fi
|
||||||
|
logs="$(kubectl -n "$ns" logs job/ha-api-endpoint-probe --tail=40)"
|
||||||
|
echo "$logs"
|
||||||
|
case "$logs" in
|
||||||
|
*HA_API_ENDPOINT_OK*) echo "HA_API_ENDPOINT_REACHABLE_OK" ;;
|
||||||
|
*) fail "the probe completed without reporting success" ;;
|
||||||
|
esac
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'HA_API_ENDPOINT_REACHABLE_OK')): true
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# =============================================================================
|
||||||
|
# L2 BEHAVIORAL — every OIDC client's STORED secret equals the one git declares
|
||||||
|
# (R-IDP-02, R-IDP-04) [RUNNABLE]
|
||||||
|
# Spec: docs/15 identity-and-access; ADR-0014.
|
||||||
|
# Impl: platform/keycloak/client-secret-reconciler.yaml (the CronJob + its script).
|
||||||
|
#
|
||||||
|
# WHY THIS TEST EXISTS
|
||||||
|
# --------------------
|
||||||
|
# The KeycloakRealmImport resolves each confidential client's secret from a `(env:NAME)`
|
||||||
|
# placeholder mapped to a key of the SOPS-encrypted keycloak-realm-clients Secret. When
|
||||||
|
# the Keycloak Operator cannot resolve a placeholder it stores the LITERAL placeholder
|
||||||
|
# text as the client secret AND THE IMPORT JOB STILL REPORTS SUCCESS; the CR then rests
|
||||||
|
# at Done=True / Started=False and never re-runs. Measured 2026-09-06: prod had all 12
|
||||||
|
# clients holding the literal placeholder — every OIDC login on the cluster failed with
|
||||||
|
# "Invalid client or Invalid client credentials" — and dev had one (oauth2-proxy), whose
|
||||||
|
# Secret key was added after dev's import had already completed.
|
||||||
|
#
|
||||||
|
# Every SIGNAL the cluster offered was green: the import Job succeeded, the CR read
|
||||||
|
# Done=True, the Keycloak pods were healthy, and the existing keycloak-config-as-code
|
||||||
|
# test (which asserts Done=True) PASSED throughout. The only observable was a human
|
||||||
|
# failing to log in. This test is that observable, automated: it compares STORED against
|
||||||
|
# DECLARED for every client and fails on any difference, so the class cannot sit
|
||||||
|
# undetected for months again.
|
||||||
|
#
|
||||||
|
# GIVEN the `homelab` realm and the SOPS-encrypted keycloak-realm-clients Secret,
|
||||||
|
# WHEN the reconciler's script is run in --audit mode (READ-ONLY: it cannot write, and
|
||||||
|
# its endpoint allow-list makes users/groups/roles unreachable),
|
||||||
|
# -> THEN every declared client's stored secret matches git, and none holds an
|
||||||
|
# unresolved import placeholder. Emits KEYCLOAK_CLIENT_SECRETS_AUDIT_OK.
|
||||||
|
#
|
||||||
|
# WHY IT RUNS THE SHIPPED SCRIPT rather than re-implementing the comparison: a detector
|
||||||
|
# that duplicates the healer's logic drifts from it. This audits with the same code, in
|
||||||
|
# the same mode the L0 gate selftests, so a passing audit is evidence about the healer
|
||||||
|
# too. It runs as a Job IN the keycloak namespace (not in the ephemeral chainsaw
|
||||||
|
# namespace) because the desired state is the mounted keycloak-realm-clients Secret and
|
||||||
|
# the admin credential is keycloak-admin-credentials — both namespace-local, which is
|
||||||
|
# also why neither this Job nor the CronJob needs any Kubernetes API access.
|
||||||
|
#
|
||||||
|
# chainsaw v0.2.12: Job + status assert; the leftover-Job delete keeps a re-run from
|
||||||
|
# colliding with an immutable Job left by a previous failure.
|
||||||
|
# =============================================================================
|
||||||
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
|
kind: Test
|
||||||
|
metadata:
|
||||||
|
name: keycloak-client-secrets-match-git
|
||||||
|
labels:
|
||||||
|
conformance.home.k3s/R-IDP-02: "true"
|
||||||
|
conformance.home.k3s/R-IDP-04: "true"
|
||||||
|
annotations:
|
||||||
|
conformance.home.k3s/requirements: "R-IDP-02, R-IDP-04"
|
||||||
|
conformance.home.k3s/layer: "L2"
|
||||||
|
conformance.home.k3s/safety-critical: "false"
|
||||||
|
conformance.home.k3s/executable: "true"
|
||||||
|
conformance.home.k3s/intent: >-
|
||||||
|
Every OIDC client's stored secret equals the SOPS-declared one and none holds an
|
||||||
|
unresolved import placeholder — the observable that was missing when all 12 prod
|
||||||
|
clients were broken behind a green realm import.
|
||||||
|
spec:
|
||||||
|
timeouts:
|
||||||
|
apply: 60s
|
||||||
|
assert: 5m
|
||||||
|
exec: 5m
|
||||||
|
steps:
|
||||||
|
- name: given-the-reconciler-is-reconciled-onto-the-cluster
|
||||||
|
description: >-
|
||||||
|
The audit Job runs the CronJob's own script from its ConfigMap; if Flux has not
|
||||||
|
applied the reconciler, there is nothing to audit WITH and that is the failure.
|
||||||
|
try:
|
||||||
|
- assert:
|
||||||
|
resource:
|
||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: keycloak-client-secret-reconciler
|
||||||
|
namespace: keycloak
|
||||||
|
- assert:
|
||||||
|
resource:
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: CronJob
|
||||||
|
metadata:
|
||||||
|
name: keycloak-client-secret-reconciler
|
||||||
|
namespace: keycloak
|
||||||
|
spec:
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
- name: then-every-stored-client-secret-matches-git
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
kubectl -n keycloak delete job keycloak-client-secret-audit \
|
||||||
|
--ignore-not-found --wait=true
|
||||||
|
- apply:
|
||||||
|
resource:
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: keycloak-client-secret-audit
|
||||||
|
namespace: keycloak
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: keycloak-client-secret-audit
|
||||||
|
app.kubernetes.io/part-of: conformance
|
||||||
|
spec:
|
||||||
|
backoffLimit: 0
|
||||||
|
activeDeadlineSeconds: 240
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app.kubernetes.io/name: keycloak-client-secret-audit
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
automountServiceAccountToken: false
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 65532
|
||||||
|
runAsGroup: 65532
|
||||||
|
seccompProfile:
|
||||||
|
type: RuntimeDefault
|
||||||
|
containers:
|
||||||
|
- name: audit
|
||||||
|
image: python:3.13.15-alpine3.23
|
||||||
|
command:
|
||||||
|
- python3
|
||||||
|
- /opt/reconciler/reconcile-client-secrets.py
|
||||||
|
# READ-ONLY. --audit never issues a write on any path; the
|
||||||
|
# script's --selftest proves that (L0 gate).
|
||||||
|
- --audit
|
||||||
|
env:
|
||||||
|
- name: KEYCLOAK_BASE_URL
|
||||||
|
value: http://keycloak-service.keycloak.svc.cluster.local:8080
|
||||||
|
- name: KEYCLOAK_REALM
|
||||||
|
value: homelab
|
||||||
|
- name: PYTHONDONTWRITEBYTECODE
|
||||||
|
value: "1"
|
||||||
|
- name: PYTHONUNBUFFERED
|
||||||
|
value: "1"
|
||||||
|
- name: KEYCLOAK_ADMIN_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: keycloak-admin-credentials
|
||||||
|
key: admin-user
|
||||||
|
- name: KEYCLOAK_ADMIN_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: keycloak-admin-credentials
|
||||||
|
key: admin-password
|
||||||
|
volumeMounts:
|
||||||
|
- name: script
|
||||||
|
mountPath: /opt/reconciler
|
||||||
|
readOnly: true
|
||||||
|
- name: client-secrets
|
||||||
|
mountPath: /etc/keycloak-realm-clients
|
||||||
|
readOnly: true
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities:
|
||||||
|
drop:
|
||||||
|
- ALL
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 10m
|
||||||
|
memory: 48Mi
|
||||||
|
limits:
|
||||||
|
memory: 128Mi
|
||||||
|
volumes:
|
||||||
|
- name: script
|
||||||
|
configMap:
|
||||||
|
name: keycloak-client-secret-reconciler
|
||||||
|
defaultMode: 0444
|
||||||
|
- name: client-secrets
|
||||||
|
secret:
|
||||||
|
secretName: keycloak-realm-clients
|
||||||
|
defaultMode: 0400
|
||||||
|
- assert:
|
||||||
|
resource:
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: keycloak-client-secret-audit
|
||||||
|
namespace: keycloak
|
||||||
|
status:
|
||||||
|
succeeded: 1
|
||||||
|
# The Job's exit status already encodes the verdict, but the sentinel is what the
|
||||||
|
# run LEDGER records (conformance/runs/*/run.log): a JUnit alone cannot tell a real
|
||||||
|
# audit from a Job that succeeded without auditing anything.
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
set -u
|
||||||
|
out="$(kubectl -n keycloak logs job/keycloak-client-secret-audit \
|
||||||
|
--tail=-1 2>&1 || true)"
|
||||||
|
echo "$out"
|
||||||
|
echo "$out" | grep -q 'KEYCLOAK_CLIENT_SECRETS_AUDIT_OK' \
|
||||||
|
|| { echo "FAIL: the audit did not report every client secret matching git"; exit 1; }
|
||||||
|
echo "$out" | grep -q 'UNRESOLVED IMPORT PLACEHOLDER' \
|
||||||
|
&& { echo "FAIL: a client still holds the literal import placeholder"; exit 1; }
|
||||||
|
echo KEYCLOAK_CLIENT_SECRET_DRIFT_NONE_OK
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'KEYCLOAK_CLIENT_SECRET_DRIFT_NONE_OK')): true
|
||||||
|
catch:
|
||||||
|
- describe:
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
namespace: keycloak
|
||||||
|
name: keycloak-client-secret-audit
|
||||||
|
- podLogs:
|
||||||
|
namespace: keycloak
|
||||||
|
selector: app.kubernetes.io/name=keycloak-client-secret-audit
|
||||||
|
tail: 60
|
||||||
|
finally:
|
||||||
|
- script:
|
||||||
|
content: |
|
||||||
|
kubectl -n keycloak delete job keycloak-client-secret-audit \
|
||||||
|
--ignore-not-found --wait=false
|
||||||
@@ -11,6 +11,18 @@
|
|||||||
#
|
#
|
||||||
# Goes GREEN once Keycloak is up + the realm client-secret hand-off (keycloak-realm-clients) is filled
|
# Goes GREEN once Keycloak is up + the realm client-secret hand-off (keycloak-realm-clients) is filled
|
||||||
# so the placeholder resolves and the import completes. chainsaw v0.2.12: script + block contains check.
|
# so the placeholder resolves and the import completes. chainsaw v0.2.12: script + block contains check.
|
||||||
|
#
|
||||||
|
# NOT PROVEN HERE — READ THIS BEFORE TRUSTING A GREEN RUN (added 2026-09-06).
|
||||||
|
# Done=True means the import JOB EXITED 0. It does NOT mean the placeholders resolved: when the
|
||||||
|
# operator cannot resolve one it stores the LITERAL `(env:...)` text as the client secret and the
|
||||||
|
# import still reports success, then rests at Done=True / Started=False and never re-runs. THIS
|
||||||
|
# TEST PASSED THROUGHOUT the 2026-09-06 outage in which all 12 prod clients held the literal
|
||||||
|
# placeholder and every OIDC login on the cluster failed. Done=True is a necessary, badly
|
||||||
|
# insufficient condition.
|
||||||
|
# The sufficient check is tests/l2-behavioral/keycloak-client-secrets-match-git, which compares
|
||||||
|
# each client's STORED secret against the SOPS-declared one; the static half is
|
||||||
|
# tests/l0-static/keycloak-client-secret-reconciler. Do not "simplify" by folding those into this
|
||||||
|
# test: the whole lesson is that the import's own success signal is not evidence.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
apiVersion: chainsaw.kyverno.io/v1alpha1
|
apiVersion: chainsaw.kyverno.io/v1alpha1
|
||||||
kind: Test
|
kind: Test
|
||||||
|
|||||||
@@ -3,10 +3,15 @@
|
|||||||
# Spec: docs/15 identity-and-access; ADR-0014. Impl: Grafana as a Keycloak OIDC client
|
# Spec: docs/15 identity-and-access; ADR-0014. Impl: Grafana as a Keycloak OIDC client
|
||||||
# (platform/monitoring/kube-prometheus-stack.yaml auth.generic_oauth) — the self-contained SSO proof.
|
# (platform/monitoring/kube-prometheus-stack.yaml auth.generic_oauth) — the self-contained SSO proof.
|
||||||
#
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC (R-ENV-02): the expected IdP host is built from the ${BASE_DOMAIN} of the
|
||||||
|
# CLUSTER UNDER TEST, read from flux-system/cluster-vars (the ConfigMap Flux substitutes from) by
|
||||||
|
# a test-level chainsaw binding — so this asserts each cluster redirects to ITS OWN Keycloak, which
|
||||||
|
# is exactly what would catch a cross-environment OIDC issuer left pointing at the other cluster.
|
||||||
|
#
|
||||||
# GIVEN Grafana is deployed and configured as an OIDC client of the Keycloak `homelab` realm,
|
# GIVEN Grafana is deployed and configured as an OIDC client of the Keycloak `homelab` realm,
|
||||||
# WHEN an unauthenticated browser starts the OIDC login (GET /login/generic_oauth)
|
# WHEN an unauthenticated browser starts the OIDC login (GET /login/generic_oauth)
|
||||||
# -> THEN Grafana redirects (302) to the CENTRAL IdP's authorize endpoint on
|
# -> THEN Grafana redirects (302) to the CENTRAL IdP's authorize endpoint on
|
||||||
# keycloak.home.keithjackson.dev/realms/homelab — proving SSO is driven by the one IdP
|
# keycloak.${BASE_DOMAIN}/realms/homelab — proving SSO is driven by the one IdP
|
||||||
# (R-IDP-01: central Identity Provider as the single user directory).
|
# (R-IDP-01: central Identity Provider as the single user directory).
|
||||||
#
|
#
|
||||||
# Goes GREEN once the grafana-oidc client-secret hand-off is filled and Keycloak + Grafana are up.
|
# Goes GREEN once the grafana-oidc client-secret hand-off is filled and Keycloak + Grafana are up.
|
||||||
@@ -30,6 +35,11 @@ spec:
|
|||||||
apply: 60s
|
apply: 60s
|
||||||
assert: 3m
|
assert: 3m
|
||||||
exec: 3m
|
exec: 3m
|
||||||
|
# -- R-ENV-02: the IdP hostname suffix is READ FROM THE CLUSTER UNDER TEST
|
||||||
|
# (flux-system/cluster-vars — the ConfigMap Flux substitutes ${BASE_DOMAIN} from).
|
||||||
|
bindings:
|
||||||
|
- name: baseDomain
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.BASE_DOMAIN)
|
||||||
steps:
|
steps:
|
||||||
- name: given-grafana-is-an-oidc-client
|
- name: given-grafana-is-an-oidc-client
|
||||||
try:
|
try:
|
||||||
@@ -59,6 +69,9 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: probe
|
- name: probe
|
||||||
image: busybox:1.36.1
|
image: busybox:1.36.1
|
||||||
|
env:
|
||||||
|
- name: BASE_DOMAIN
|
||||||
|
value: ($baseDomain)
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
@@ -68,13 +81,14 @@ spec:
|
|||||||
# busybox wget has no --max-redirect; -S dumps every response header
|
# busybox wget has no --max-redirect; -S dumps every response header
|
||||||
# (incl. the initial 302 Location to the IdP) before following, so the
|
# (incl. the initial 302 Location to the IdP) before following, so the
|
||||||
# grep below still matches the Keycloak authorize URL. (Grafana verified
|
# grep below still matches the Keycloak authorize URL. (Grafana verified
|
||||||
# live: 302 -> keycloak.home.keithjackson.dev/realms/homelab/.../auth.)
|
# live: 302 -> keycloak.${BASE_DOMAIN}/realms/homelab/.../auth.)
|
||||||
|
idp="keycloak.${BASE_DOMAIN}/realms/homelab"
|
||||||
out="$(wget -S -O /dev/null "$svc/login/generic_oauth" 2>&1 || true)"
|
out="$(wget -S -O /dev/null "$svc/login/generic_oauth" 2>&1 || true)"
|
||||||
echo "$out"
|
echo "$out"
|
||||||
if echo "$out" | grep -qi 'keycloak.home.keithjackson.dev/realms/homelab'; then
|
if echo "$out" | grep -qiF "$idp"; then
|
||||||
echo GRAFANA_OIDC_REDIRECT_OK
|
echo "GRAFANA_OIDC_REDIRECT_OK -> ${idp}"
|
||||||
else
|
else
|
||||||
echo "FAIL: Grafana did not redirect to the Keycloak homelab realm"; exit 1
|
echo "FAIL: Grafana did not redirect to ${idp} (this cluster's Keycloak homelab realm)"; exit 1
|
||||||
fi
|
fi
|
||||||
- assert:
|
- assert:
|
||||||
resource:
|
resource:
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
# Catalog: docs/14 onboarding (auth wiring) + docs/15 identity-and-access.
|
# Catalog: docs/14 onboarding (auth wiring) + docs/15 identity-and-access.
|
||||||
# Impl: platform/oauth2-proxy (forward-auth against the Keycloak `homelab` realm), ADR-0014.
|
# Impl: platform/oauth2-proxy (forward-auth against the Keycloak `homelab` realm), ADR-0014.
|
||||||
#
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC (R-ENV-02): this test names NO hostname. It talks to oauth2-proxy over its
|
||||||
|
# in-cluster Service DNS and asserts HTTP behaviour, so the same authored test is correct on dev and
|
||||||
|
# on prod. The issuer host below is written as ${BASE_DOMAIN} because that is what the platform
|
||||||
|
# substitutes; it is prose, not an input — nothing here needs parameterizing.
|
||||||
|
#
|
||||||
# GIVEN oauth2-proxy is deployed and has validated the Keycloak OIDC issuer (it only becomes Available
|
# GIVEN oauth2-proxy is deployed and has validated the Keycloak OIDC issuer (it only becomes Available
|
||||||
# after successfully discovering https://keycloak.home.keithjackson.dev/realms/homelab — this is
|
# after successfully discovering https://keycloak.${BASE_DOMAIN}/realms/homelab — this is
|
||||||
# the proxy->Keycloak flow working),
|
# the proxy->Keycloak flow working),
|
||||||
# WHEN an UNAUTHENTICATED request hits the forward-auth decision endpoint (/oauth2/auth)
|
# WHEN an UNAUTHENTICATED request hits the forward-auth decision endpoint (/oauth2/auth)
|
||||||
# -> THEN it is challenged: HTTP 401 (no valid Keycloak session => access denied), and a normal
|
# -> THEN it is challenged: HTTP 401 (no valid Keycloak session => access denied), and a normal
|
||||||
|
|||||||
@@ -3,8 +3,13 @@
|
|||||||
# Catalog: docs/14 "Trusted TLS (R-TLS-01/02)"
|
# Catalog: docs/14 "Trusted TLS (R-TLS-01/02)"
|
||||||
# Spec: docs/09 (Gateway API + DNS-01 wildcard), ADR-0006 (LE issuers)
|
# Spec: docs/09 (Gateway API + DNS-01 wildcard), ADR-0006 (LE issuers)
|
||||||
#
|
#
|
||||||
|
# ENVIRONMENT-AGNOSTIC (R-ENV-02): the hostnames are built from the ${BASE_DOMAIN} of the CLUSTER
|
||||||
|
# UNDER TEST, read from flux-system/cluster-vars (the ConfigMap Flux substitutes from) by a
|
||||||
|
# test-level chainsaw binding. The same authored test therefore verifies dev's wildcard on dev and
|
||||||
|
# prod's on prod, with nothing to pass at run time.
|
||||||
|
#
|
||||||
# GIVEN the shared Gateway terminating TLS with the cert-manager wildcard cert,
|
# GIVEN the shared Gateway terminating TLS with the cert-manager wildcard cert,
|
||||||
# WHEN an in-cluster probe dials it by hostname (SNI *.home.keithjackson.dev) and
|
# WHEN an in-cluster probe dials it by hostname (SNI *.${BASE_DOMAIN}) and
|
||||||
# verifies the chain against the SYSTEM trust store,
|
# verifies the chain against the SYSTEM trust store,
|
||||||
# THEN the served cert is non-self-signed, unexpired, covers the wildcard SAN, and
|
# THEN the served cert is non-self-signed, unexpired, covers the wildcard SAN, and
|
||||||
# chains to a PUBLICLY-TRUSTED CA root (R-TLS-01). The wildcard SAN also covers
|
# chains to a PUBLICLY-TRUSTED CA root (R-TLS-01). The wildcard SAN also covers
|
||||||
@@ -36,6 +41,11 @@ metadata:
|
|||||||
that chains to a publicly-trusted CA root (LE production); the wildcard SAN
|
that chains to a publicly-trusted CA root (LE production); the wildcard SAN
|
||||||
covers internal-only hostnames too.
|
covers internal-only hostnames too.
|
||||||
spec:
|
spec:
|
||||||
|
# -- R-ENV-02: the hostname suffix is READ FROM THE CLUSTER UNDER TEST (flux-system/cluster-vars
|
||||||
|
# — the ConfigMap Flux substitutes ${BASE_DOMAIN} from), never authored per environment.
|
||||||
|
bindings:
|
||||||
|
- name: baseDomain
|
||||||
|
value: (x_k8s_get($client, 'v1', 'ConfigMap', 'flux-system', 'cluster-vars').data.BASE_DOMAIN)
|
||||||
steps:
|
steps:
|
||||||
- name: then-gateway-cert-chains-to-trusted-root
|
- name: then-gateway-cert-chains-to-trusted-root
|
||||||
try:
|
try:
|
||||||
@@ -54,13 +64,16 @@ spec:
|
|||||||
containers:
|
containers:
|
||||||
- name: probe
|
- name: probe
|
||||||
image: curlimages/curl:8.11.1
|
image: curlimages/curl:8.11.1
|
||||||
|
env:
|
||||||
|
- name: BASE_DOMAIN
|
||||||
|
value: ($baseDomain)
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
set -eu
|
set -eu
|
||||||
# Probe TWO wildcard hostnames — one "public", one "internal-only". Both
|
# Probe TWO wildcard hostnames — one "public", one "internal-only". Both
|
||||||
# match *.home.keithjackson.dev, so a clean verify against the SYSTEM CA
|
# match *.${BASE_DOMAIN}, so a clean verify against the SYSTEM CA
|
||||||
# bundle for BOTH proves R-TLS-01 (chains to a publicly-trusted root) AND
|
# bundle for BOTH proves R-TLS-01 (chains to a publicly-trusted root) AND
|
||||||
# R-TLS-03 (the same trusted wildcard covers internal-only names). Names
|
# R-TLS-03 (the same trusted wildcard covers internal-only names). Names
|
||||||
# resolve via in-cluster split-horizon DNS to the Gateway VIP. curl (no
|
# resolve via in-cluster split-horizon DNS to the Gateway VIP. curl (no
|
||||||
@@ -68,7 +81,7 @@ spec:
|
|||||||
# the gateway's 404 once TLS+hostname verify; it exits 60 if the chain or
|
# the gateway's 404 once TLS+hostname verify; it exits 60 if the chain or
|
||||||
# hostname fails. TLS terminates at the gateway before HTTP routing, so no
|
# hostname fails. TLS terminates at the gateway before HTTP routing, so no
|
||||||
# backend is needed.
|
# backend is needed.
|
||||||
for host in conformance.home.keithjackson.dev internal-only.home.keithjackson.dev; do
|
for host in "conformance.${BASE_DOMAIN}" "internal-only.${BASE_DOMAIN}"; do
|
||||||
curl -sS --retry 5 --retry-connrefused --retry-delay 3 --max-time 20 \
|
curl -sS --retry 5 --retry-connrefused --retry-delay 3 --max-time 20 \
|
||||||
-o /dev/null "https://${host}/"
|
-o /dev/null "https://${host}/"
|
||||||
echo "TLS_TRUSTED ${host}"
|
echo "TLS_TRUSTED ${host}"
|
||||||
@@ -80,3 +93,20 @@ spec:
|
|||||||
kind: Job
|
kind: Job
|
||||||
metadata: { name: tls-probe }
|
metadata: { name: tls-probe }
|
||||||
status: { succeeded: 1 }
|
status: { succeeded: 1 }
|
||||||
|
# -----------------------------------------------------------------------------------
|
||||||
|
# PROOF-OF-PASS SENTINEL (R-SEC-03). Every step above is an assert/Job-status gate, so
|
||||||
|
# chainsaw already fails the test before reaching this step if any of them fails — but a
|
||||||
|
# green Chainsaw JUnit carries NO stdout, and the L2/L3 tests in this suite can self-skip
|
||||||
|
# GREEN, so lib/record_run.py refuses to treat a bare `passed` as evidence and records
|
||||||
|
# `unknown-no-sentinel-declared` instead. Without a declared token this test could pass
|
||||||
|
# forever and NEVER become a recorded proof of R-TLS-01/03 — the TLS half of docs/07's audit proof list
|
||||||
|
# — which is precisely what docs/07 asks for ("These proofs are part of the platform
|
||||||
|
# acceptance suite, not one-time checks"). This step exists only to emit that token, and it
|
||||||
|
# is reached only when everything above has passed. Asserted by
|
||||||
|
# tests/l0-static/auditability-proofs-producible.
|
||||||
|
- name: then-emit-the-proof-of-pass-sentinel
|
||||||
|
try:
|
||||||
|
- script:
|
||||||
|
content: echo TLS_TRUSTED_PROVEN_OK
|
||||||
|
check:
|
||||||
|
(contains($stdout, 'TLS_TRUSTED_PROVEN_OK')): true
|
||||||
@@ -40,10 +40,17 @@
|
|||||||
# alternative source for the data.
|
# alternative source for the data.
|
||||||
#
|
#
|
||||||
# KNOWN, DELIBERATE GAPS (asserted nowhere — do not read this test as claiming them):
|
# KNOWN, DELIBERATE GAPS (asserted nowhere — do not read this test as claiming them):
|
||||||
# * OFF-HOST is NOT proven, only OFF-CLUSTER. On the dev estate the k3s nodes are
|
# * OFF-HOST is NOT proven, only OFF-CLUSTER — TRUE AS RUN, and being fixed. On the
|
||||||
# VMs on the same Proxmox host that runs the versitygw S3 target (192.168.1.9),
|
# dev estate the k3s nodes are VMs on the same Proxmox host that runs the
|
||||||
# so this drill does not survive loss of that hypervisor. Off-site/off-host
|
# versitygw/MinIO S3 target (192.168.1.9), so this drill does not survive loss of
|
||||||
# replication is an operator concern outside R-STATE-03's wording.
|
# that hypervisor: one machine takes the cluster AND its backups.
|
||||||
|
# That is exactly why the operator moved off-cluster DR to Cloudflare R2 on
|
||||||
|
# 2026-09-05 (commit 32d6766 — S3_BACKUP_ENDPOINT is now an R2 endpoint). Once that
|
||||||
|
# merges and this drill is RE-RUN against R2, off-host stops being a gap and becomes
|
||||||
|
# proven, because the target no longer shares a failure domain with the cluster.
|
||||||
|
# UNTIL THEN THIS TEST'S PASS IS AGAINST MinIO. R-STATE-03 is currently proven only
|
||||||
|
# for the same-host target; re-proving it against R2 is a prerequisite for claiming
|
||||||
|
# off-host DR, and the ledger row that proves R-STATE-03 predates the move.
|
||||||
# * The test does NOT read the backup objects out of the bucket with an S3 client.
|
# * The test does NOT read the backup objects out of the bucket with an S3 client.
|
||||||
# Doing so would require handing the conformance runner the backup credential,
|
# Doing so would require handing the conformance runner the backup credential,
|
||||||
# widening a privileged test identity for weaker evidence than (1)-(4) above.
|
# widening a privileged test identity for weaker evidence than (1)-(4) above.
|
||||||
|
|||||||
+20
-4
@@ -84,6 +84,9 @@ toolchain: yamllint, check-jsonschema, kyverno, chainsaw, yq) lives in the image
|
|||||||
infra/devcontainer/dev.sh build # once
|
infra/devcontainer/dev.sh build # once
|
||||||
infra/devcontainer/dev.sh run make -C ci gate # exactly what CI runs
|
infra/devcontainer/dev.sh run make -C ci gate # exactly what CI runs
|
||||||
infra/devcontainer/dev.sh # interactive shell at /work
|
infra/devcontainer/dev.sh # interactive shell at /work
|
||||||
|
|
||||||
|
infra/devcontainer/dev.sh run kubectl get nodes # DEV (the default)
|
||||||
|
CLUSTER=prod infra/devcontainer/dev.sh run kubectl get nodes # PROD
|
||||||
```
|
```
|
||||||
|
|
||||||
Unlike the libvirt era, **tofu and ansible now run fine inside the container** — the
|
Unlike the libvirt era, **tofu and ansible now run fine inside the container** — the
|
||||||
@@ -95,10 +98,23 @@ which is what broke the old `dmacvicar/libvirt` provider in a container.
|
|||||||
file, and the sibling spec repo read-only at `/home-network-k3s` so the spec-immutability
|
file, and the sibling spec repo read-only at `/home-network-k3s` so the spec-immutability
|
||||||
check runs instead of silently skipping.
|
check runs instead of silently skipping.
|
||||||
|
|
||||||
> **Known gap.** `dev.sh` defaults `KUBECONFIG` to `/work/infra/ansible/.cache/dev.kubeconfig`,
|
> **Closed (was a known gap): `dev.sh` can now target either cluster.** It used to hard-wire
|
||||||
> a libvirt-era path that no longer exists, and does not mount `~/.kube`. In-container `kubectl`
|
> `KUBECONFIG` to `dev.kubeconfig`, leaving an undocumented — and misleadingly named —
|
||||||
> therefore needs an explicit `DEV_KUBECONFIG=<path to a break-glass cert kubeconfig>` — the OIDC
|
> `DEV_KUBECONFIG=<prod path>` as the only route to prod. Select with **`CLUSTER=dev|prod`**;
|
||||||
> context cannot work there anyway (no browser). Not yet fixed.
|
> **dev remains the default**, so every existing invocation means what it always meant. Inside the
|
||||||
|
> container `KUBECONFIG` is a chain of a tiny per-invocation selector file over a merged config
|
||||||
|
> carrying both clusters as contexts **`k3s-dev`** and **`k3s-prod`** (never `default`), regenerated
|
||||||
|
> each run by [`infra/devcontainer/kubeconfig.sh`](devcontainer/kubeconfig.sh) into the gitignored
|
||||||
|
> `infra/ansible/.cache/clusters.kubeconfig`. The merged file deliberately carries **no**
|
||||||
|
> `current-context`: it is shared by every container, and stamping the current cluster into it made
|
||||||
|
> concurrent `CLUSTER=dev` and `CLUSTER=prod` runs silently swap clusters mid-command. Naming matters: k3s writes *both* source kubeconfigs
|
||||||
|
> with context, cluster and user all called `default`, and kubectl's merge keeps the first
|
||||||
|
> duplicate — so a plain `KUBECONFIG=dev:prod` would silently resolve `default` to **dev** while you
|
||||||
|
> believed you were on prod. Requesting a cluster whose kubeconfig is absent is a hard error, never
|
||||||
|
> a fallback. `DEV_KUBECONFIG` still works and still wins, but is **superseded** — it is opaque
|
||||||
|
> about its target and yields an ambiguous `default` context. `~/.kube` is still not mounted and the
|
||||||
|
> OIDC context still cannot work in a container (no browser); use the break-glass cert kubeconfigs
|
||||||
|
> above. Details: [`devcontainer/README.md`](devcontainer/README.md).
|
||||||
|
|
||||||
## Off-repo state you must not lose
|
## Off-repo state you must not lose
|
||||||
|
|
||||||
|
|||||||
+1124
-26
File diff suppressed because it is too large.
Load diff
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user