feat(sec): provision the narrow conformance CI trigger credential -- and refuse to arm a fake green
docs/plans/l2-l3-conformance-ci-wiring.md §3 item 6, as an operator script:
infra/gitea/conformance-trigger-kubeconfig.sh. UNEXECUTED here -- minting a
cluster credential and changing Gitea variables are operator actions.
It mints a token for conformance/conformance-trigger (TokenRequest), wraps ONLY
that token with the cluster CA and server into a kubeconfig, and stores it as the
Actions secret CONFORMANCE_TRIGGER_KUBECONFIG. Never cluster-admin, never a copy
of the operator's own kubeconfig. The token is never printed and never reaches
argv: it goes into a 0600 file and into the PUT body FILE (--data @file), and the
script refuses to run under `set -x` because xtrace would print it.
Two traps this repo already paid for are designed around:
* GITEA_ is a RESERVED secret-name prefix (HTTP 400 "invalid secret name"), so
the name is CONFORMANCE_TRIGGER_KUBECONFIG -- which is also what gate.yml and
merge-gate.yaml already reference.
* `curl -sS` exits 0 on an HTTP error. Every write checks %{http_code} AND reads
the object back. The secret read-back proves presence by NAME only (Gitea
never returns secret values) and the script says so rather than implying its
content was checked; the variable read-back asserts the value is exactly
"true", which is what gate.yml compares against.
PRECONDITIONS ARE SPLIT IN TWO, and the split is the security argument:
mint-blocking (cluster) -- SA/Role/RoleBinding present, and ClusterPolicy
conformance-trigger-job-shape present + Enforce + failurePolicy Fail + Ready.
Any failure and NOTHING IS MINTED. Kubernetes places no `use` check on a Job's
serviceAccountName, so a `create jobs` token in that namespace IS the privileged
conformance-runner identity unless admission bounds its shape. RBAC cannot close
that (plan §4); minting before the boundary is live hands CI the escalation the
boundary exists to close.
arm-blocking (repo) -- digest-pinned CONFORMANCE_RUNNER_IMAGE, an initContainer
plus the admitted image in chainsaw-job.yaml, and CONFORMANCE_TRIGGER_KUBECONFIG
referenced in ci/conformance.sh CODE rather than a comment. These block only
vars.CLUSTER_AVAILABLE, because storing the secret early is harmless (the job is
gated off) and is the plan's own order.
All three arm-blocking checks FAIL in the tree today, so a live run would store
the secret and then REFUSE to arm CLUSTER_AVAILABLE, naming each missing item.
That is the intended behaviour: measured 2026-09-29, CLUSTER_AVAILABLE=true with
no trigger path scaffold-skipped all 54 tests at rc 0 and the stage printed "L2/L3
conformance passed". ci/conformance.sh now blocks that case, so arming early does
not enable L2/L3 -- it only breaks every PR. Arming needs the explicit flag, every
precondition green, and a typed confirmation on a TTY.
Before storing anything, the credential is PROVEN narrow: auth whoami must be
system:serviceaccount:conformance:conformance-trigger, a real `get jobs` round-trip
must succeed, and an auth can-i matrix asserts both halves -- granted (create/get
jobs, get pods, get pods/log) and DENIED (delete jobs, create pods/exec, get
secrets, create jobs in default, get nodes, '*' '*' --all-namespaces). A mismatch
aborts before any store, and says explicitly not to widen
conformance/incluster/rbac.yaml to make it pass.
Verified only by `bash -n` (clean) and reading. The Gitea variables API shape
(POST creates, PUT updates, body field `value` not `data`) is from documentation,
not measurement: the script tries POST then PUT, prints the response body on
failure, and treats the read-back as the authority. Kyverno readiness is accepted
from either .status.ready or a Ready condition, and "neither present" blocks
rather than assuming ready.
Gate (pinned toolbox): MERGE GATE: UNDETERMINED (rc 3) -- spec-immutability and
gitea-settings unrun, the expected local result.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
1 parent
c59fe9f923
commit
19f9a8ae0c
2 files changed
+628
No files matched your search
@@ -112,6 +112,59 @@ the stage goes **green on a real PR**, and only then set `REQUIRE_GITEA_SETTINGS
|
||||
**Changing the declaration is how you change policy.** If the live state is what you now want,
|
||||
edit this JSON in the same commit — deliberately, in review — rather than letting the two drift.
|
||||
|
||||
## `conformance-trigger-kubeconfig.sh` — the narrow L2/L3 trigger credential
|
||||
|
||||
Implements `docs/plans/l2-l3-conformance-ci-wiring.md` §3 item 6 (= §5 step 7): mint a
|
||||
ServiceAccount token for `conformance/conformance-trigger`, wrap **only that token** in a
|
||||
kubeconfig, store it as the Actions secret `CONFORMANCE_TRIGGER_KUBECONFIG`, and — separately,
|
||||
and only on request — set `vars.CLUSTER_AVAILABLE=true`.
|
||||
|
||||
./infra/gitea/conformance-trigger-kubeconfig.sh --dry-run # measure only; writes nothing
|
||||
./infra/gitea/conformance-trigger-kubeconfig.sh # mint + store the secret
|
||||
./infra/gitea/conformance-trigger-kubeconfig.sh --arm-cluster-available # + the variable
|
||||
|
||||
Never a cluster-admin kubeconfig, never a copy of the operator's own. The token is never
|
||||
printed: it goes from the TokenRequest API into a 0600 file, into the request **body file**
|
||||
(`--data @file`, so not even into `ps`), and nowhere else. The script refuses to run under
|
||||
`set -x`.
|
||||
|
||||
**Its preconditions are split in two, and the split is the point.**
|
||||
|
||||
| Class | Checks | Blocks |
|
||||
|---|---|---|
|
||||
| **mint-blocking** (cluster) | SA/Role/RoleBinding exist; ClusterPolicy `conformance-trigger-job-shape` is present, `Enforce`, `failurePolicy: Fail`, Ready | the **mint** — nothing is created |
|
||||
| **arm-blocking** (repo) | digest-pinned `CONFORMANCE_RUNNER_IMAGE`; `chainsaw-job.yaml` has an init-container and the admitted image; `ci/conformance.sh` names the trigger kubeconfig in **code, not a comment** | only `CLUSTER_AVAILABLE` |
|
||||
|
||||
Why mint-blocking: Kubernetes puts **no `use` check on a Job's `serviceAccountName`**, so a
|
||||
token that can `create jobs` in `conformance` can run arbitrary code as the privileged
|
||||
`conformance-runner` SA. RBAC cannot close that; the Kyverno policy is the boundary. Minting
|
||||
while it is absent or in `Audit` hands CI the privileged identity, so the script will not do it.
|
||||
|
||||
Why arm-blocking is separate: storing the secret before the runner image / Job template / CI
|
||||
trigger flow exist is harmless (the job is gated off), and is the plan's intended order. So
|
||||
running this today provisions the secret and then **refuses to arm the variable**, naming the
|
||||
missing item.
|
||||
|
||||
**`CLUSTER_AVAILABLE` is not a "turn conformance on" switch.** Measured 2026-09-29: set with no
|
||||
trigger path, `run.sh` scaffold-skipped all 54 tests at rc 0 and `ci/conformance.sh` printed
|
||||
"L2/L3 conformance passed" — a fake green on an enforcing required check. `ci/conformance.sh`
|
||||
now blocks that case, so arming early does not enable L2/L3; it only breaks every PR. Hence:
|
||||
`--arm-cluster-available` plus all preconditions green plus a typed confirmation on a TTY, or
|
||||
nothing happens.
|
||||
|
||||
Both writes are verified the way `set-ci-token.sh` learned to: check `%{http_code}` **and**
|
||||
read the object back. For the secret that proves presence by name only (Gitea never returns
|
||||
secret values); for the variable it proves the value is exactly `true`, which is what
|
||||
`gate.yml` compares against.
|
||||
|
||||
The token **expires** — it is a TokenRequest token, and the script decodes the JWT `exp` claim
|
||||
to report the real expiry rather than trusting `--duration` (the API server caps it). Re-run to
|
||||
rotate. After expiry the trigger gets 401 and the stage fails loudly rather than skipping.
|
||||
|
||||
`--server` exists because the embedded API-server URL must be reachable **from the CI runner
|
||||
pod** — the VIP — which is not necessarily the address your workstation uses. The script
|
||||
refuses a loopback server outright.
|
||||
|
||||
### Why these scripts exist at all, and what is still missing
|
||||
|
||||
Gitea's repo settings live in **its own database**, not in anything Flux reconciles or the
|
||||
|
||||
Reference in new issue
Block a user