Compare commits

...
5 Commits
Author SHA1 Message Date
jakman85andClaude Opus 5 fbe3126d3f feat(sec): bake conformance-clone so the init container has no injection surface
gate / gate (pull_request) Successful in 1m36s
gate / conformance (pull_request) Has been skipped
Closes half of the admission gap a sibling agent found in PR #13's policy: it pins
the MAIN container's `command` but never reads `initContainers`, so a Job from
`conformance-trigger` can carry an init container with `command: ["/bin/sh","-c"]`
and an arbitrary script, running as the privileged `conformance-runner` SA.
Reproduced by the policy's author: pass 3, fail 0 on a privileged alpine:latest
init container running `kubectl get secrets -A`.

The image now carries /usr/local/bin/conformance-clone, 0755 root:root, so uid
65532 cannot replace the one executable the policy will come to trust. Clone logic
taken from PR #20's `clone-suite-at-commit`, with both load-bearing properties
preserved: the SHA must match ^[0-9a-f]{40}$, and `rev-parse HEAD` is ASSERTED
equal to it after checkout, so an unrendered placeholder is a hard failure instead
of a silent run of the branch tip. No arguments are accepted.

DEVIATION FROM THE BRIEF, DELIBERATE: the repo URL and clone target are COMPILED
IN rather than read from the environment. I was asked to take SHA, repo URL and
target dir from env. The policy contains no `env`/`valueFrom`/`secretKeyRef` clause
at all -- so trusting CONFORMANCE_REPO_URL would move the injection one field over:
the trigger points it at a repo it controls, this fetches that, and ./run.sh from
the attacker's tree runs under the privileged SA. "No args" would not have stopped
it. The env vars are still read so the Job template stays self-documenting, but a
value disagreeing with the baked literal is a hard failure, never a redirect. The
SHA is the only genuinely trusted per-run input.

THIS IS HALF A FIX. What makes the fixed path REQUIRED rather than merely available
is a 4th policy rule demanding command: ["/usr/local/bin/conformance-clone"] with
no args on every initContainer. That edits the security boundary and belongs in its
own reviewed PR -- it is NOT here. Until it lands the hole is open, and the README,
the Containerfile and the script header all say so.

VERIFIED by running it (uid 65532, --read-only): root:root 755 and confirmed not
writable by the runner; seven negative cases each asserted to exit non-zero
(arguments, missing SHA, a branch name, an unrendered @@placeholder@@, HOME off the
workspace volume, a REDIRECTED repo URL, a clone target off the workspace volume);
and the positive case -- a real clone of this repo at a real 40-hex SHA with
rev-parse HEAD proven equal to it.

Two bugs of mine that only running it could find, both fixed: the workspace-path
guard matched /workspace/* and so rejected the image's own HOME=/workspace; and the
negative checks were pipelines, which under pipefail aborted the verify at the
first expected failure and silently skipped the other six. A verify that stops
early looks exactly like a verify that passed.

The anonymous-clone fallback is deliberate and logged: read access to this repo
needs no credential today, the integrity guarantee is the SHA assertion rather than
the credential, and requiring the Secret would make the script untestable without
one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 19:33:02 -05:00
jakman85andClaude Opus 5 c8899368e7 fix(conformance): state the chainsaw supply-chain gap accurately
gate / gate (pull_request) Successful in 1m36s
gate / conformance (pull_request) Has been skipped
The Containerfile said chainsaw "publishes no per-asset checksum file at a stable
URL". Literally true, and misleading: it reads as "no integrity option exists".
Checked the release assets via the GitHub API -- v0.2.12 ships per-asset cosign
`.sig` + `.pem` and an SBOM alongside each tarball. So verification IS available,
it just needs cosign in the builder stage and the right keyless identity for
kyverno's release workflow.

Comment now says what is actually true: kubectl is sha256-verified, chainsaw rests
on tag + TLS, and the cosign path is named as a TODO rather than implied away.
Deliberately not implemented here -- picking a trusted signer identity is a
supply-chain decision that belongs in its own reviewed change, not a silent
addition to a runner image. Noted that infra/devcontainer/Containerfile has the
same gap for the same binary.

Rebuilt after the edit; the in-image verification still passes.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 19:22:03 -05:00
jakman85andClaude Opus 5 b27254dfee docs(decisions-log): PR #18 has no approval-policy check -- measured, not assumed
gate / gate (pull_request) Successful in 1m37s
gate / conformance (pull_request) Has been skipped
My brief said this PR's CI would fail on an `approval-policy` stage because
`conformance/**` is a security-relevant path requiring human approval, and to
report that as expected. I wrote that down before checking it. It is not true:

  * `grep -rn 'approval-policy|approval_policy' .gitea/ ci/` returns nothing.
    gate.yml has jobs `gate` and `conformance`; merge-gate.yaml has
    spec-immutability, lint, l0, render-golden, l1, conformance, merge-gate.
  * GET /branch_protections -> protected_file_patterns: '' . No path rule of any
    kind, and no CODEOWNERS file in the repo.
  * status_check_contexts: ['gate / gate (pull_request)'] -- one context, not an
    approval stage.

What blocks this PR is what blocked #12-#15: required_approvals: 1 with
block_admin_merge_override: true, and gitea-admin authors every agent PR and
cannot self-approve. That is a MERGE block, not a CI failure. The distinction is
not pedantry -- "expect a red X" and "expect a green run an agent still cannot
merge" send a reviewer to different places.

The correction is left IN the log rather than quietly deleted. This is the fourth
consecutive entry where a confidently-stated control turned out not to exist, and
the pattern is more useful than a tidy paragraph.

Also records what the CI run actually did, read out of the job log rather than off
the tick (a skipped Gitea job reports success): lint, l0, render-golden, l1 and
gitea-settings all pass; spec-immutability and conformance SKIPPED; MERGE GATE
UNDETERMINED. So `gate / gate` = success means rc 3 tolerated, not rc 0. Note that
gitea-settings RAN AND PASSED with REQUIRE_GITEA_SETTINGS=true -- the detector
armed in #15 works, and CI is more complete than the local toolbox run, where that
stage can only skip.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 19:19:09 -05:00
jakman85andClaude Opus 5 ff4892f795 docs(decisions-log): handoff for the conformance-runner image (PR #18)
gate / gate (pull_request) Successful in 1m36s
gate / conformance (pull_request) Has been skipped
CLAUDE.md section 7. Records what was built, what was VERIFIED by running it, and —
more usefully — what was not:

* the image has never run in the cluster; every proof above is local podman
* it is not pushed, so CONFORMANCE_RUNNER_IMAGE_DIGEST is empty on purpose
* L2/L3 still do not run in CI; this image is a prerequisite, not the closure

And one constraint the plan does not account for, which the next agent would
otherwise hit at runtime: run.sh runs the offline L0/L1 checks for any selection
that is not offline-only, and those need flux/promtool/kyverno/kustomize. So BOTH
of the plan's documented invocation shapes -- `--layer all --strict-coverage` and
a bare `--requirements <IDs>` -- fail on this image. `--layer l2` / `--layer l3`
work, and are verified to. That is a decision for the plan owner, not a detail.

Also records a measurement rather than an assumption: from inside the image as uid
65532 the Gitea host verifies against the stock ca-certificates (Let's Encrypt
YR2) and an anonymous `ls-remote` succeeds, so the init-container clone needs no
internal CA bundle -- and anonymous read is currently open, which means the plan's
read-only deploy token buys authorisation hygiene, not reachability.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 19:14:12 -05:00
jakman85andClaude Opus 5 526a38d137 feat(conformance): build the runner image the in-cluster Job could never have run
gate / gate (pull_request) Successful in 1m38s
gate / conformance (pull_request) Has been skipped
The committed L2/L3 Job pins `ghcr.io/kyverno/chainsaw:v0.2.12` with
`command: ["./run.sh"]`. Those two lines cannot both be true. That image carries
the chainsaw binary and essentially nothing else -- no bash, no python3, no
kubectl, no git -- so the Job would have failed at exec of `./run.sh`, before a
single test ran. Nothing in the tree said so.

This adds the image that makes the Job runnable (plan
docs/plans/l2-l3-conformance-ci-wiring.md section 3 item 1):

* conformance/incluster/Containerfile -- bash + python3 + chainsaw + kubectl +
  git-core + tar/gzip/coreutils (the base64-tar results channel of item 3).
  Two-stage: a full Fedora fetches the release binaries and verifies kubectl's
  published sha256; the runtime layer is fedora-minimal and carries no
  downloader. Versions are --build-arg-injected from
  infra/devcontainer/versions.env (CHAINSAW_VERSION, KUBECTL_VERSION,
  FEDORA_VERSION) -- the single source of truth -- so the chainsaw and kubectl in
  the runner are the same pins the toolbox gate runs with. No new pin is invented
  here, and the ARG defaults are EMPTY so a build that forgets one fails loudly
  instead of baking a stale version.

* conformance/incluster/build-image.sh -- builds it, PROVES the toolchain by
  executing it inside the image, and prints the digest. It never pushes: the
  registry credential is not an agent's to hold and publishing the artifact a
  fail-closed policy then trusts is an operator action. It prints the exact
  `podman push --digestfile ...` line instead.

* conformance/versions.env -- CONFORMANCE_RUNNER_IMAGE, with the digest
  deliberately EMPTY and a loud block saying the operator fills it in after the
  push. Empty is a visible failure in whatever renders the Job; a guessed digest
  would be an ImagePullBackOff dressed up as a cluster fault.

WHAT THE ADMISSION BOUNDARY FORCED, AND WHY THE IMAGE OBEYS IT

platform/kyverno/admission/conformance-trigger-job-shape.yaml is Enforce +
failurePolicy: Fail and it is the R-SEC-02 boundary. It is NOT modified here.
Two of its clauses are load-bearing on the image itself:

* `runAsNonRoot: true` with no explicit runAsUser means the kubelet rejects an
  image whose USER is a name it cannot resolve to a uid. So USER is NUMERIC
  (65532), not a friendly name.
* The pod is readOnlyRootFilesystem and the policy admits only the `workspace`
  and `results` volumes -- there is no writable /tmp and one cannot be added
  without editing the boundary. So HOME, XDG_CACHE_HOME, XDG_CONFIG_HOME and
  TMPDIR all point into /workspace, and git's safe.directory config is written
  to /etc/gitconfig at BUILD time because a runtime --global write would fail.

VERIFIED BY RUNNING IT, not by a successful build

As uid 65532 with --read-only: bash 5.3.9, Python 3.14.7, git 2.55.0,
kubectl v1.36.4 (client), chainsaw 0.2.12; / confirmed read-only, /workspace
writable, uid confirmed non-zero. Then the real thing -- `./run.sh --lint`,
`--coverage-only` and `--layer l2 --no-cluster` all rc 0 inside the image
against this worktree, emitting coverage.md/.json and report.junit.xml.

KNOWN LIMIT, FLAGGED NOT PAPERED OVER

run.sh runs the selected offline L0/L1 checks before it touches the cluster for
ANY selection that is not offline-only, and those checks shell out to flux,
promtool, kyverno and kustomize, which this image does not carry. Baking the
whole devcontainer toolchain into the runner would roughly double its size to
re-run work ci/l0.sh and ci/l1.sh already did offline in the same CI run. So the
Job must select the cluster-gated layers only (`--layer l2` / `--layer l3`);
`--layer all` and a bare `--requirements <IDs>` would drag the L0 checks in and
fail on a missing tool. `--layer l2` is verified above to resolve the offline
selection to nothing. Choosing the Job's args belongs to T-6.1b; the constraint
is documented in the Containerfile header and the incluster README rather than
left for someone to discover from a pod log.

chainsaw-job.yaml's `image:` is left stale ON PURPOSE -- there is no digest to
put there yet, and the trigger renders that template per run (plan item 4). The
README now says so instead of letting the placeholder read as a pin.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-29 19:08:19 -05:00
6 changed files with 887 additions and 1 deletions

No files matched your search

+235
View File
@@ -5109,3 +5109,238 @@ next L2/L3 piece on top of unmerged branches would create a rebase tangle for no
cannot land any faster. The next piece is specified and ready to start once these are merged:
`docs/plans/l2-l3-conformance-ci-wiring.md` §3, with §4's credential model now decided and its
boundary landed in #13.
---
## 2026-09-29 — the in-cluster conformance Job could never have run; built the image it needs (PR #18)
Closes `docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 1. Branch
`feat/conformance-runner-image`, PR
[#18](https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl/pulls/18). **Not
merged** — an agent cannot, and should not.
### The defect, stated plainly
`conformance/incluster/chainsaw-job.yaml` pinned `ghcr.io/kyverno/chainsaw:v0.2.12` **and**
`command: ["./run.sh"]`. Both lines could not be true. That image carries the chainsaw binary
and essentially nothing else — no `bash`, no `python3`, no `kubectl`, no `git`. The Job would
have failed at exec of `./run.sh` before a single test ran, and the only place that would have
surfaced is a pod log nobody was reading. This is the same failure shape as the last three
entries in this log: a manifest that *described* a working mechanism, with nothing measuring
whether the mechanism existed.
### What landed
- `conformance/incluster/Containerfile` — the `conformance-runner` image: bash + python3 +
chainsaw + kubectl + `git-core` + tar/gzip/coreutils/diffutils. Two-stage (a full Fedora
fetches the binaries and verifies kubectl's published `sha256`; the runtime layer is
`fedora-minimal` and carries no downloader).
- `conformance/incluster/build-image.sh` — builds, **proves the toolchain by executing it**,
prints the digest, and **never pushes**.
- `conformance/versions.env` — `CONFORMANCE_RUNNER_IMAGE`, digest **empty** pending the push.
- `conformance/incluster/README.md` — documents both, and flags the Job manifest's stale `image:`.
`infra/devcontainer/versions.env` stays the single source of truth. `build-image.sh` sources it
and injects `CHAINSAW_VERSION` / `KUBECTL_VERSION` / `FEDORA_VERSION` as `--build-arg`; the
Containerfile's `ARG` defaults are **empty** and asserted in its first `RUN`, so a build that
forgets a pin fails loudly rather than baking a stale version. No new pin was invented.
### VERIFIED — by running the image, not by a green build
As uid 65532 with `--read-only`: bash 5.3.9, Python 3.14.7, git 2.55.0, kubectl v1.36.4
(client), chainsaw 0.2.12; `/` confirmed read-only, `/workspace` writable, uid confirmed
non-zero. Then the real entrypoint in the same shape against the worktree: `./run.sh --lint`,
`--coverage-only`, and `--layer l2 --no-cluster` all **rc 0**, emitting `coverage.md`,
`coverage.json` and `report.junit.xml`. 408 MiB.
Two things the admission boundary forced, both of which would otherwise have been discovered
at first run: `USER` must be **numeric** (`runAsNonRoot` with no `runAsUser` makes the kubelet
reject a named user), and every writable path must live under `/workspace` (the pod is
`readOnlyRootFilesystem` and the policy admits no third volume, so there is no `/tmp`). git's
`safe.directory` is therefore written to `/etc/gitconfig` at build time.
`platform/kyverno/admission/conformance-trigger-job-shape.yaml` was **not modified**.
### NOT VERIFIED — state this before building on it
- **The image has never run in the cluster.** Everything above is local podman. Admission has
not been exercised against it, because there is no digest to reference yet.
- **The image is not pushed**, so `CONFORMANCE_RUNNER_IMAGE_DIGEST` is empty. Empty on purpose:
it fails visibly in whatever renders the Job, whereas a guessed digest would look like an
`ImagePullBackOff` cluster fault. Pushing needs a registry credential and is an operator
action — `build-image.sh` prints the exact commands.
- The init-container's clone path **is** verified end to end, and this one was measured rather
than assumed: from inside the image, as uid 65532, `python3` completes a verifying TLS
handshake to `gitea.home.keithjackson.dev` against the stock `ca-certificates`
(issuer `Let's Encrypt / YR2`, notAfter `Dec 12 02:16:57 2026 GMT`) and
`git ls-remote https://…/home-network-k3s-impl.git HEAD` returns `20ea70d` with **no
credential**. So no internal CA bundle is needed, and anonymous read is currently open — the
plan's read-only deploy token buys authorisation hygiene, not reachability.
- L2/L3 still do not run in CI (`vars.CLUSTER_AVAILABLE` unset). This image is a prerequisite
for closing that hole, not the closure.
### A REAL CONSTRAINT THE PLAN DOES NOT ACCOUNT FOR — read before wiring T-6.1b
`run.sh` runs the selected **offline L0/L1 checks** before it touches the cluster, for **any**
selection that is not offline-only. Those checks shell out to `flux`, `promtool`, `kyverno` and
`kustomize`. This image deliberately omits them — baking the whole devcontainer toolchain into
the runner would roughly double its size to re-run work `ci/l0.sh` and `ci/l1.sh` already did
offline in the same CI run.
So **the plan's own invocation shapes do not work on this image.** `--layer all
--strict-coverage` (the documented phase-exit shape) and a bare `--requirements <IDs>` (the
documented merge-gate shape) both drag the L0 checks in and will fail on a missing tool. The Job
must use `--layer l2` / `--layer l3`, which is verified above to resolve the offline selection
to nothing.
**This is a decision for the plan owner, not a detail:** either the Job selects cluster-gated
layers only, or the runner grows the full L0 toolchain, or `run.sh` learns to skip offline
checks when their tools are absent (which would be a fail-open and is the wrong answer). It is
documented in the Containerfile header and the incluster README so it cannot be rediscovered
from a pod log.
### Follow-ups this creates
1. **Operator:** `podman login` + `podman push --digestfile`, then a PR setting
`CONFORMANCE_RUNNER_IMAGE_DIGEST` from the digestfile — the digest the *registry* returned,
not the local manifest digest the build printed.
2. **Then tighten the boundary**, as that policy's own header asks: replace its `@sha256:*`
wildcard with the literal digest, closing the residual "anyone who can push to this path"
gap. Separate, human-reviewed PR — it edits the security boundary.
3. **T-6.1b:** decide the Job's `args` per the constraint above, and stop `chainsaw-job.yaml`
carrying an image reference that admission would deny.
### Gate
`DEV_KUBECONFIG=/dev/null ENGINE=<worktree .git shim> infra/devcontainer/dev.sh run bash -c './ci/gate.sh'`
→ **rc 3, `MERGE GATE: UNDETERMINED`. Nothing FAILED.**
[ ok ] lint [ ok ] l0 [ ok ] render-golden [ ok ] l1
[skip] spec-immutability (sibling spec repo not checked out — CLAUDE.md §3 tolerates this one)
[skip] gitea-settings (SETTINGS_API_TOKEN not set)
[skip] conformance (CLUSTER_AVAILABLE != true)
Touched requirements resolved to `R-SEC-02 R-TEST-01 R-TEST-02 R-TEST-03 R-TEST-04`; all five
remain **L2/L3-UNVERIFIED**. This is not a green run and is not offered as one.
### CORRECTION — there is no `approval-policy` CI stage, and `conformance/**` is not a protected path
My brief told me to expect this PR's CI to fail on an `approval-policy` stage because
`conformance/**` is a security-relevant path requiring human approval, and to report that as
expected. **I repeated that in this entry before checking it. It is not true**, and per §2b the
check is the point:
| Claim | Measured |
|---|---|
| an `approval-policy` CI stage exists | **No.** `grep -rn 'approval-policy\|approval_policy' .gitea/ ci/` → nothing. `.gitea/workflows/gate.yml` has jobs `gate` and `conformance`; `merge-gate.yaml` has `spec-immutability, lint, l0, render-golden, l1, conformance, merge-gate`. No approval job in either. |
| `conformance/**` is a protected path | **No.** `GET /branch_protections` → `protected_file_patterns: ''`. There is no path-based rule of any kind, and no `CODEOWNERS` file in the repo. |
| required status checks | `status_check_contexts: ['gate / gate (pull_request)']` — one context, and it is not an approval stage. |
What actually blocks this PR is what blocked #12 through #15: `required_approvals: 1` with
`block_admin_merge_override: true`, and `gitea-admin` authors every agent PR and cannot
self-approve. That is a **merge** block, not a CI failure — the distinction matters, because
"expect a red X" and "expect a green run that an agent still cannot merge" lead a reviewer to
look in different places. Left in as a correction rather than quietly deleted: this is the
fourth consecutive entry in this log where a confidently-stated control turned out not to
exist, and the pattern is worth more than a clean paragraph.
**And here is what PR #18's CI actually did** — read out of the job log
(`/Home-Network/home-network-k3s-impl/actions/runs/128/jobs/0/logs`), not off the tick, because
a skipped Gitea job reports `success`:
[skip] stage spec-immutability: SKIPPED (not run)
[ ok ] stage lint: pass
[ ok ] stage l0: pass
[ ok ] stage render-golden: pass
[ ok ] stage l1: pass
[ ok ] stage gitea-settings: pass
[skip] stage conformance: SKIPPED (not run)
[ ok ] gitea-settings: green
[fail] MERGE GATE: UNDETERMINED — greenness was NOT ESTABLISHED.
`gate / gate (pull_request)` = **success in 1m36s**, and that success means **rc 3, tolerated**
by `./ci/gate.sh || [ "$?" -eq 3 ]` — not rc 0. No approval stage appears anywhere in the run.
Two things worth carrying forward: **`gitea-settings` ran and passed in CI with
`REQUIRE_GITEA_SETTINGS=true`** (so the token resolves and the live Gitea still matches
`infra/gitea/expected-branch-protection.json` — the detector armed in #15 is working), and CI is
therefore *more* complete than the local toolbox run, where that stage can only skip. The two
stages unrun in CI are `spec-immutability` (spec repo not checked out into the runner) and
`conformance` (`if: false`, `CLUSTER_AVAILABLE` unset).
So the accurate statement for a reviewer is: **this PR's CI is as green as any PR in this repo
can currently be, it is still formally UNDETERMINED, and what stops it merging is the missing
human approval — not a failed check.**
### Addendum — `/usr/local/bin/conformance-clone`, and the env half of the same hole
Added at the coordinator's request, to close the admission gap a sibling agent found: the
policy pins the MAIN container's `command` but **never reads `initContainers`**, so a Job from
`conformance-trigger` can carry an init container with `command: ["/bin/sh","-c"]` and an
arbitrary script, running as the privileged `conformance-runner` SA. Reproduced by the policy's
author (`pass: 3, fail: 0` on a privileged `alpine:latest` init container running
`kubectl get secrets -A`).
The image now bakes `/usr/local/bin/conformance-clone`, **0755 root:root**, so uid 65532 cannot
replace the one executable the policy will come to trust. Clone logic taken from PR #20's
`clone-suite-at-commit` init container, with both load-bearing properties preserved: the SHA
must match `^[0-9a-f]{40}$`, and `rev-parse HEAD` is asserted equal to it after checkout. No
arguments are accepted.
**A DEVIATION FROM THE BRIEF, DELIBERATE — the repo URL and clone target are COMPILED IN, not
taken from the environment.** I was told to take SHA, repo URL and target dir from the
environment. I checked the policy first: it contains no `env`, `valueFrom` or `secretKeyRef`
clause at all. So a script that trusted `CONFORMANCE_REPO_URL` would have moved the injection
one field over — the trigger points it at a repo it controls, `conformance-clone` fetches that,
and `./run.sh` from the attacker's tree runs under the privileged SA. "No args" would not have
stopped it; it is the *same* hole as `initContainers`, one field further along. The env vars are
still read so the Job template stays self-documenting, but a value that disagrees with the baked
literal is a hard failure, never a redirect. The only genuinely trusted per-run input is the SHA.
**This file is half a fix and must not be mistaken for the whole one.** What makes the fixed
path *required* rather than merely *available* is a 4th policy rule demanding
`command: ["/usr/local/bin/conformance-clone"]` with no `args` on every initContainer. That rule
edits the security boundary and belongs in its own reviewed PR — it is **not** in this branch.
Until it lands, the hole is open.
VERIFIED by running it in the image as uid 65532 with `--read-only`:
/usr/local/bin/conformance-clone root:root 755
confirmed: not writable by uid 65532
Seven negative cases, each asserted to exit non-zero (not merely to print something):
arguments rejected; missing SHA; a branch name instead of a SHA; an unrendered
`@@CONFORMANCE_GIT_SHA@@`; `HOME` off the workspace volume; a **redirected**
`CONFORMANCE_REPO_URL=https://evil.invalid/x.git`; a `CONFORMANCE_SRC` off the workspace volume.
And the positive case, which is the one that matters:
requesting commit c8899368e76cf734f9717417167dd683e5bed8bd
==> no GITEA_USERNAME/GITEA_TOKEN in the environment — cloning ANONYMOUSLY
==> c889936... is not in the shallow tip; fetching that commit
==> suite ready at c8899368e76cf734f9717417167dd683e5bed8bd
requested: c8899368e76cf734f9717417167dd683e5bed8bd
HEAD: c8899368e76cf734f9717417167dd683e5bed8bd
confirmed: the working tree is at the requested commit
Two bugs of my own that only running it could have found, both now fixed: the workspace-path
guard matched `/workspace/*` and so **rejected the image's own `HOME=/workspace`**; and the
negative checks were written as pipelines, which under `pipefail` aborted the verify at the
first expected failure and silently skipped the remaining six. A verify that stops early looks
identical to a verify that passed.
The anonymous-clone fallback is deliberate and announced in the log line: read access to this
repo needs no credential today, the integrity guarantee here is the SHA assertion rather than
the credential, and requiring the Secret would have made the script untestable without one.
### Process note — the shared scratchpad caused a near-miss, and reading back caught my own bug
Three parallel agents shared one scratchpad directory. Two siblings wrote helper scripts to the
same path; one overwrote the other's and the victim executed the wrong script, PATCHing a PR
belonging to neither under its own credentials — returning a plausible `HTTP 201` to both. My
own helpers went to per-agent paths afterwards, and I audited PR #18 by reading it back: still
`head.ref: feat/conformance-runner-image`, no sibling content.
That read-back also caught a defect of mine that the `201` had concealed: my second body PATCH
truncated at a `## Expected CI failure` marker and appended after it, which silently deleted my
own earlier anonymous-read section. **A 2xx proves a request succeeded, not that it did what was
intended** — the same reason this branch verifies kubectl's `sha256` and runs every tool in the
image rather than trusting that the install worked. Restored in the PR body.
+200
View File
@@ -0,0 +1,200 @@
# =============================================================================
# conformance-runner — the image the in-cluster L2/L3 Chainsaw Job actually runs.
# Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1.
#
# WHY THIS EXISTS. chainsaw-job.yaml pins `ghcr.io/kyverno/chainsaw:v0.2.12` and
# `command: ["./run.sh"]`. That image carries the chainsaw binary and almost nothing else —
# no bash, no python3, no kubectl, no git. So the committed Job could never have run: it
# would have failed at exec of `./run.sh`. This image carries exactly what
# `conformance/run.sh` needs:
#
# bash run.sh and every tests/**/check.sh are bash (arrays, mapfile, BASH_SOURCE)
# python3 lib/{run_l1_offline,gen_coverage,merge_junit,emit_skipped_junit,status}.py
# (stdlib only — conformance/versions.env PYTHON_MIN=3.9, so no pip layer)
# chainsaw the L1/L2/L3 runner itself (run_chainsaw / lint_tests)
# kubectl conf_cluster_reachable()'s `kubectl cluster-info` preflight + probe scripts
# git the init-container's `clone --depth 1` + `checkout <SHA>` (§3 item 2) and
# run.sh's `rev-parse --short HEAD` for the run id
# tar/gzip/coreutils the `=== ARTIFACTS-TGZ-BASE64 ===` results channel (§3 item 3)
#
# It also carries `/usr/local/bin/conformance-clone` (0755 root:root) — the init container's
# ONLY entrypoint, a fixed path with no argument surface. That exists to close an admission
# hole, not for convenience: see the COPY below and the script's own header.
#
# VERSIONS COME FROM infra/devcontainer/versions.env — NOT from new pins invented here.
# That file is the single source of truth for CHAINSAW_VERSION / KUBECTL_VERSION /
# FEDORA_VERSION, and build-image.sh sources it and passes them in as --build-arg. The
# ARG defaults below are deliberately EMPTY so a build that forgets to pass them fails
# loudly (see the assertion in the first RUN) instead of silently baking a stale version.
#
# KNOWN LIMIT — READ BEFORE CHOOSING THE JOB'S `args`.
# `run.sh` runs the selected OFFLINE L0/L1 checks (run_offline_checks) before it ever
# touches the cluster, for ANY selection that is not offline-only. Those checks shell out
# to flux / promtool / kyverno / kustomize, which this image deliberately does NOT carry —
# baking the whole devcontainer toolchain into the runner would roughly double its size to
# re-run work that ci/l0.sh + ci/l1.sh already did offline in the same CI run. So the Job
# must select the cluster-gated layers only, e.g. `args: ["--layer", "l2"]` /
# `["--layer", "l3"]`, where offline_selected_dirs() resolves to nothing and
# run_offline_checks() returns 0. `--layer all` and a bare `--requirements <IDs>` would drag
# the L0 checks in and fail on a missing tool. Flagged to the plan owner; choosing the Job's
# args is T-6.1b's call, not this image's.
#
# THE ADMISSION CONTRACT THIS IMAGE MUST SATISFY.
# platform/kyverno/admission/conformance-trigger-job-shape.yaml is a fail-closed Enforce
# ClusterPolicy (R-SEC-02) and it is the security boundary. It requires the Job's container
# to be `gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*` — a DIGEST,
# never a tag — with `runAsNonRoot: true` and `allowPrivilegeEscalation: false`. Two
# consequences are baked in here:
#
# 1. `USER` is NUMERIC. With `runAsNonRoot: true` and no explicit `runAsUser`, the kubelet
# REJECTS an image whose USER is a name it cannot resolve to a uid
# ("container has runAsNonRoot and image has non-numeric user"). So: USER 65532:65532.
# 2. The pod is `readOnlyRootFilesystem: true` and the policy admits only the `workspace`
# and `results` volumes — there is no writable /tmp and one cannot be added without
# changing the boundary. So HOME and every cache path point INTO /workspace (the
# emptyDir), not at the read-only root. That is why HOME=/workspace below.
# =============================================================================
# --- builder: fetch the pinned release binaries ------------------------------
# A full Fedora for the download stage (it has curl + tar); none of it ships in the runtime
# image, so the runner does not carry a downloader it has no business having at runtime.
ARG FEDORA_VERSION=44
FROM registry.fedoraproject.org/fedora:${FEDORA_VERSION} AS builder
ARG CHAINSAW_VERSION=
ARG KUBECTL_VERSION=
ARG TARGETARCH=amd64
# Fail the BUILD — not the Job, three weeks later — if a pin was not passed in.
RUN set -eux; \
: "${CHAINSAW_VERSION:?pass --build-arg CHAINSAW_VERSION from infra/devcontainer/versions.env}"; \
: "${KUBECTL_VERSION:?pass --build-arg KUBECTL_VERSION from infra/devcontainer/versions.env}"
# Same URLs and the same arch handling as infra/devcontainer/Containerfile, so the chainsaw
# and kubectl in this image are the same builds the toolbox gate runs with.
#
# INTEGRITY, MEASURED NOT ASSUMED. kubectl publishes a plain `<url>.sha256`, so it is verified
# below. chainsaw publishes NO checksums file — the v0.2.12 release assets are the tarballs plus
# per-asset cosign `.sig`/`.pem` and an SBOM (listed via the GitHub releases API). So chainsaw's
# integrity here rests on the tag + TLS, and afterwards on this image's own digest.
# TODO, worth doing and deliberately not smuggled in here: verify chainsaw with
# `cosign verify-blob --certificate chainsaw_linux_amd64.tar.gz.pem
# --signature chainsaw_linux_amd64.tar.gz.sig ...`. That needs cosign in the builder stage and
# the correct keyless identity/issuer for kyverno's release workflow — a supply-chain decision
# that belongs in its own reviewed change, not as a silent addition to a runner image.
# infra/devcontainer/Containerfile has the same gap for the same binary.
RUN set -eux; \
mkdir -p /out; \
curl -fsSL "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl" \
-o /out/kubectl; \
curl -fsSL "https://dl.k8s.io/release/v${KUBECTL_VERSION}/bin/linux/${TARGETARCH}/kubectl.sha256" \
-o /tmp/kubectl.sha256; \
echo "$(cat /tmp/kubectl.sha256) /out/kubectl" | sha256sum -c -; \
curl -fsSL "https://github.com/kyverno/chainsaw/releases/download/v${CHAINSAW_VERSION}/chainsaw_linux_${TARGETARCH}.tar.gz" \
| tar -xz -C /out chainsaw; \
chmod 0755 /out/kubectl /out/chainsaw; \
/out/kubectl version --client=true --output=yaml >/dev/null; \
/out/chainsaw version >/dev/null
# --- runtime -----------------------------------------------------------------
ARG FEDORA_VERSION=44
FROM registry.fedoraproject.org/fedora-minimal:${FEDORA_VERSION}
ARG CHAINSAW_VERSION=
ARG KUBECTL_VERSION=
ARG FEDORA_VERSION=44
# Nonroot uid/gid. 65532 is the distroless `nonroot` convention; the value is arbitrary but
# it MUST be numeric and non-zero — see "THE ADMISSION CONTRACT" above.
ARG RUNNER_UID=65532
ARG RUNNER_GID=65532
LABEL org.opencontainers.image.title="conformance-runner" \
org.opencontainers.image.description="bash + python3 + chainsaw + kubectl + git for the in-cluster L2/L3 conformance Job (conformance/run.sh)" \
org.opencontainers.image.source="https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl" \
org.opencontainers.image.documentation="conformance/incluster/README.md" \
homelab.keithjackson.dev/requirements="R-TEST-02,R-SEC-02"
# bash: run.sh + every check.sh. python3: the stdlib report generators.
# git-core: the init-container clone (git-core, not git — drops the perl/svn tail).
# coreutils/findutils/grep/sed/gawk: find, sort, base64, xargs, sed -nE, awk in lib/*.sh.
# tar+gzip: the base64-tar results channel. diffutils: `diff` in the L1 golden compare.
# ca-certificates: TLS for the init clone and for kubectl's in-cluster API calls.
RUN set -eux; \
microdnf -y install --setopt=install_weak_deps=0 --nodocs \
bash python3 git-core coreutils findutils grep sed gawk tar gzip diffutils \
ca-certificates; \
microdnf clean all; \
rm -rf /var/cache/* /var/log/dnf*
COPY --from=builder /out/kubectl /out/chainsaw /usr/local/bin/
# The init container's ONLY entrypoint. Copied as a file with a fixed path and NO argument
# surface, because the admission policy pins the main container's `command` but never reads
# `initContainers` — so an init container with `command: ["/bin/sh","-c"]` and an arbitrary
# script is currently ADMITTED and runs as the privileged `conformance-runner` SA (reproduced
# by the policy's author; pass 3, fail 0). A 4th policy rule requiring
# `command: ["/usr/local/bin/conformance-clone"]` with no `args` is what makes this file
# REQUIRED rather than merely available; that rule belongs in its own reviewed PR against the
# boundary, not here. See the script's header for the env-injection half of the same hole.
#
# 0755 root:root: the container runs as 65532 and MUST NOT be able to replace the one
# executable the policy will trust. Ownership is asserted below rather than assumed.
COPY conformance-clone /usr/local/bin/conformance-clone
RUN set -eux; \
chown root:root /usr/local/bin/conformance-clone; \
chmod 0755 /usr/local/bin/conformance-clone; \
bash -n /usr/local/bin/conformance-clone; \
/usr/local/bin/conformance-clone --anything 2>&1 | grep -q 'takes no arguments'; \
test "$(stat -c '%U:%G %a' /usr/local/bin/conformance-clone)" = 'root:root 755'
# The nonroot account is written directly rather than pulling in shadow-utils to run useradd
# once. HOME is /workspace because at runtime that emptyDir is the only writable path.
RUN set -eux; \
echo "runner:x:${RUNNER_UID}:${RUNNER_GID}:conformance runner:/workspace:/bin/bash" >> /etc/passwd; \
echo "runner:x:${RUNNER_GID}:" >> /etc/group; \
install -d -m 0775 -o "${RUNNER_UID}" -g "${RUNNER_GID}" /workspace
# SYSTEM-level config, because HOME is an emptyDir that starts empty on every run and the
# root filesystem is read-only — a runtime `--global` write would fail.
# safe.directory=*: the init-container clones into the shared emptyDir and the main container
# then runs `rev-parse` over it; any uid mismatch between the two would otherwise abort with
# "detected dubious ownership". The repo is fetched fresh per run into an emptyDir, so there
# is no pre-existing hostile checkout this relaxes a check against.
RUN set -eux; \
printf '%s\n' \
'[safe]' \
' directory = *' \
'[advice]' \
' detachedHead = false' \
> /etc/gitconfig; \
chmod 0644 /etc/gitconfig
# Provenance: what is actually in this layer, readable from a running pod.
RUN set -eux; \
{ echo "fedora=${FEDORA_VERSION}"; \
echo "chainsaw=${CHAINSAW_VERSION}"; \
echo "kubectl=${KUBECTL_VERSION}"; \
echo "bash=$(bash --version | head -1)"; \
echo "python3=$(python3 --version)"; \
echo "git=$(git --version)"; \
echo "conformance-clone=$(sha256sum /usr/local/bin/conformance-clone | cut -d' ' -f1)"; \
} > /etc/conformance-runner-versions; \
chmod 0444 /etc/conformance-runner-versions
# Every writable path points into the `workspace` emptyDir — the root filesystem is
# read-only and the admission policy forbids adding a third volume for scratch.
ENV HOME=/workspace \
XDG_CACHE_HOME=/workspace/.cache \
XDG_CONFIG_HOME=/workspace/.config \
TMPDIR=/workspace/.tmp \
PYTHONDONTWRITEBYTECODE=1 \
CONF_ROOT=/workspace/conformance
USER ${RUNNER_UID}:${RUNNER_GID}
WORKDIR /workspace
# No ENTRYPOINT on purpose. The admission policy pins `command: ["./run.sh"]` with
# `workingDir: /workspace/conformance`; an ENTRYPOINT here would either be ignored or
# prepend something the policy cannot see. `bash` is the interactive fallback for a human
# debugging the image locally.
CMD ["/bin/bash"]
+32 -1
View File
@@ -27,7 +27,38 @@ credentials — the Job's own scoped ServiceAccount does (R-SEC-02).
audit item.
- `chainsaw-job.yaml` — the Job skeleton. Single entrypoint `./run.sh`; CI sets `args` to
`--requirements <touched IDs>` (merge gate) or `--layer all --strict-coverage` (phase
exit). Image pinned to `versions.env` `CHAINSAW_IMAGE`.
exit). **Its `image:` is STALE**: it still names `ghcr.io/kyverno/chainsaw:v0.2.12`, which
(a) cannot run `./run.sh` at all and (b) is DENIED at admission. The trigger renders this
template per run with `CONFORMANCE_RUNNER_IMAGE` injected (plan §3 item 4, T-6.1b); until
that lands, treat the literal `image:` here as a placeholder, not a pin.
- `Containerfile` — the **`conformance-runner`** image: `bash` + `python3` + `chainsaw` +
`kubectl` + `git`, non-root (numeric uid 65532) and working under a read-only root
filesystem. This is what the Job actually needs; the stock chainsaw image carries none of
it. Versions are `--build-arg`-injected from `infra/devcontainer/versions.env` — the single
source of truth — so the chainsaw/kubectl in the runner are the same pins the toolbox gate
uses. Read its header before changing the Job's `args`: `run.sh` runs the selected offline
L0/L1 checks for any non-offline-only selection, and those need flux/promtool/kyverno/
kustomize, which this image deliberately omits. Use `--layer l2` / `--layer l3`.
- `conformance-clone` — the init container's **only** entrypoint, baked into the image at
`/usr/local/bin/conformance-clone` (0755 **root:root**, so uid 65532 cannot replace it).
Clones the impl repo at the commit under test: the SHA must match `^[0-9a-f]{40}$` and
`rev-parse HEAD` is **asserted** equal to it afterwards, so an unrendered placeholder is a
hard failure rather than a silent run of the branch tip. It takes **no arguments**, and the
repository URL and clone target are **compiled in** — see its header for why the env vars
are read but may not override them.
> **This file is half a fix.** The admission policy pins the main container's `command` but
> **never reads `initContainers`**, so an init container with `command: ["/bin/sh","-c"]` and
> an arbitrary script is currently ADMITTED and runs as the privileged `conformance-runner`
> SA. What makes this file *required* rather than merely available is a 4th policy rule
> demanding `command: ["/usr/local/bin/conformance-clone"]` with no `args`. That rule belongs
> in its own reviewed PR against the boundary and is **not** in this branch.
- `build-image.sh` — builds the above, **verifies the toolchain by running it** inside the
image as uid 65532 with `--read-only`, and prints the digest. It **never pushes**: it
prints the exact `podman push --digestfile …` command for the operator instead. See
`conformance/versions.env` `CONFORMANCE_RUNNER_IMAGE` for the post-push pin.
`--verify-clone` additionally has `conformance-clone` really clone this repo at a real SHA
and asserts the checkout matches (opt-in, because it needs the Gitea host reachable — the
default verify stays offline so an air-gapped build is fully checked, not half-checked).
## Flow
1. CI (Gitea Actions, delivered in T-6.1b) creates a Job from `chainsaw-job.yaml` (via a
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
###############################################################################
# conformance/incluster/build-image.sh — build the pinned `conformance-runner`
# image and print its digest. Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1.
#
# ./build-image.sh # build + verify + print the digest and push command
# ./build-image.sh --no-verify # build only (skip the in-image tool proof)
# ./build-image.sh --verify-clone # ALSO really clone this repo with conformance-clone.
# # Opt-in because it needs the Gitea host reachable; the
# # default verify is offline so an air-gapped build is
# # still fully checked rather than half-checked.
# ENGINE=docker ./build-image.sh # build with docker instead of podman
#
# THIS SCRIPT NEVER PUSHES. Publishing is an operator action: the registry credential is
# not an agent's to hold, and the pushed digest is what a fail-closed admission policy
# then trusts. So this builds locally, proves the toolchain works INSIDE the image as the
# non-root uid the policy demands, and prints the exact `podman push` line for a human.
#
# VERSIONS ARE NOT DEFINED HERE. They are read from infra/devcontainer/versions.env, the
# single source of truth (CHAINSAW_VERSION, KUBECTL_VERSION, FEDORA_VERSION) — the same
# pins the toolbox gate runs with. Nothing in this file invents a version.
#
# THE DIGEST CAVEAT — read it before pasting a digest into anything.
# The digest printed here is the LOCAL manifest digest. It is normally identical to the
# registry digest (podman pushes the manifest it already has rather than recompressing),
# but that is a property of the push, not a guarantee. So the push command below uses
# `--digestfile`: the digest the registry actually returns is the authoritative one, and
# that is the one that goes into conformance/versions.env.
###############################################################################
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${HERE}/../.." && pwd)"
ENGINE="${ENGINE:-podman}"
VERIFY=1
VERIFY_CLONE=0
for arg in "$@"; do
case "$arg" in
--no-verify) VERIFY=0 ;;
--verify-clone) VERIFY_CLONE=1 ;;
-h|--help) sed -n '2,/^####/p' "${BASH_SOURCE[0]}" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGINE=docker to use docker)"
# --- pins: sourced, never invented -------------------------------------------
TOOLCHAIN_ENV="${REPO_ROOT}/infra/devcontainer/versions.env"
[ -f "${TOOLCHAIN_ENV}" ] || die "missing ${TOOLCHAIN_ENV} — the pins live there, not here"
# shellcheck disable=SC1090
source "${TOOLCHAIN_ENV}"
: "${CHAINSAW_VERSION:?CHAINSAW_VERSION not set in ${TOOLCHAIN_ENV}}"
: "${KUBECTL_VERSION:?KUBECTL_VERSION not set in ${TOOLCHAIN_ENV}}"
: "${FEDORA_VERSION:?FEDORA_VERSION not set in ${TOOLCHAIN_ENV}}"
# --- image name: fixed by the admission policy -------------------------------
# platform/kyverno/admission/conformance-trigger-job-shape.yaml admits ONLY
# gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*. The repository path
# is therefore not a preference; a different path is a DENIED Job. Do not "improve" it here.
IMAGE_REPO="gitea.home.keithjackson.dev/home-network/conformance-runner"
# The tag is build convenience only (a human-readable handle for the push). Admission
# ignores it: only the digest is ever referenced from a Job.
IMAGE_TAG="chainsaw${CHAINSAW_VERSION}-kubectl${KUBECTL_VERSION}-fc${FEDORA_VERSION}"
IMAGE_REF="${IMAGE_REPO}:${IMAGE_TAG}"
log "Building ${IMAGE_REF}"
info "engine ${ENGINE} ($(${ENGINE} --version 2>/dev/null | head -1))"
info "pins from ${TOOLCHAIN_ENV}"
info " CHAINSAW ${CHAINSAW_VERSION}"
info " KUBECTL ${KUBECTL_VERSION}"
info " FEDORA ${FEDORA_VERSION}"
"${ENGINE}" build \
--file "${HERE}/Containerfile" \
--tag "${IMAGE_REF}" \
--build-arg "CHAINSAW_VERSION=${CHAINSAW_VERSION}" \
--build-arg "KUBECTL_VERSION=${KUBECTL_VERSION}" \
--build-arg "FEDORA_VERSION=${FEDORA_VERSION}" \
"${HERE}"
# --- verify: the point of the whole exercise ---------------------------------
# The stock chainsaw image "had" chainsaw and still could not run ./run.sh. So prove the
# tools EXECUTE, and prove they execute as the NON-ROOT uid with a READ-ONLY root
# filesystem — the shape the admission policy forces. A pass here is the only evidence
# this image is usable; a build that merely succeeded is not.
if [ "${VERIFY}" -eq 1 ]; then
log "Verifying the toolchain inside the image (non-root, read-only rootfs)"
"${ENGINE}" run --rm \
--user 65532:65532 \
--read-only \
--tmpfs /workspace:rw,mode=1777 \
--entrypoint "" \
"${IMAGE_REF}" \
/bin/bash -euo pipefail -c '
echo "--- id ---"; id
echo "--- HOME ---"; echo "HOME=${HOME}"
echo "--- bash ---"; bash --version | head -1
echo "--- python3 ---"; python3 --version
echo "--- git ---"; git --version
echo "--- kubectl ---"; kubectl version --client
echo "--- chainsaw ---"; chainsaw version
echo "--- baked versions ---"; cat /etc/conformance-runner-versions
echo "--- conformance-clone: fixed path, root-owned, NOT writable by the runner ---"
stat -c "%n %U:%G %a" /usr/local/bin/conformance-clone
if [ -w /usr/local/bin/conformance-clone ]; then
echo "UNEXPECTED: uid $(id -u) can write the one executable the policy will trust"; exit 1
fi
echo "confirmed: not writable by uid $(id -u)"
# Each of these MUST fail. Assert the non-zero exit as well as the message: a check
# that only greps stderr would pass a build where the guard had stopped rejecting.
# (`|| true` inside the capture keeps `set -e`/`pipefail` from aborting on the
# expected failure — without it the verify dies at the first negative case.)
expect_fail() {
local label="$1"; shift
local out rc=0
out="$(env "$@" 2>&1)" || rc=$?
echo "--- ${label} ---"
printf "%s\n" "${out}" | head -2
if [ "${rc}" -eq 0 ]; then
echo "UNEXPECTED: that should have failed, but it exited 0"; exit 1
fi
echo "(rejected, exit ${rc})"
}
expect_fail "conformance-clone rejects arguments (that is where an injection would live)" \
conformance-clone --layer l2
expect_fail "conformance-clone rejects a missing SHA" \
conformance-clone
expect_fail "conformance-clone rejects a branch name instead of a SHA (no silent run of the tip)" \
CONFORMANCE_GIT_SHA=main conformance-clone
expect_fail "conformance-clone rejects an UNRENDERED placeholder" \
CONFORMANCE_GIT_SHA=@@CONFORMANCE_GIT_SHA@@ conformance-clone
expect_fail "conformance-clone rejects a HOME off the workspace volume" \
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
HOME=/etc conformance-clone
expect_fail "conformance-clone refuses a REDIRECTED repository URL (the env-injection hole)" \
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
CONFORMANCE_REPO_URL=https://evil.invalid/x.git conformance-clone
expect_fail "conformance-clone refuses a clone target off the workspace volume" \
CONFORMANCE_GIT_SHA=0000000000000000000000000000000000000000 \
CONFORMANCE_SRC=/etc/src conformance-clone
echo "--- rootfs is read-only (expected: a failure) ---"
if touch /should-fail 2>/dev/null; then echo "UNEXPECTED: rootfs is writable"; exit 1; fi
echo "confirmed: / is read-only"
echo "--- HOME is writable (the workspace emptyDir) ---"
touch "${HOME}/.probe" && echo "confirmed: ${HOME} is writable"
echo "--- uid is non-zero ---"
[ "$(id -u)" -ne 0 ] && echo "confirmed: running as uid $(id -u), not root"
'
log "Toolchain verified"
fi
# --- verify the clone helper against the REAL repository (opt-in: needs network) ----
# The negative cases above prove conformance-clone refuses bad input. This proves the
# positive case: it lands the EXACT commit asked for and can prove it did. Without this, the
# rev-parse assertion is only an unexercised line of shell.
if [ "${VERIFY_CLONE}" -eq 1 ]; then
log "Verifying conformance-clone against the real repository"
SHA="$(git -C "${REPO_ROOT}" rev-parse HEAD)"
info "requesting commit ${SHA}"
"${ENGINE}" run --rm \
--user 65532:65532 \
--read-only \
--tmpfs /workspace:rw,mode=1777 \
--network host \
--entrypoint "" \
-e "CONFORMANCE_GIT_SHA=${SHA}" \
"${IMAGE_REF}" \
/bin/bash -euo pipefail -c '
conformance-clone
echo "--- assert rev-parse HEAD == the requested SHA ---"
got="$(git -C /workspace/src rev-parse HEAD)"
echo "requested: ${CONFORMANCE_GIT_SHA}"
echo "HEAD: ${got}"
[ "${got}" = "${CONFORMANCE_GIT_SHA}" ] || { echo "MISMATCH"; exit 1; }
echo "confirmed: the working tree is at the requested commit"
test -x /workspace/src/conformance/run.sh && echo "confirmed: run.sh present and executable"
'
log "conformance-clone verified against the real repository"
fi
# --- digest ------------------------------------------------------------------
DIGEST="$("${ENGINE}" image inspect --format '{{.Digest}}' "${IMAGE_REF}")"
IMAGE_ID="$("${ENGINE}" image inspect --format '{{.Id}}' "${IMAGE_REF}")"
SIZE="$("${ENGINE}" image inspect --format '{{.Size}}' "${IMAGE_REF}")"
log "Built ${IMAGE_REF}"
info "local manifest digest ${DIGEST}"
info "image id ${IMAGE_ID}"
info "size $(( SIZE / 1024 / 1024 )) MiB"
cat <<EOF
================================================================================
NOT PUSHED. Publishing is an operator action — this script holds no registry
credential and will never acquire one.
OPERATOR: run these two commands, then paste the digest from the digestfile into
conformance/versions.env (CONFORMANCE_RUNNER_IMAGE) via a PR:
${ENGINE} login gitea.home.keithjackson.dev
${ENGINE} push --digestfile /tmp/conformance-runner.digest \\
${IMAGE_REF}
Then:
cat /tmp/conformance-runner.digest
# -> sha256:.... THIS is the authoritative digest, not the local one above.
# conformance/versions.env
CONFORMANCE_RUNNER_IMAGE="${IMAGE_REPO}@\$(cat /tmp/conformance-runner.digest)"
WHY THE DIGEST AND NOT THE TAG: platform/kyverno/admission/conformance-trigger-job-shape.yaml
is a fail-closed Enforce policy. It DENIES any conformance-trigger Job whose image is not
${IMAGE_REPO}@sha256:<digest>. A tag reference is denied,
and so is any other repository path.
TIGHTEN THE BOUNDARY AFTERWARDS: that policy's header asks for the wildcard
\`${IMAGE_REPO}@sha256:*\` to be replaced with the
LITERAL digest once the image exists, closing the "anyone who can push to this path" gap.
That is a separate, human-reviewed PR against the security boundary.
================================================================================
EOF
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
###############################################################################
# conformance-clone — fetch the impl repo at the exact commit under test.
#
# Baked into the conformance-runner image at /usr/local/bin/conformance-clone, mode 0755
# root:root, so the running container (uid 65532) cannot replace it. The Job's init
# container runs it as `command: ["/usr/local/bin/conformance-clone"]` with NO args.
#
# WHY IT IS A FILE IN THE IMAGE AND NOT AN INLINE SCRIPT IN THE JOB
# -----------------------------------------------------------------
# platform/kyverno/admission/conformance-trigger-job-shape.yaml pins the MAIN container's
# `command` to ["./run.sh"] and constrains its `args` — but it never reads `initContainers`.
# So a Job from `conformance-trigger` could carry an init container with
# `command: ["/bin/sh","-c"]` and an arbitrary script, running as the privileged
# `conformance-runner` SA. Reproduced by the policy's author: such a Job is ADMITTED
# (pass 3, fail 0).
#
# The fix has two halves and NEITHER works alone:
# 1. this file — a fixed path with fixed behaviour and no argument surface, so there is
# nothing for a caller to inject into; and
# 2. a 4th policy rule requiring every initContainer's `command` to be exactly
# ["/usr/local/bin/conformance-clone"] with no `args`. That rule lives in the policy
# and is NOT this branch's to write — a security boundary changes in its own reviewed PR.
# Without (2) this file is merely available, not required.
#
# THE ENV SURFACE IS THE OTHER HALF OF THE SAME HOLE — READ THIS BEFORE "SIMPLIFYING"
# -----------------------------------------------------------------------------------
# The admission policy says NOTHING about `env`. Verified: no `env`, `valueFrom` or
# `secretKeyRef` clause appears in it. So a script that took its repository URL from the
# environment would just move the injection one field over — the trigger sets
# CONFORMANCE_REPO_URL to a repo it controls, this clones it, and `./run.sh` from THAT repo
# executes under the privileged runner SA. Identical shape to the initContainers gap, and
# "no args" would not have stopped it.
#
# Therefore the repository URL and the clone target are COMPILED IN below. The matching env
# vars are still read, so the Job template can keep declaring them and stay self-documenting,
# but a value that DISAGREES with the baked literal is a hard failure, never a redirect.
# The only per-run input that is genuinely trusted is the commit SHA, and it must be 40 hex
# characters of a commit that the baked repository actually contains.
#
# Inputs (environment; no arguments are accepted):
# CONFORMANCE_GIT_SHA REQUIRED. 40 lowercase hex. The commit under test.
# CONFORMANCE_REPO_URL optional. Must equal the baked URL if set.
# CONFORMANCE_SRC optional. Must equal the baked target if set.
# GITEA_USERNAME optional, from Secret `conformance-git-readonly`.
# GITEA_TOKEN optional, from Secret `conformance-git-readonly`.
# TMPDIR / HOME optional; must be under /workspace. Default to /workspace/{tmp,home}.
#
# Runs under: uid 65532, readOnlyRootFilesystem, only the `workspace` and `results` volumes.
# Everything it writes therefore lives under /workspace. There is no writable /tmp.
###############################################################################
set -euo pipefail
# --- COMPILED-IN, NOT CONFIGURABLE (see the header) --------------------------
readonly BAKED_REPO_URL="https://gitea.home.keithjackson.dev/Home-Network/home-network-k3s-impl.git"
readonly BAKED_SRC="/workspace/src"
die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; }
log() { printf '==> %s\n' "$*"; }
[ "$#" -eq 0 ] || die "takes no arguments (got $#: $*). Arguments are where an injection would live."
# --- the one genuinely trusted input ----------------------------------------
# A bad SHA must never degrade into "ran whatever the branch tip was": an unrendered
# @@CONFORMANCE_GIT_SHA@@ placeholder has to be a hard failure, not a silent run of main.
: "${CONFORMANCE_GIT_SHA:?CONFORMANCE_GIT_SHA is not set; the CI trigger did not render it}"
printf '%s' "${CONFORMANCE_GIT_SHA}" | grep -Eq '^[0-9a-f]{40}$' \
|| die "CONFORMANCE_GIT_SHA is not a 40-hex commit id: '${CONFORMANCE_GIT_SHA}'
The CI trigger did not substitute it, or substituted junk."
# --- env that may agree with the baked values, but may not override them -----
REPO_URL="${CONFORMANCE_REPO_URL:-${BAKED_REPO_URL}}"
[ "${REPO_URL}" = "${BAKED_REPO_URL}" ] || die "CONFORMANCE_REPO_URL does not match the image's baked repository.
got: ${REPO_URL}
baked: ${BAKED_REPO_URL}
Refusing. A caller that can choose the repository can choose the code that runs."
SRC="${CONFORMANCE_SRC:-${BAKED_SRC}}"
[ "${SRC}" = "${BAKED_SRC}" ] || die "CONFORMANCE_SRC does not match the image's baked clone target.
got: ${SRC}
baked: ${BAKED_SRC}
Refusing."
# Scratch paths must stay on the workspace volume; the root filesystem is read-only.
# `/workspace` ITSELF is allowed, not just subdirectories: the image's own ENV sets
# HOME=/workspace, and an earlier version of this guard rejected exactly that — it matched
# only `/workspace/*` and so refused its own default. Caught by running it, not by reading it.
: "${TMPDIR:=/workspace/tmp}"
: "${HOME:=/workspace/home}"
on_workspace() { case "$1" in /workspace|/workspace/*) return 0 ;; *) return 1 ;; esac; }
on_workspace "${TMPDIR}" || die "TMPDIR must be /workspace or under it (got '${TMPDIR}')"
on_workspace "${HOME}" || die "HOME must be /workspace or under it (got '${HOME}')"
export TMPDIR HOME
mkdir -p "${TMPDIR}" "${HOME}"
# --- credential (optional) ---------------------------------------------------
# git's shell credential helper is invoked as `sh -c '<value> get'`, so it reads $1 and
# answers from the environment. The token therefore never appears in argv and never on disk.
#
# ANONYMOUS FALLBACK, DELIBERATE AND ANNOUNCED. Read access to this repository currently
# needs no credential (measured: `ls-remote` succeeds unauthenticated from inside this
# image), so requiring the Secret would fail runs that would otherwise work — and would make
# this script untestable without one. It is not a privilege downgrade: the integrity
# guarantee here is the SHA assertion below, not the credential. Which mode was used is
# logged, so a run that silently lost its Secret is visible in the log rather than inferred.
CRED=''
if [ -n "${GITEA_USERNAME:-}" ] && [ -n "${GITEA_TOKEN:-}" ]; then
CRED='!f() { if [ "$1" = get ]; then printf "username=%s\npassword=%s\n" "$GITEA_USERNAME" "$GITEA_TOKEN"; fi; }; f'
log "authenticating as '${GITEA_USERNAME}' (token from the environment, never argv)"
else
log "no GITEA_USERNAME/GITEA_TOKEN in the environment — cloning ANONYMOUSLY"
fi
git_c() {
if [ -n "${CRED}" ]; then git -c credential.helper="${CRED}" "$@"; else git "$@"; fi
}
# --- clone, land the exact commit, and PROVE it ------------------------------
log "shallow clone ${REPO_URL} -> ${SRC}"
git_c clone --depth 1 --no-tags --single-branch "${REPO_URL}" "${SRC}"
if ! git -C "${SRC}" cat-file -e "${CONFORMANCE_GIT_SHA}^{commit}" 2>/dev/null; then
log "${CONFORMANCE_GIT_SHA} is not in the shallow tip; fetching that commit"
git_c -C "${SRC}" fetch --depth 1 --no-tags origin "${CONFORMANCE_GIT_SHA}"
fi
git -C "${SRC}" checkout --detach "${CONFORMANCE_GIT_SHA}"
# The assertion is the point of the whole script: everything above is best-effort plumbing,
# this is what makes "the suite ran at the commit under test" a fact rather than a hope.
head="$(git -C "${SRC}" rev-parse HEAD)"
[ "${head}" = "${CONFORMANCE_GIT_SHA}" ] \
|| die "checked out ${head}, expected ${CONFORMANCE_GIT_SHA}."
test -x "${SRC}/conformance/run.sh" \
|| die "no executable conformance/run.sh at ${head}."
log "suite ready at ${head}"
+53
View File
@@ -6,6 +6,10 @@
# Chainsaw — L1 rendering / L2 behavioral / L3 fault-injection runner (ADR-0011).
CHAINSAW_VERSION="v0.2.12"
# The UPSTREAM chainsaw image. It is NOT what the in-cluster Job runs — see
# CONFORMANCE_RUNNER_IMAGE below. It carries the chainsaw binary and essentially nothing
# else (no bash, no python3, no kubectl, no git), so `command: ["./run.sh"]` on this image
# fails at exec. Kept only as the provenance of the chainsaw binary we rebuild from.
CHAINSAW_IMAGE="ghcr.io/kyverno/chainsaw:v0.2.12"
# kubectl — used by run.sh for cluster reachability preflight and by probe scripts.
@@ -18,3 +22,52 @@ KYVERNO_VERSION="v1.19.0"
# Python 3 — coverage generator + JUnit emitter use stdlib only (no pip deps), so the
# only constraint is a modern interpreter.
PYTHON_MIN="3.9"
# --- In-cluster conformance runner image -------------------------------------
# Built from conformance/incluster/Containerfile by conformance/incluster/build-image.sh.
# This — not CHAINSAW_IMAGE — is what the L2/L3 Job runs (bash + python3 + chainsaw +
# kubectl + git). Closes docs/plans/l2-l3-conformance-ci-wiring.md §3 item 1.
#
# It ALSO carries /usr/local/bin/conformance-clone (0755 root:root, no argument surface) as the
# init container's only entrypoint. That is a security fixture, not a convenience: the
# admission policy never reads `initContainers`, so an init container with an inline shell
# script is currently admitted with the privileged runner SA. The policy rule that makes the
# fixed path REQUIRED is not in this branch — see conformance/incluster/README.md.
#
# THE REPOSITORY PATH IS NOT A PREFERENCE. platform/kyverno/admission/
# conformance-trigger-job-shape.yaml is a fail-closed Enforce ClusterPolicy (R-SEC-02) that
# ADMITS a conformance-trigger Job only when its image matches
# gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:*
# Any other path, and any TAG reference, is DENIED at admission. Do not "tidy" these values.
CONFORMANCE_RUNNER_IMAGE_REPO="gitea.home.keithjackson.dev/home-network/conformance-runner"
# Build handle only. Admission never sees the tag; a Job must reference the digest.
CONFORMANCE_RUNNER_IMAGE_TAG="chainsaw0.2.12-kubectl1.36.4-fc44"
#
# ############################################################################
# # DIGEST DELIBERATELY EMPTY — THE IMAGE HAS NOT BEEN PUBLISHED YET. #
# ############################################################################
# The image has been BUILT and its toolchain verified locally (non-root uid 65532,
# read-only rootfs: bash 5.3.9, Python 3.14.7, git 2.55.0, kubectl v1.36.4, chainsaw 0.2.12,
# and `./run.sh --layer l2 --no-cluster` completing with rc 0). It has NOT been pushed:
# pushing needs a registry credential, and publishing the artifact a fail-closed security
# boundary then trusts is an OPERATOR action, not an agent's.
#
# OPERATOR, to complete the pin:
# 1. ./conformance/incluster/build-image.sh # rebuild, verify, print the commands
# 2. podman login gitea.home.keithjackson.dev
# 3. podman push --digestfile /tmp/conformance-runner.digest \
# "${CONFORMANCE_RUNNER_IMAGE_REPO}:${CONFORMANCE_RUNNER_IMAGE_TAG}"
# 4. Set CONFORMANCE_RUNNER_IMAGE_DIGEST below to `cat /tmp/conformance-runner.digest`
# — the digest the REGISTRY returned, not the local manifest digest the build printed.
# 5. Land steps 4 in a PR (this file is GitOps input, not a live edit).
#
# UNTIL THEN: this is empty on purpose, NOT a placeholder to be guessed at. An empty value
# makes any consumer that renders the Job fail visibly; a fake or stale digest would instead
# produce an ImagePullBackOff that looks like a cluster fault. Do not invent one.
CONFORMANCE_RUNNER_IMAGE_DIGEST=""
CONFORMANCE_RUNNER_IMAGE="${CONFORMANCE_RUNNER_IMAGE_DIGEST:+${CONFORMANCE_RUNNER_IMAGE_REPO}@${CONFORMANCE_RUNNER_IMAGE_DIGEST}}"
#
# AFTERWARDS, TIGHTEN THE BOUNDARY: the header of conformance-trigger-job-shape.yaml asks for
# its `@sha256:*` wildcard to be replaced with the LITERAL digest once the image exists —
# that closes the residual "anyone who can push to this path" gap. Separate PR, human
# reviewed, because it edits the security boundary itself.