VMs did not boot on the dev cluster. Three distinct live-verified causes, all fixed GitOps-side: 1. containerDisk root failed at OCI init: exec "/container-disk-binary/usr/bin/container-disk": no such file. KubeVirt v1.9 supplies that binary to the volumerootdisk init container via a k8s-native `image` volume (VolumeSource.Image), gated by the ALPHA `ImageVolume` feature gate. k3s v1.31 has it DISABLED, so the apiserver silently prunes the image volume + its mount -> binary missing -> crash. (Proved: a server-side dry-run of a pod with an `image` volume drops it.) Fix: convert the VM roots in all three tests to CDI DataVolumes imported onto Longhorn PVCs (mounted disks bypass the container-disk/image-volume path). This is also the spec-required disk model (docs/06, R-STATE-01). vm-lifecycle now PASSES green live (VMI Running on start + restart). 2. Guest dropped to emergency mode: the containerdisks/fedora:41 image is UEFI-native (ESP + /boot mounted via systemd GPT auto-discovery); under KubeVirt's SeaBIOS default the ESP+/boot by-uuid mounts time out -> local-fs.target fails -> cloud-init runcmd/poweroff never runs. Fix: boot the VMs with EFI (OVMF, secureBoot:false) in all three tests and fixtures/vm.yaml. Confirmed live: emergency mode gone with EFI. 3. VMs ran under TCG software emulation (guests ~10x slow, saturating nodes until one went NotReady and VMs were evicted). Cause: useEmulation:true in the KubeVirt CR makes virt-controller NOT request devices.kubevirt.io/ kvm, so /dev/kvm is never mounted and qemu falls back to TCG even though all nodes advertise kvm=1k / hypervisor=true. (Proved: `ls /dev/kvm` in a virt-launcher compute container = "No such file"; qemu accel = tcg.) Fix: useEmulation:false so the kvm device is requested and VMs run on real (nested) KVM. Requires Flux reconcile to take effect live (PM action). The virt-handler clientcertificates tls.crt error is UNRELATED: node-labeller (294 labels, hypervisor=true), device plugins, and VMI scheduling all work. Also raised test exec timeouts to 30m (CDI import + boot) and, in vm-default-deny, import the root BEFORE applying the default-deny NetworkPolicy so the CDI importer's egress to quay.io is not severed by the policy. Co-Authored-By: Claude Opus 4.8 <[email protected]>
Conformance tests — layout & requirement-tagging convention
Every test is a Chainsaw Test in its own directory:
tests/
l1-rendering/ L1 — ServiceDefinition -> rendered resources match golden
l2-behavioral/ L2 — real runtime behavior (probes against a cluster)
l3-fault-injection/ L3 — break something; assert fail-closed / no-data-loss
meta/ harness self-checks (R-TEST-*), cluster-independent
<test-name>/chainsaw-test.yaml
The layer a test belongs to is its subtree and its .../layer annotation; the
coverage generator cross-checks the two.
Requirement-tagging convention (authoritative)
A test declares the requirement(s) it verifies three ways; the label is the source of truth, the annotations must agree with it (the coverage generator fails on drift):
metadata:
name: default-deny
labels:
# ONE label per verified R-* ID. Machine-selectable + coverage source of truth.
conformance.home.k3s/R-NET-01: "true"
annotations:
# Human-readable canonical list — MUST equal the label set.
conformance.home.k3s/requirements: "R-NET-01"
# Declared layer (L1|L2|L3|META) — must match the subtree.
conformance.home.k3s/layer: "L2"
# true => R-TEST-05 requires this to be (or have) an L3 fault-injection test.
conformance.home.k3s/safety-critical: "false"
# Given/When/Then intent, for the coverage report and reviewers.
conformance.home.k3s/intent: "Given ... When ... Then ..."
Why a label per requirement (not a comma list): Kubernetes label values can't hold
a comma-separated list, and chainsaw test --selector selects on label keys/values.
One conformance.home.k3s/<R-ID>=true label per requirement makes both subset-selection
and coverage extraction exact and robust.
How this drives the tooling
- Subset runs (merge gate):
run.sh --requirements R-NET-01,R-NET-06resolves the R-IDs to the set of test dirs carrying those labels (union, deduped) and runs each once. - Coverage (
lib/coverage.py): reads these labels from everychainsaw-test.yaml, maps each registry requirement -> its verifying test(s), and flags gaps + any label/annotation mismatch + tags not in the registry. - JUnit: results (real or skipped-scaffold) carry the R-IDs in
classname+ properties, so the JUnit itself stays traceable (R-TEST-04).
Authoring rules (R-TEST-01/02/06)
- Behavior, not existence. Assert what the system does (a probe cannot connect), never that an object merely exists (a policy can exist and not enforce).
- Given/When/Then structure with
given-*/when-*/then-*step names. - Hermetic & self-cleaning. Rely on Chainsaw's per-test namespace + auto-teardown;
put any out-of-namespace fixture cleanup in a
finallyblock. A leaked fixture is a failure. - Bounded, deterministic waits. Use assert/error polling within the config timeouts;
never
sleep-and-hope. - Safety-critical => fault-injection. For a safety-critical requirement, actually
break the thing and assert fail-closed / no-data-loss, with
finallyrestoring state. - Never weaken a test to go green (R-TEST-06). If the spec can't be met, escalate to the PM — do not relax the assertion or the requirement.
Scaffolds in this tree carry TODO(T-x.y) markers where a fixture/assertion lands as a
later roadmap phase delivers the capability under test.