fix(golden): give the runner the kvm group and a world-accessible /dev/kvm
Stacked on #615 (fix/agentenv-guest-privileges). Base of this PR is that branch.
Why
Nested KVM is what the AgentENV bake's validation probe and every KVM job open. The guest's /dev/kvm stayed root:root 0600, and the runner account was not in kvm, so uid 1001 could not open the device. The probe runs through aenv exec.
Two separate problems:
- Mode. Group membership alone does not reach a process started by
aenv exec: it carries only the uid's own group (/proc/self/statusGroups: 1001 1001). The node has to be world-accessible. Hosted runners ship exactly that:KERNEL=="kvm", GROUP="kvm", MODE="0666". - Membership. The runner was not in
kvmin the golden. The cause is not established (see below), so this change reports the reason instead of hiding it.
What
Golden contract (golden_contract_steps), last two steps:
kvm_udev_rule_script: writes99-preloop-kvm.ruleswithMODE="0666"for any guest that runs udev.runner_kvm_access_script(user, "/dev/kvm"): creates groupkvmif missing, hands/dev/kvmto it at mode0666, then adds the runner withusermod -aG(falling back togpasswd -a). It checks membership in/etc/groupand warns if the runner is still absent. Every step is idempotent. A step that cannot land writes apreloop: warning:line to stderr; the contract still exits 0, so the bake continues.
Engine:
apply_golden_contractlogs the contract's stderr as a warning when the contract succeeds. Before this, those lines were discarded.- Each job launch (
as_runner_userprovisioning) runs the same access script after the docker group. A golden that lost the membership or the grant still gives job steps the group and the node.
Not established
Why the membership was missing in the golden. Ruled out:
/etc/gshadowinconsistency: on Ubuntu 24.04,usermod -aGsucceeds when the group exists only in/etc/group(checked in a throwaway container).- A later step rewriting groups: nothing after the account step removes memberships. Every later writer is append-only (
usermod -aG dockerin the golden and at each launch).
Remaining candidate: usermod failed in the golden with its error discarded by 2>/dev/null || true. The new warning carries that error into the engine log on the next bake.
Tests
Run in preloop-orchestrator's library suite:
runner_kvm_access_opens_the_device_to_every_process: mode 0666, group, device, then membership, in that order; nogpasswdunlessusermodrefuses.runner_kvm_access_is_idempotent_across_bootsrunner_kvm_access_falls_back_to_gpasswd_when_usermod_refusesrunner_kvm_membership_failure_is_reported_not_swallowedrunner_kvm_access_without_a_device_still_adds_membershiprunner_kvm_access_reports_a_refused_group_and_still_sets_the_modekvm_udev_rule_grants_the_device_at_mode_0666kvm_membership_is_the_last_group_write_in_the_golden_contractrunner_launch_adds_kvm_membership_after_dockerevery_generated_script_parses_as_posix_shcovers both new scripts.
Notes
- Changing the contract changes the golden fingerprint for configured images, so existing bakes rebuild. Intended.
- The grant is applied while the golden is baked, and the per-launch step re-asserts it, so a restored snapshot keeps working even if its devtmpfs node is recreated.
Test status (cpane, cargo test --release -p preloop-orchestrator --lib)
- Single-threaded: 150 of 151 pass. All of this change's tests pass.
- The one failure,
lifecycle_tests::sudo_wrapper_strict_variant_preserves_failures, is host policy and predates this change: on the test hostsudo -n trueis allowed whilesudo -n sh -c 'exit 0'is refused, and the test assumes the two agree. The wrapper is unchanged here. - In the parallel run,
environment::tests::fingerprint_tracks_the_official_golden_sourcecan fail. It passes alone and single-threaded. Five tests setPRELOOP_GOLDEN_URLwithout serialising against it, and the official source reads that variable. The same five calls exist on the base commit, so this predates the change. - End-to-end: the generated golden contract was run in a clean
ubuntu:24.04container as root (dash). Exit 0;runneris inkvmin/etc/group;/dev/kvmisroot:kvmmode0666; the udev rule is written. The container has no/etc/sudoers.d, so the contract prints one harmless line for the sudoers drop-in; the golden image ships sudo.
Summary by cubic
Makes /dev/kvm accessible to the runner so KVM jobs and the bake's validation probe can open the device.
The guest's /dev/kvm was root:root 0600 and the runner was not in the kvm group, so uid 1001 could not open it. The golden contract now hands the node to group kvm at mode 0666 (as hosted runners ship it), adds the runner to kvm via usermod -aG with a gpasswd fallback, and writes a udev rule for guests that run udev. Each job launch re-asserts the group and the grant after the docker group.
- Membership alone is insufficient:
aenv execgives its processes only the uid's own group, so the node must be world-accessible. - The contract no longer swallows step failures; its stderr is logged as a warning, and per-step warnings identify what could not land.
- The contract still exits 0 when a KVM step fails, so the bake proceeds.
Existing bakes will rebuild because the golden contract changed.