The CNPG alert unit tests (324 lines, mutation-checked) were run by NOTHING. promtool was
not in the baked toolchain, no gate stage evaluated any *.promtool-test.yaml, and the only
runner `curl | tar -xz`'d a promtool release from GitHub at TEST-RUN time if one was
missing. Hours later a second, independent harness landed for the Velero rules
(platform/monitoring/tests/) with its own fallback — a `podman run` of
quay.io/prometheus/prometheus. So the repo had two harnesses, two acquisition paths, and
coverage that looked present and was enforced nowhere: the exact failure mode that had just
produced a permanently-firing CNPG alert and a structurally inert Velero alert.
PROMTOOL IS NOW PINNED LIKE EVERY OTHER TOOL. PROMTOOL_VERSION=3.14.0 in
infra/devcontainer/versions.env and ci/versions.env (the two stay in lockstep, per the
repo's rule), baked by the Containerfile from the prometheus release tarball (promtool ships
inside it), recorded in /etc/dev-toolchain-versions, passed by dev.sh, and installed for the
l0 stage by .gitea/workflows/scripts/install-tools.sh so the CI runner is not permanently
SKIPPED. The pin is not free-floating: promtool IS the rule engine, so it tracks the
Prometheus prod actually runs — verified against the live cluster, not assumed:
kubectl --context k3s-prod get prometheus -n monitoring -o jsonpath='{.items[*].spec.version}'
-> v3.14.0-distroless (image quay.io/prometheus/prometheus:v3.14.0-distroless)
ONE HARNESS. platform/cnpg/run-alert-tests.sh is folded into
platform/monitoring/tests/run.sh, which now covers every PrometheusRule in the repo that has
tests, via two conventions it discovers automatically: `<manifest>.promtool-test.yaml`
beside its manifest (cnpg), and `<component>/tests/*.test.yaml` over that component's
manifests (monitoring). It writes the flattened rules under whatever name each test file's
own `rule_files:` declares, so no test file needs to know the harness exists, and it
`promtool check rules` first — a rules file that does not parse would make every unit test
pass vacuously by finding no rules at all. The cnpg script's reasoning is preserved: why a
CR must be flattened (`.spec.groups`, never re-typed), and why generated files go to a temp
dir. Its `${BASE_DOMAIN}` substitution is dropped in favour of extract_rules.py's verbatim
placeholders, with the trade-off recorded there — the hazard it guarded against is addressed
by the temp dir, and verbatim leaves one transformation fewer between the shipped rule and
the rule under test.
NO ENGINE IS ACQUIRED AT RUN TIME any more — not the GitHub release, not the container
image. Not because a pinned download is unusual here (the Containerfile downloads every tool
it bakes) but because those two decided the version, the network and the machine SILENTLY,
at test time, on whatever host ran the tests; and each quietly turned "the toolchain has no
promtool" into a green run, which is how the toolchain stayed without one. Missing promtool
is exit 3 (UNWIRED, never green); a PROMTOOL= that does not resolve is exit 2.
ONE WIRING: conformance/tests/l0-static/alert-rules-unit-tested, NOT a gate stage. The
`prom-rules` stage (ci/prom-rules.sh + its Makefile target + ci/gate.sh entry) is deleted:
it is the bespoke-per-check pattern b9f8807 had just removed from ci/l0.sh, and the
declarative replacement gives what a hand-written stage cannot — selection by
`conformance.home.k3s/scope: "whole-repo"` (asserted: the test is selected even under a
requirement filter matching nothing), requirement-attributed JUnit/coverage/ledger evidence,
and run_l1_offline.py's rule that a SKIP of a scope-declaring check is the runner's rc 3.
The scope declaration is load-bearing here: touched-requirements.sh keys apps/* to nothing
and platform/monitoring/* to R-OBS-01/02, never R-OBS-04, so a new untested PrometheusRule
would otherwise be gated by nothing.
The check asserts the pins and their lockstep, that the image bakes and records promtool,
that the promtool IN USE reports exactly the pin (a FAILURE, not a skip: the wrong engine
still prints SUCCESS), that exactly ONE harness exists and none fetches an engine, that
every rule-unit test file passes, and — on every run — that the harness CAN fail, by putting
a synthetic rule through it twice with one expectation mutated.
MISSING TESTS ARE A RATCHET, deliberately, and the reasoning is in the check's header. 51 of
58 alert rules have no unit test. Failing on all of them makes the gate red until ~51 tests
exist, so it gets disabled and protects nothing; merely reporting is exactly how the CNPG
tests came to be enforced by nothing. So the gap is DECLARED in the check as a shrink-only
register, printed with counts every run (13 alert files nothing tests at all; 35 named
alerts inside covered files that nothing asserts on), and it cannot grow: a new
PrometheusRule, or a new alert in an already-covered file, FAILS without a test, and a stale
entry FAILS too. A missing test is never an exit-3 skip — SKIPPED is reserved for the one
genuine environment gap, promtool being absent.
PROVEN, in the rebuilt image (promtool 3.14.0):
* both suites pass through the single harness: cnpg 6 rules, monitoring 36 rules, SUCCESS.
* the check passes: 95 assertions, 7/58 alert rules unit-tested, 13 declared gap files.
* NOT VACUOUS, twice over. Shortening CNPGPrimariesCoLocated's `for: 30m` to 5m turns it
red (pending expected at 25m/29m, got firing), reverted. Adding an untested
PrometheusRule (platform/kubevirt/probe-alerts.yaml) turns it red on the ratchet,
removed. The harness's own mutation self-test runs on every invocation.
* the scope mechanism selects it: `run.sh --layer l0 --requirements r-nosuchreq-99
--include-scope whole-repo --print-selection` lists alert-rules-unit-tested.
Co-Authored-By: Claude Opus 5 <[email protected]>
166 lines
9.2 KiB
Bash
Executable File
166 lines
9.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# infra/devcontainer/dev.sh — build & enter the pinned dev toolbox.
|
|
#
|
|
# Mounts the repo so OpenTofu (bpg/proxmox) and Ansible drive the Proxmox-hosted k3s cluster
|
|
# from inside the container. Rootless podman by default (ENGINE=docker to switch); SELinux
|
|
# label is disabled for the run. kubectl/flux/helm reach the kube-API DIRECTLY via the pfSense
|
|
# NAT (192.168.2.50:6443 -> kube-vip VIP 192.168.1.49:6443) — no SSH tunnel; the kubeconfig
|
|
# validates TLS against the VIP via tls-server-name. The pfSense SSH NAT (2250/2251/2252) is
|
|
# retained ONLY for Ansible node access, not for the kube-API.
|
|
#
|
|
# ./dev.sh build # build the image only
|
|
# ./dev.sh # build (if needed) + open a shell
|
|
# ./dev.sh run tofu -version # run one command in the toolbox
|
|
# ./dev.sh run kubectl get nodes # talks to the PROD k3s cluster directly (no tunnel)
|
|
#
|
|
# CHOOSING A CLUSTER — `CLUSTER=<prod|dev>`, defaulting to PROD (dev was decommissioned):
|
|
#
|
|
# ./dev.sh run kubectl get nodes # prod (the default since 2026-09-12)
|
|
# CLUSTER=dev ./dev.sh run kubectl get nodes # dev — DECOMMISSIONED, warns; VMs are down
|
|
# ./dev.sh # interactive shell pointed at prod
|
|
#
|
|
# The selector is an env-var PREFIX so the target is visible on the same line as the command, and
|
|
# every invocation prints a banner naming the cluster it is about to talk to. Inside the container
|
|
# KUBECONFIG is a two-file CHAIN, "<per-invocation selector>:<shared merged config>":
|
|
#
|
|
# * the shared merged config holds BOTH clusters under unambiguous names — contexts `k3s-dev`
|
|
# and `k3s-prod`, never `default` — so `--context k3s-prod` actually works, and it carries NO
|
|
# current-context of its own;
|
|
# * the selector is a private per-invocation file holding only `current-context: k3s-$CLUSTER`.
|
|
#
|
|
# So `kubectl config current-context` still answers the "which cluster am I on?" question honestly,
|
|
# but the answer is now PER INVOCATION. That is the point: the previous design stamped
|
|
# current-context into the one shared merged file, so two concurrent dev.sh runs with different
|
|
# CLUSTER values overwrote each other's default MID-COMMAND — a `CLUSTER=prod` script could read
|
|
# prod for its first half and DEV for its second. See infra/devcontainer/kubeconfig.sh for the full
|
|
# write-up, and for why the naive `KUBECONFIG=dev:prod` merge is dangerous in the first place (both
|
|
# source files name everything `default`).
|
|
#
|
|
# Concurrency note: `CLUSTER=dev ./dev.sh run ...` and `CLUSTER=prod ./dev.sh run ...` may now run
|
|
# at the same time and neither can affect the other's default cluster.
|
|
#
|
|
# Requesting a cluster whose kubeconfig is missing is a hard ERROR — it never falls back to the
|
|
# other cluster.
|
|
set -euo pipefail
|
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
REPO_ROOT="$(cd "${HERE}/../.." && pwd)"
|
|
IMAGE="${IMAGE:-home-k3s-dev:local}"
|
|
ENGINE="${ENGINE:-podman}"
|
|
# shellcheck disable=SC1091
|
|
source "${HERE}/versions.env"
|
|
|
|
# k3s API access: the dev cluster runs on Proxmox, and its kube-API is reached DIRECTLY over
|
|
# the pfSense NAT (192.168.2.50:6443 -> kube-vip VIP 192.168.1.49:6443). No SSH tunnel is
|
|
# opened. infra/ansible/.cache/dev.kubeconfig targets https://192.168.2.50:6443 with
|
|
# tls-server-name=192.168.1.49 so TLS validates against the VIP's SAN through the NAT.
|
|
# Prod is reached the same way via infra/ansible/.cache/prod.kubeconfig (https://192.168.2.9:6443).
|
|
|
|
# ------------------------------------------------------------------ cluster selection
|
|
# Defaults to PROD because dev was decommissioned on 2026-09-12 (full migration: dev out, prod
|
|
# onto the apex — see DECISIONS-LOG). Defaulting to a cluster that no longer answers made every
|
|
# unprefixed `./dev.sh run kubectl ...` a silent no-op against a dead context, which is a trap:
|
|
# the banner named dev, the command hung or errored, and nothing said "that cluster is gone".
|
|
# `CLUSTER=dev` is still ACCEPTED, not removed — the dev VM disks were deliberately kept so the
|
|
# environment is reversible — but it warns, because a working dev cluster is no longer the default
|
|
# assumption anywhere in this repo.
|
|
CLUSTER="${CLUSTER:-prod}"
|
|
case "${CLUSTER}" in
|
|
prod) ;;
|
|
dev) printf '\033[33mWARNING: CLUSTER=dev — dev was DECOMMISSIONED 2026-09-12; this context will not\nanswer unless the dev VMs have been deliberately brought back up.\033[0m\n' >&2 ;;
|
|
*) printf '\033[31mERROR: CLUSTER=%s is not a known cluster (dev|prod).\033[0m\n' "${CLUSTER}" >&2; exit 2 ;;
|
|
esac
|
|
CLUSTER_KUBECONFIG="${REPO_ROOT}/infra/ansible/.cache/${CLUSTER}.kubeconfig"
|
|
|
|
build() {
|
|
"$ENGINE" build \
|
|
--build-arg FEDORA_VERSION="$FEDORA_VERSION" \
|
|
--build-arg OPENTOFU_VERSION="$OPENTOFU_VERSION" \
|
|
--build-arg AGE_VERSION="$AGE_VERSION" \
|
|
--build-arg SOPS_VERSION="$SOPS_VERSION" \
|
|
--build-arg FLUX_VERSION="$FLUX_VERSION" \
|
|
--build-arg KUBECTL_VERSION="$KUBECTL_VERSION" \
|
|
--build-arg HELM_VERSION="$HELM_VERSION" \
|
|
--build-arg KYVERNO_VERSION="$KYVERNO_VERSION" \
|
|
--build-arg KYVERNO_JSON_VERSION="$KYVERNO_JSON_VERSION" \
|
|
--build-arg CHAINSAW_VERSION="$CHAINSAW_VERSION" \
|
|
--build-arg YAMLLINT_VERSION="$YAMLLINT_VERSION" \
|
|
--build-arg YQ_VERSION="$YQ_VERSION" \
|
|
--build-arg CHECK_JSONSCHEMA_VERSION="$CHECK_JSONSCHEMA_VERSION" \
|
|
--build-arg PROMTOOL_VERSION="$PROMTOOL_VERSION" \
|
|
-t "$IMAGE" -f "${HERE}/Containerfile" "${HERE}"
|
|
}
|
|
|
|
run() {
|
|
# Persist the SOPS age keyring on the HOST so the age PRIVATE key generated by
|
|
# bootstrap/sops/age-keygen.sh survives this --rm container (otherwise it lives only
|
|
# in the ephemeral container fs and is lost on exit — you'd be locked out of every
|
|
# SOPS-encrypted secret). SOPS_AGE_KEY_FILE defaults to ~/.config/sops/age/keys.txt,
|
|
# which is this mount. Create it first so the bind source exists.
|
|
mkdir -p "${HOME}/.config/sops/age"
|
|
# Spec repo (home-network-k3s) mount, read-only, for ci/spec-immutability.sh (R-TEST-06).
|
|
# That script reads SPEC_REPO (default ${REPO_ROOT}/../home-network-k3s); inside the
|
|
# container REPO_ROOT is /work, so the default resolves to /home-network-k3s. Mount the
|
|
# sibling spec repo there RO when it exists; skip the mount otherwise so the container
|
|
# still runs (the gate then honestly SKIPS immutability — exit 3, never faked green).
|
|
local -a spec_mount=()
|
|
local spec_host
|
|
spec_host="$(cd "${REPO_ROOT}/../home-network-k3s" 2>/dev/null && pwd || true)"
|
|
if [ -n "${spec_host}" ] && [ -d "${spec_host}/.git" ]; then
|
|
spec_mount=(-v "${spec_host}:/home-network-k3s:ro" -e SPEC_REPO=/home-network-k3s)
|
|
fi
|
|
|
|
# Which cluster, said out loud, before anything runs. CLUSTER can be inherited from an exported
|
|
# shell variable, so the banner is what stops "I thought I was on dev".
|
|
if [ -n "${DEV_KUBECONFIG:-}" ]; then
|
|
# SUPERSEDED but still honoured: DEV_KUBECONFIG used to be the only way to reach a cluster other
|
|
# than the (then non-existent) default dev path. It wins over CLUSTER so nothing that depends on
|
|
# it breaks, but it is opaque about its target — prefer CLUSTER=dev|prod.
|
|
printf '\033[33m==> dev.sh: KUBECONFIG pinned by DEV_KUBECONFIG=%s (CLUSTER=%s ignored; DEV_KUBECONFIG is superseded — use CLUSTER=dev|prod)\033[0m\n' \
|
|
"${DEV_KUBECONFIG}" "${CLUSTER}" >&2
|
|
else
|
|
# Fail here, on the host, before a container is even started: a missing kubeconfig must never
|
|
# degrade into "whatever config we could find", because that is how a prod command lands on dev.
|
|
if [ ! -f "${CLUSTER_KUBECONFIG}" ]; then
|
|
printf '\033[31mERROR: dev.sh: CLUSTER=%s but its kubeconfig is MISSING: %s\n Refusing to run. dev.sh does NOT fall back to the other cluster.\033[0m\n' \
|
|
"${CLUSTER}" "${CLUSTER_KUBECONFIG}" >&2
|
|
exit 1
|
|
fi
|
|
printf '\033[1m==> dev.sh: cluster=%s (kube context k3s-%s)\033[0m\n' "${CLUSTER}" "${CLUSTER}" >&2
|
|
fi
|
|
|
|
"$ENGINE" run --rm -it \
|
|
--network host \
|
|
--security-opt label=disable \
|
|
-v "${REPO_ROOT}:/work" -w /work \
|
|
"${spec_mount[@]}" \
|
|
-v "${HOME}/.ssh:/root/.ssh:ro" \
|
|
-v "${HOME}/.config/sops:/root/.config/sops" \
|
|
-v "${HOME}/.config/home-k3s/ansible-vault-pass:/root/.config/home-k3s/ansible-vault-pass:ro" \
|
|
-e CLUSTER="${CLUSTER}" \
|
|
-e DEV_KUBECONFIG="${DEV_KUBECONFIG:-}" \
|
|
"$IMAGE" bash -lc '
|
|
set -e
|
|
if [ -n "${DEV_KUBECONFIG:-}" ]; then
|
|
export KUBECONFIG="${DEV_KUBECONFIG}"
|
|
else
|
|
# Regenerated every run: cheap, and it means an updated dev/prod.kubeconfig is picked up
|
|
# without anyone remembering to refresh a hand-made merged file. Exits non-zero (set -e)
|
|
# if the requested cluster is missing — no silent fallback.
|
|
#
|
|
# This prints a KUBECONFIG CHAIN ("<per-invocation selector>:<shared merged config>"), not a
|
|
# single path. Export it verbatim; do not split it or take only the last element, or this
|
|
# invocation loses its private default context and inherits whatever is shared.
|
|
KUBECONFIG="$(/work/infra/devcontainer/kubeconfig.sh "${CLUSTER}")"
|
|
export KUBECONFIG
|
|
fi
|
|
if [ "$#" -gt 0 ]; then exec "$@"; else exec bash -l; fi
|
|
' dev.sh "$@"
|
|
}
|
|
|
|
case "${1:-shell}" in
|
|
build) build ;;
|
|
run) shift; run "$@" ;;
|
|
shell) build; shift 2>/dev/null || true; run ;;
|
|
*) build; run "$@" ;;
|
|
esac
|