#!/usr/bin/env bash # infra/gitea/conformance-trigger-kubeconfig.sh # # OPERATOR script for docs/plans/l2-l3-conformance-ci-wiring.md §3 item 6 (= §5 step 7): # provision the NARROW CI trigger credential for L2/L3 conformance, and — only when the # rest of the trigger path actually exists — arm `vars.CLUSTER_AVAILABLE`. # # What it does, in order: # 1. measures the preconditions (cluster-side AND repo-side) instead of assuming them # 2. mints a bounded-lifetime ServiceAccount token for `conformance-trigger` in ns # `conformance` (kubectl create token -> TokenRequest API) # 3. assembles a kubeconfig containing ONLY that token + the cluster CA + the API server # 4. PROVES the kubeconfig is the narrow identity (auth whoami + an allow/deny matrix) # 5. stores it as the Gitea Actions repo secret CONFORMANCE_TRIGGER_KUBECONFIG, and # VERIFIES the store by reading the secret list back # 6. sets vars.CLUSTER_AVAILABLE=true ONLY under --arm-cluster-available, only with every # precondition green, and only after a typed confirmation. Otherwise it REFUSES. # # NOTHING SECRET IS EVER PRINTED. The token exists in a shell variable and in a 0600 file # under a 0700 temp dir that is removed on exit; it reaches the Gitea API in a request-body # FILE (`--data @file`), never in argv, never on stdout. This script prints names, byte # lengths, HTTP status codes, the token's expiry timestamp, and yes/no authorization answers. # # ---------------------------------------------------------------------------------------- # THREE HARD-WON LESSONS THIS SCRIPT IS BUILT AROUND # ---------------------------------------------------------------------------------------- # 1. GITEA RESERVES THE `GITEA_` PREFIX FOR SECRET NAMES. # `PUT .../actions/secrets/GITEA_ANYTHING` returns HTTP 400 {"message":"invalid secret # name"}. That is why set-ci-token.sh stores SETTINGS_API_TOKEN and not # GITEA_SETTINGS_TOKEN. `CONFORMANCE_TRIGGER_KUBECONFIG` is legal, and is the exact name # .gitea/workflows/{gate.yml,merge-gate.yaml} already reference — do not "tidy" it. # # 2. `curl -sS` EXITS 0 ON AN HTTP ERROR. The first version of set-ci-token.sh sent its PUT # to /dev/null and printed "stored" unconditionally; the secret never existed and nobody # knew for hours. So every write here captures `-w '%{http_code}'`, checks it, AND reads # the object back afterwards. A store is not claimed unless the read-back shows it. # # 3. `CLUSTER_AVAILABLE` IS A LOADED GUN — SEE THE 2026-09-29 CORRECTION IN THE PLAN. # `conformance/run.sh` exits 0 in scaffold mode with all 54 tests `` when it # cannot reach a cluster. Measured on 2026-09-29: with CLUSTER_AVAILABLE=true and no # trigger path, ci/conformance.sh printed "[ ok ] L2/L3 conformance passed" at rc 0 — a # FAKE GREEN on an enforcing required status check. ci/conformance.sh now BLOCKS that # case (exit 1), so today the variable does not enable L2/L3: it only changes which way # the stage fails. Arming it before §3 items 1-4 land therefore buys nothing and breaks # every PR. This script refuses to do it. # # ---------------------------------------------------------------------------------------- # WHY THE PRECONDITIONS ARE SPLIT INTO TWO CLASSES (deliberate, and load-bearing) # ---------------------------------------------------------------------------------------- # MINT-BLOCKING (cluster-side). Kubernetes places NO `use` check on a Job's # `serviceAccountName`, so a token with plain `create jobs` in ns `conformance` can create a # Job running as the PRIVILEGED `conformance-runner` SA with an arbitrary command. The # trigger Role is only the necessary half; the sufficient half is the Kyverno ClusterPolicy # `conformance-trigger-job-shape` (platform/kyverno/admission/), which must be present AND # Enforce AND Ready. Minting this token while that policy is absent or in Audit hands CI a # privileged identity. So those checks block the MINT itself — nothing is created if they # fail. (See conformance/incluster/rbac.yaml's trigger header and plan §4.) # # ARM-BLOCKING (repo-side). The runner image, the Job template's init-container, and # ci/conformance.sh's in-cluster trigger flow (plan §3 items 1, 2, 4) are what make a real # L2/L3 run possible. Storing the kubeconfig secret without them is harmless — the # conformance job is gated on the variable and never starts — and the plan explicitly lands # the secret before the switch. So these checks block ONLY the variable. # # Net effect: you can safely run this script today to provision the secret. It will then # REFUSE to arm the variable, and tell you exactly which repo-side item is missing. # # ---------------------------------------------------------------------------------------- # USAGE # ---------------------------------------------------------------------------------------- # ./infra/gitea/conformance-trigger-kubeconfig.sh --dry-run # Read-only. Measures every precondition, mints nothing, writes nothing. # ./infra/gitea/conformance-trigger-kubeconfig.sh [--server URL] # Mint + store the secret. Does NOT touch CLUSTER_AVAILABLE. # ./infra/gitea/conformance-trigger-kubeconfig.sh --arm-cluster-available # The above, then set vars.CLUSTER_AVAILABLE=true after a typed confirmation — # refused unless every precondition is green. # # Options: # --dry-run measure only; no TokenRequest, no PUT/POST # --arm-cluster-available additionally set vars.CLUSTER_AVAILABLE=true (guarded) # --server URL API server URL to embed (default: the operator context's own) # --context NAME kube context to read the CA/server from (default: k3s-prod) # --duration D requested token lifetime (default: 720h = 30d) # -h | --help this text # # ON --server: the embedded server URL must be reachable FROM THE CI RUNNER POD, which is # not necessarily the address your workstation uses. The runner has broad egress and reaches # the kube-API VIP (per the plan: 192.168.1.49:6443). If your operator kubeconfig points at # a workstation-only name or a localhost tunnel, pass --server explicitly; the script warns # on a loopback server but cannot know your topology for you. # # ON TOKEN LIFETIME: this is a TokenRequest token and it EXPIRES. The API server may cap the # requested duration, so the script decodes the JWT `exp` claim and reports the ACTUAL # expiry rather than trusting --duration. Re-run this script to rotate. After expiry the # trigger gets 401 and the conformance stage FAILS (loudly) rather than skipping — which is # the correct direction, but put the expiry date in your calendar. # # DELIBERATELY NOT DONE: SOPS-sealing the token into secrets/conformance-trigger.sops.yaml # (plan §3 item 6's "for reproducibility/rotation"). A bearer token with a 30-day life # cannot usefully survive the branch -> PR -> review -> merge flow (CLAUDE.md §6), and # committing one buys re-issuance we get for free by re-running this script against the # live SA. If the operator still wants it sealed, that is a separate reviewed change. # # FORBIDDEN HERE, ON PURPOSE: no cluster-admin kubeconfig, no copy of the operator's own # kubeconfig, no widening of conformance/incluster/rbac.yaml, no mutating kubectl beyond the # TokenRequest itself, no `kubectl apply`. set -euo pipefail # xtrace would print the token. Refuse rather than leak. case "$-" in *x*) echo "FATAL: refusing to run under 'set -x' — xtrace would print the token" >&2; exit 1 ;; esac umask 077 HOST=gitea.home.keithjackson.dev OWNER=Home-Network REPO=home-network-k3s-impl # Legal secret name: NOT prefixed GITEA_ (lesson 1), and the name the workflows already use. SECRET=CONFORMANCE_TRIGGER_KUBECONFIG VARIABLE=CLUSTER_AVAILABLE NS=conformance SA=conformance-trigger RUNNER_SA=conformance-runner POLICY=conformance-trigger-job-shape # The image prefix the admission policy admits. Kept in sync with # platform/kyverno/admission/conformance-trigger-job-shape.yaml by the P2 check below. RUNNER_IMAGE_PREFIX='gitea.home.keithjackson.dev/home-network/conformance-runner@sha256:' KCTX=k3s-prod DURATION=720h DRY_RUN=false ARM=false SERVER_OVERRIDE="" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" step() { printf '\n==> %s\n' "$*"; } info() { printf ' %s\n' "$*"; } warn() { printf ' WARN: %s\n' "$*" >&2; } die() { printf 'FATAL: %s\n' "$*" >&2; exit 1; } usage() { cat <<'USAGE' conformance-trigger-kubeconfig.sh — provision the narrow L2/L3 conformance CI trigger credential. --dry-run measure every precondition; mint nothing, write nothing (read-only) --arm-cluster-available additionally set vars.CLUSTER_AVAILABLE=true (guarded + confirmed) --server URL API server URL to embed (default: the operator context's own; must be reachable FROM THE CI RUNNER POD — the kube-API VIP) --context NAME kube context to read the CA/server from (default: k3s-prod) --duration D requested token lifetime (default: 720h) -h | --help this text Read the header of this file before running it: the CLUSTER_AVAILABLE guard, the split between mint-blocking and arm-blocking preconditions, and why nothing secret is ever printed. USAGE } while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY_RUN=true ;; --arm-cluster-available) ARM=true ;; --server) SERVER_OVERRIDE="${2:?--server needs a URL}"; shift ;; --context) KCTX="${2:?--context needs a name}"; shift ;; --duration) DURATION="${2:?--duration needs a value}"; shift ;; -h|--help) usage; exit 0 ;; *) die "unknown argument: $1 (try --help)" ;; esac shift done if $DRY_RUN && $ARM; then die "--dry-run and --arm-cluster-available are contradictory"; fi for t in kubectl jq curl git base64; do command -v "$t" >/dev/null || die "missing required tool: $t" done WORK="$(mktemp -d)" KC="${WORK}/conformance-trigger.kubeconfig" cleanup() { rm -rf "$WORK"; } trap cleanup EXIT trap 'cleanup; exit 130' INT TERM # ======================================================================================== # 0. Gitea credentials — from the credential helper git already has for this host, exactly # as branch-protection.sh / set-ci-token.sh / require-gate.sh do. Never in argv: curl # reads them from a --config file descriptor. # ======================================================================================== creds="$(printf 'protocol=https\nhost=%s\n\n' "$HOST" | git credential fill)" U="$(sed -n 's/^username=//p' <<<"$creds")" P="$(sed -n 's/^password=//p' <<<"$creds")" unset creds [ -n "$U" ] && [ -n "$P" ] || die "no stored credential for ${HOST} (git credential fill returned nothing)" basic() { curl -sS --config <(printf 'user = "%s:%s"\n' "$U" "$P") "$@"; } API="https://${HOST}/api/v1/repos/${OWNER}/${REPO}" # ======================================================================================== # 1. PRECONDITIONS — measured, never assumed (CLAUDE.md §2b: query the thing itself). # Two independent tallies, per the split documented in the header. # ======================================================================================== MINT_FAIL=0 ARM_FAIL=0 pass() { printf ' [ ok ] %s\n' "$*"; } fail_mint() { printf ' [MINT-BLOCK] %s\n' "$*" >&2; MINT_FAIL=$((MINT_FAIL + 1)); } fail_arm() { printf ' [ARM-BLOCK] %s\n' "$*" >&2; ARM_FAIL=$((ARM_FAIL + 1)); } step "preconditions — cluster side (these block the MINT: without them the token IS privileged)" kubectl --context "$KCTX" get --raw /version >/dev/null 2>&1 \ || die "cannot reach the API server via context '${KCTX}' — nothing below can be measured" pass "API server reachable via context ${KCTX}" # C1: the trigger identity exists (created by Flux from conformance/incluster/rbac.yaml via # clusters/base/conformance.yaml). We print object names only, never contents. if kubectl --context "$KCTX" -n "$NS" get serviceaccount "$SA" -o name >/dev/null 2>&1; then pass "ServiceAccount ${NS}/${SA} exists" else fail_mint "ServiceAccount ${NS}/${SA} is ABSENT — is the 'conformance' Flux Kustomization reconciled?" fi for kind in role rolebinding; do if kubectl --context "$KCTX" -n "$NS" get "$kind" "$SA" -o name >/dev/null 2>&1; then pass "${kind} ${NS}/${SA} exists" else fail_mint "${kind} ${NS}/${SA} is ABSENT — the token would authenticate but authorize nothing" fi done # C2: THE SECURITY BOUNDARY. Present, Enforce, and Ready. Anything else and we do not mint. # `validationFailureAction: Audit` is not a boundary; an unready policy is not a boundary. if pol="$(kubectl --context "$KCTX" get clusterpolicy "$POLICY" -o json 2>/dev/null)"; then action="$(jq -r '.spec.validationFailureAction // .spec.rules[0].validate.failureAction // "UNSET"' <<<"$pol")" failpol="$(jq -r '.spec.failurePolicy // "UNSET"' <<<"$pol")" # Kyverno moved readiness from .status.ready to a Ready condition; accept either, and # treat "neither key present" as UNMEASURABLE -> block (fail closed, do not assume ready). ready="$(jq -r ' if (.status.ready != null) then (.status.ready | tostring) elif (.status.conditions != null) then ((.status.conditions[] | select(.type == "Ready") | .status) // "UNKNOWN") else "UNKNOWN" end' <<<"$pol")" info "ClusterPolicy ${POLICY}: validationFailureAction=${action} failurePolicy=${failpol} ready=${ready}" [ "$action" = "Enforce" ] || fail_mint "ClusterPolicy ${POLICY} is '${action}', not Enforce — the trigger would be unbounded" [ "$failpol" = "Fail" ] || fail_mint "ClusterPolicy ${POLICY} failurePolicy='${failpol}', not Fail — a Kyverno outage would admit arbitrary Jobs" case "$ready" in true|True) pass "ClusterPolicy ${POLICY} reports Ready" ;; *) fail_mint "ClusterPolicy ${POLICY} does not report Ready (got '${ready}') — refusing to assume it is loaded" ;; esac else fail_mint "ClusterPolicy ${POLICY} is ABSENT — plan §4's boundary does not exist; a token with 'create jobs' here can run arbitrary code as ${RUNNER_SA}" fi step "preconditions — repo side (these block ONLY arming ${VARIABLE}; the secret may be stored first)" # R1: the runner image is built and digest-pinned. Without it the Job cannot run run.sh at # all (the stock chainsaw image has no bash/python3/kubectl/git) — plan §3 item 1. # EVALUATED, not grepped: the line is `CONFORMANCE_RUNNER_IMAGE="${..._REPO}@${..._DIGEST}"`, so # its literal text never contains 64 hex digits and the grep this replaced could never pass. The # all-zero digest is the "not published yet" placeholder (Renovate replaces it after CI's first # publish, .gitea/workflows/images.yaml) and must not count as pinned. VERSIONS="${REPO_ROOT}/conformance/versions.env" runner_img="$( (set +u; . "$VERSIONS" >/dev/null 2>&1; printf '%s' "${CONFORMANCE_RUNNER_IMAGE:-}") )" if [[ "$runner_img" =~ @sha256:[0-9a-f]{64}$ ]] && [[ ! "$runner_img" =~ @sha256:0{64}$ ]]; then pass "CONFORMANCE_RUNNER_IMAGE is digest-pinned in conformance/versions.env (${runner_img##*@})" else fail_arm "conformance/versions.env has no PUBLISHED digest for CONFORMANCE_RUNNER_IMAGE (got '${runner_img}') — the image pipeline has not published it yet, or Renovate's bump is unmerged (plan §3 item 1)" fi # R2: the Job template is the real thing, not the skeleton: the admitted image AND an # init-container that populates the empty `workspace` volume with the suite at the PR SHA # (plan §3 item 2). A template whose image the policy will not admit is a guaranteed denial. JOBTPL="${REPO_ROOT}/conformance/incluster/chainsaw-job.yaml" if grep -q 'initContainers' "$JOBTPL" 2>/dev/null; then pass "chainsaw-job.yaml declares an initContainers block (workspace population)" else fail_arm "chainsaw-job.yaml has no initContainers — 'workspace' is an empty emptyDir and the suite is never delivered (plan §3 item 2)" fi if grep -q "$RUNNER_IMAGE_PREFIX" "$JOBTPL" 2>/dev/null; then pass "chainsaw-job.yaml uses the digest-pinned image the admission policy admits" else fail_arm "chainsaw-job.yaml does not use ${RUNNER_IMAGE_PREFIX}… — every triggered Job would be DENIED by ${POLICY}" fi # R3: ci/conformance.sh actually drives the in-cluster Job. Today it mentions # CONFORMANCE_TRIGGER_KUBECONFIG only in a TODO comment and otherwise runs run.sh LOCALLY in # the CI job — where there is no credential at all (the runner sets # automountServiceAccountToken: false by design), so it can only scaffold-skip. Hence the # check must ignore comment lines: a mention in prose is not an implementation. # # ONE awk, NOT `grep -v … | grep -q …`: under `set -o pipefail` a successful `grep -q` exits # early, the upstream grep takes EPIPE, and the pipeline reports non-zero — which would read # here as "unwired" exactly when the seam IS wired. A single process cannot have that bug. CONF_STAGE="${REPO_ROOT}/ci/conformance.sh" if awk '!/^[[:space:]]*#/ && /CONFORMANCE_TRIGGER_KUBECONFIG/ { found = 1 } END { exit !found }' \ "$CONF_STAGE" 2>/dev/null; then pass "ci/conformance.sh references CONFORMANCE_TRIGGER_KUBECONFIG in code (in-cluster trigger path wired)" else fail_arm "ci/conformance.sh mentions CONFORMANCE_TRIGGER_KUBECONFIG only in comments — the trigger path is UNIMPLEMENTED (plan §3 item 4); arming ${VARIABLE} now only changes which way the stage fails" fi info "" info "precondition tally: mint-blocking failures=${MINT_FAIL} arm-blocking failures=${ARM_FAIL}" if [ "$MINT_FAIL" -gt 0 ]; then cat >&2 <&2 <}" cur="$(basic -o "${WORK}/var.json" -w '%{http_code}' "${API}/actions/variables/${VARIABLE}")" || true if [ "$cur" = 200 ]; then info "existing ${VARIABLE} = $(jq -r '.data // ""' "${WORK}/var.json")" else info "existing ${VARIABLE}: not set (HTTP ${cur})" fi exit 0 fi # ======================================================================================== # 2. MINT the ServiceAccount token (TokenRequest). This is the only write this script makes # against the cluster, and it creates no durable object. # ======================================================================================== step "minting a ServiceAccount token for ${NS}/${SA} (requested duration ${DURATION})" TOKEN="$(kubectl --context "$KCTX" -n "$NS" create token "$SA" --duration="$DURATION")" \ || die "kubectl create token failed for ${NS}/${SA}" [ -n "$TOKEN" ] || die "kubectl create token returned an empty token" info "got a ${#TOKEN}-character token (value NOT shown)" # Report the ACTUAL expiry from the JWT rather than trusting --duration: the API server caps # TokenRequest lifetimes (--service-account-max-token-expiration) and silently shortens. jwt_exp() { local payload pad b64 payload="$(cut -d. -f2 <<<"$1")" pad=$(( (4 - ${#payload} % 4) % 4 )) b64="$(tr '_-' '/+' <<<"$payload")$(printf '%*s' "$pad" '' | tr ' ' '=')" printf '%s' "$b64" | base64 -d 2>/dev/null | jq -r '.exp // empty' 2>/dev/null || true } exp="$(jwt_exp "$TOKEN")" if [ -n "$exp" ]; then info "token expires: $(date -u -d "@${exp}" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || echo "epoch ${exp}") — re-run this script to rotate" else warn "could not decode the token's exp claim; lifetime is UNVERIFIED (requested ${DURATION})" fi # ======================================================================================== # 3. ASSEMBLE the kubeconfig — SA token + cluster CA + server. Nothing else. # # The CA and the server URL are read from the operator's context, but the operator's # client certificate/key are NEVER copied: only .cluster fields are extracted. `config # view --raw` output is held in a variable and discarded, never printed. # Built with jq (not `kubectl config set-credentials --token=…`) so the token never # appears in argv where `ps` can see it. # ======================================================================================== step "assembling the kubeconfig (SA token only — never cluster-admin, never the operator's)" raw="$(kubectl --context "$KCTX" config view --raw --minify -o json)" \ || die "could not read context '${KCTX}' from the operator kubeconfig" SERVER="$(jq -r '.clusters[0].cluster.server // empty' <<<"$raw")" CADATA="$(jq -r '.clusters[0].cluster["certificate-authority-data"] // empty' <<<"$raw")" CAFILE="$(jq -r '.clusters[0].cluster["certificate-authority"] // empty' <<<"$raw")" INSECURE="$(jq -r '.clusters[0].cluster["insecure-skip-tls-verify"] // false | tostring' <<<"$raw")" unset raw [ -n "$SERVER" ] || die "context '${KCTX}' has no cluster.server" if [ -n "$SERVER_OVERRIDE" ]; then info "overriding server ${SERVER} -> ${SERVER_OVERRIDE}" SERVER="$SERVER_OVERRIDE" fi case "$SERVER" in *localhost*|*127.0.0.1*|*://0.0.0.0*) die "server '${SERVER}' is loopback — the CI runner pod cannot reach it. Pass --server with the address the runner uses (the kube-API VIP)." ;; esac if [ -z "$CADATA" ] && [ -n "$CAFILE" ]; then [ -r "$CAFILE" ] || die "certificate-authority file '${CAFILE}' is not readable" CADATA="$(base64 -w0 <"$CAFILE")" fi [ -n "$CADATA" ] || die "no cluster CA available for '${KCTX}' (insecure-skip-tls-verify=${INSECURE}); refusing to emit a kubeconfig that trusts anything" [ "$INSECURE" = "false" ] || die "context '${KCTX}' sets insecure-skip-tls-verify; refusing to propagate that into CI" info "server: ${SERVER}" info "CA: ${#CADATA} base64 characters embedded (public certificate, not a secret)" jq -n --arg srv "$SERVER" --arg ca "$CADATA" --arg tok "$TOKEN" --arg ns "$NS" --arg sa "$SA" ' { apiVersion: "v1", kind: "Config", clusters: [ { name: "conformance", cluster: { server: $srv, "certificate-authority-data": $ca } } ], users: [ { name: $sa, user: { token: $tok } } ], contexts: [ { name: "conformance", context: { cluster: "conformance", user: $sa, namespace: $ns } } ], "current-context": "conformance" }' >"$KC" chmod 600 "$KC" info "kubeconfig written to the temp dir: $(wc -c <"$KC") bytes (content NOT shown)" # Prove kubectl can PARSE the file we generated (JSON is valid YAML, but assert it rather # than assume it) — this prints only the server we already printed. parsed="$(kubectl --kubeconfig "$KC" config view -o jsonpath='{.clusters[0].cluster.server}')" \ || die "kubectl cannot parse the generated kubeconfig" [ "$parsed" = "$SERVER" ] || die "generated kubeconfig parses to server '${parsed}', expected '${SERVER}'" pass "kubectl parses the generated kubeconfig" # ======================================================================================== # 4. PROVE it is the narrow identity. Two independent proofs: # a) who the token authenticates AS (SelfSubjectReview) # b) an allow/deny matrix (SelfSubjectAccessReview) — both halves matter: "can create # jobs" proves it works, "cannot get secrets / delete jobs / exec" proves it is not # something wider that happens to include the trigger rights. # All read-only. `auth can-i` answers from the API server's own authorizer. # ======================================================================================== step "verifying the credential is the NARROW trigger identity" EXPECT_USER="system:serviceaccount:${NS}:${SA}" if who="$(kubectl --kubeconfig "$KC" auth whoami -o jsonpath='{.status.userInfo.username}' 2>/dev/null)"; then [ "$who" = "$EXPECT_USER" ] || die "token authenticates as '${who}', expected '${EXPECT_USER}' — WRONG CREDENTIAL, nothing stored" pass "authenticates as ${who}" else warn "'kubectl auth whoami' unavailable (older kubectl / SelfSubjectReview denied) — identity UNVERIFIED by this check; the matrix below still constrains it" fi # A real authorization round-trip, not just a review: `list jobs` is granted by the Role. kubectl --kubeconfig "$KC" -n "$NS" get jobs -o name >/dev/null \ || die "the minted credential cannot list Jobs in ns ${NS} — the RoleBinding is not effective; nothing stored" pass "can list Jobs in ns ${NS} (end-to-end authentication + authorization)" cani() { # cani local want="$1"; shift local got got="$(kubectl --kubeconfig "$KC" auth can-i "$@" 2>/dev/null || true)" case "$got" in yes|no) : ;; *) die "auth can-i '$*' returned '${got:-}' — cannot measure the identity's rights; refusing to store an unverified credential" ;; esac if [ "$got" = "$want" ]; then pass "can-i $* -> ${got} (expected)" else die "can-i $* -> ${got}, expected ${want}. This credential is NOT the narrow trigger identity described in conformance/incluster/rbac.yaml. Nothing stored. Do NOT widen that Role to make this pass — investigate what it is actually bound to." fi } # Granted by the trigger Role (create/get/list/watch jobs; get/list/watch pods + pods/log). cani yes create jobs.batch -n "$NS" cani yes get pods -n "$NS" cani yes get pods/log -n "$NS" # Explicitly NOT granted — each is a specific escape route the Role's comments call out. cani no delete jobs.batch -n "$NS" # a trigger that can delete can hide a failed run cani no create pods/exec -n "$NS" # no execution channel into the runner pod cani no get secrets -n "$NS" cani no create jobs.batch -n default # namespaced Role: no reach outside ns conformance cani no get nodes # the RUNNER has nodes/patch; the trigger must not cani no '*' '*' --all-namespaces # not cluster-admin, stated as a measurement # ======================================================================================== # 5. STORE as the Gitea Actions repo secret, then VERIFY BY READING IT BACK (lesson 2). # The body goes in a 0600 file so the token is not in argv. # ======================================================================================== step "storing the kubeconfig as repo Actions secret ${SECRET}" jq -n --rawfile d "$KC" '{data:$d}' >"${WORK}/body.json" chmod 600 "${WORK}/body.json" code="$(basic -o "${WORK}/put.out" -w '%{http_code}' -X PUT \ -H 'Content-Type: application/json' --data @"${WORK}/body.json" \ "${API}/actions/secrets/${SECRET}")" unset TOKEN rm -f "${WORK}/body.json" "$KC" if [ "$code" != 201 ] && [ "$code" != 204 ]; then # Safe to echo: the response body is Gitea's error message, not our payload. HTTP 400 # "invalid secret name" here means the name hit the reserved GITEA_ prefix (lesson 1). die "PUT ${SECRET} failed: HTTP ${code} -- $(head -c 300 "${WORK}/put.out" 2>/dev/null)" fi info "PUT returned HTTP ${code}" # curl -sS exits 0 on an HTTP error, so the status check above is necessary but NOT # sufficient: read the object back. Gitea never returns secret VALUES, so this proves # PRESENCE BY NAME only — say so rather than implying the content was checked. names="$(basic "${API}/actions/secrets" | jq -rc '[.. | objects | select(has("name")) | .name] | unique')" grep -q "$SECRET" <<<"$names" \ || die "PUT reported HTTP ${code} but ${SECRET} is NOT in ${names} — refusing to claim success" pass "stored and VERIFIED PRESENT by read-back: ${names}" info "(read-back proves the secret EXISTS; Gitea never returns secret values, so its CONTENT" info " is proven only by the first CI run that uses it — see the NOTE below.)" info "" info "FOR WHOEVER WRITES plan §3 item 4: this secret holds the kubeconfig CONTENT, not a path." info "The plan's sketch writes 'kubectl --kubeconfig=\$CONFORMANCE_TRIGGER_KUBECONFIG', which" info "would treat it as a filename. ci/conformance.sh must write the env var into a 0600 temp" info "file (umask 077, trap-removed) and pass THAT path — an Actions secret is a string, so a" info "kubeconfig cannot reach CI any other way." # ======================================================================================== # 6. vars.CLUSTER_AVAILABLE — the loaded gun. Only here, only on request, only confirmed. # ======================================================================================== if ! $ARM; then cat < ${VARIABLE} NOT TOUCHED (no --arm-cluster-available). This is the safe default. The secret is in place; the conformance job stays gated off until the variable is set. That is plan §5's order: land items 1-6, prove them, and only then flip step 7's switch. EOF else step "arming vars.${VARIABLE}=true — every precondition above is green" cat </dev/null)); trying PUT (variable may already exist)" vcode="$(basic -o "${WORK}/var.out" -w '%{http_code}' -X PUT \ -H 'Content-Type: application/json' \ --data "$(jq -n --arg n "$VARIABLE" '{name:$n,value:"true"}')" \ "${API}/actions/variables/${VARIABLE}")" fi [ "$vcode" = 201 ] || [ "$vcode" = 204 ] \ || die "could not set ${VARIABLE}: HTTP ${vcode} -- $(head -c 300 "${WORK}/var.out" 2>/dev/null)" info "write returned HTTP ${vcode}" # Unlike a secret, a variable's value IS readable — so verify the VALUE, not just presence. # gate.yml compares `vars.CLUSTER_AVAILABLE == 'true'`, so it must be exactly "true". gcode="$(basic -o "${WORK}/varget.json" -w '%{http_code}' "${API}/actions/variables/${VARIABLE}")" [ "$gcode" = 200 ] || die "stored HTTP ${vcode} but GET ${VARIABLE} returned HTTP ${gcode} — refusing to claim success" val="$(jq -r '.data // .value // empty' "${WORK}/varget.json")" [ "$val" = "true" ] || die "${VARIABLE} reads back as '${val}', not 'true' — gate.yml compares against 'true' exactly" pass "${VARIABLE}=${val} stored and VERIFIED by read-back" fi cat <