From 9f0704799bed58809d893fd6d7e99a1163a58054 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 28 Aug 2026 01:02:25 -0400 Subject: [PATCH] feat(runtime): fill MicroVMRuntime lifecycle methods (RIG-2493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit U4 of the frozen microVM Runner V2b plan (docs/designs/infra/runtime/compass-elastic-session-runtime/microvm-v2b-guest-supervisor-exec.md, Plan U4 / (c)/(d)/(e)). Fills the eight typed-error MicroVMRuntime stubs behind their frozen ContainerRuntime signatures against the V2a boot harness + the U3 GuestExec layer. ### Socket-path budget (CI fix, folded) `e2eConfig` built its runroot with `t.TempDir()`, whose path embeds the test-function name. Under the 39-char `TestMicroVMExecStreamingKillSignalsExit` the widest per-session socket path — `/microvm/<32-hex id>/virtiofsd.sock`, a 56-byte tail — reached 114 bytes, over the 107-byte Linux AF_UNIX `sun_path` cap, so virtiofsd's `bind(2)` failed EINVAL, the socket never appeared, and the boot timed out (`… did not appear within 10s`). `TestMicroVMLifecycleEndToEnd` (28-char name, 103 bytes) fit and booted. Production runroots are short and startup-budget-checked (`run.go` validateRuntimeDir); this is a pure harness artifact. Fix: `e2eConfig` uses a short `os.MkdirTemp("", "cvm")` runroot (cleaned via `t.Cleanup`), keeping the worst-case path well under the cap. Refs RIG-2493 Co-authored-by: Matt Wilkinson --- go/cmd/compass-runner/main.go | 54 +- go/internal/runtime/microvm.go | 122 ++-- go/internal/runtime/microvm/launch.go | 56 +- .../runtime/microvm/launch_teardown_test.go | 43 ++ go/internal/runtime/microvm_lifecycle.go | 642 ++++++++++++++++++ .../runtime/microvm_lifecycle_microvm_test.go | 220 ++++++ go/internal/runtime/microvm_lifecycle_test.go | 376 ++++++++++ go/internal/runtime/microvm_test.go | 44 +- 8 files changed, 1423 insertions(+), 134 deletions(-) create mode 100644 go/internal/runtime/microvm_lifecycle.go create mode 100644 go/internal/runtime/microvm_lifecycle_microvm_test.go create mode 100644 go/internal/runtime/microvm_lifecycle_test.go diff --git a/go/cmd/compass-runner/main.go b/go/cmd/compass-runner/main.go index 030872a33..e6dfc97a0 100644 --- a/go/cmd/compass-runner/main.go +++ b/go/cmd/compass-runner/main.go @@ -16,6 +16,7 @@ import ( "log/slog" "os" "os/signal" + "strconv" "strings" "syscall" @@ -172,7 +173,8 @@ func run() error { // backendFlags holds the runtime-backend selection flags, registered on the // default flag set before flag.Parse and resolved into a runtime after it. type backendFlags struct { - backend, vmm, virtiofsd, kernel, rootfs *string + backend, vmm, virtiofsd, kernel, rootfs, initrd, runRoot *string + cpus, memoryMB *int } // registerBackendFlags declares the backend-selection flags. Call before @@ -190,19 +192,41 @@ func registerBackendFlags() backendFlags { "Path to the guest kernel image (microvm backend). Defaults to $COMPASS_MICROVM_KERNEL."), rootfs: flag.String("microvm-rootfs", "", "Path to the guest rootfs image (microvm backend). Defaults to $COMPASS_MICROVM_ROOTFS."), + initrd: flag.String("microvm-initrd", "", + "Path to the guest initramfs image (microvm backend). Defaults to $COMPASS_MICROVM_INITRD."), + runRoot: flag.String("microvm-runroot", "", + "Root dir for per-session microVM runtime dirs (microvm backend). Defaults to $COMPASS_MICROVM_RUNROOT."), + cpus: flag.Int("microvm-cpus", 0, + "Default vCPU count per session guest (microvm backend); 0 leaves the VMM default. "+ + "Defaults to $COMPASS_MICROVM_CPUS."), + memoryMB: flag.Int("microvm-memory-mb", 0, + "Default guest RAM in MiB per session (microvm backend); 0 leaves the VMM default. "+ + "Defaults to $COMPASS_MICROVM_MEMORY_MB."), } } // selectEngine resolves the configured runtime backend from the parsed flags // and their environment fallbacks. func (f backendFlags) selectEngine() (runtime.ContainerRuntime, error) { + cpus, err := intOrEnv(*f.cpus, "COMPASS_MICROVM_CPUS") + if err != nil { + return nil, err + } + memoryMB, err := intOrEnv(*f.memoryMB, "COMPASS_MICROVM_MEMORY_MB") + if err != nil { + return nil, err + } return runtime.SelectBackend(runtime.BackendConfig{ Backend: orEnv(*f.backend, "COMPASS_RUNTIME_BACKEND"), MicroVM: runtime.MicroVMConfig{ - VMMPath: orEnv(*f.vmm, "COMPASS_MICROVM_VMM"), - VirtiofsdPath: orEnv(*f.virtiofsd, "COMPASS_MICROVM_VIRTIOFSD"), - KernelImage: orEnv(*f.kernel, "COMPASS_MICROVM_KERNEL"), - RootfsImage: orEnv(*f.rootfs, "COMPASS_MICROVM_ROOTFS"), + VMMPath: orEnv(*f.vmm, "COMPASS_MICROVM_VMM"), + VirtiofsdPath: orEnv(*f.virtiofsd, "COMPASS_MICROVM_VIRTIOFSD"), + KernelImage: orEnv(*f.kernel, "COMPASS_MICROVM_KERNEL"), + RootfsImage: orEnv(*f.rootfs, "COMPASS_MICROVM_ROOTFS"), + InitrdImage: orEnv(*f.initrd, "COMPASS_MICROVM_INITRD"), + RunRoot: orEnv(*f.runRoot, "COMPASS_MICROVM_RUNROOT"), + DefaultCPUs: cpus, + DefaultMemoryMB: memoryMB, }, }) } @@ -215,6 +239,26 @@ func orEnv(flagVal, envKey string) string { return os.Getenv(envKey) } +// intOrEnv returns flagVal when non-zero, else the named environment variable +// parsed as an int. An empty env var is 0 (unset — the config treats 0 as +// "leave the VMM default"); a present-but-non-numeric env var is an error +// naming the offending variable and value so a misconfiguration surfaces at +// startup rather than as a zero silently swallowing a typo. +func intOrEnv(flagVal int, envKey string) (int, error) { + if flagVal != 0 { + return flagVal, nil + } + raw := os.Getenv(envKey) + if raw == "" { + return 0, nil + } + parsed, err := strconv.Atoi(raw) + if err != nil { + return 0, fmt.Errorf("$%s=%q is not an integer: %w", envKey, raw, err) + } + return parsed, nil +} + // parseEgress parses the comma-separated allowlist into a validated EgressPolicy. // An empty list is a valid default-deny policy (no host reachable). func parseEgress(csv string) (runtime.EgressPolicy, error) { diff --git a/go/internal/runtime/microvm.go b/go/internal/runtime/microvm.go index 5cca5bb52..4cb563909 100644 --- a/go/internal/runtime/microvm.go +++ b/go/internal/runtime/microvm.go @@ -1,27 +1,27 @@ package runtime -// microvm.go is the microVM ContainerRuntime backend seam: a MicroVMRuntime -// that satisfies the same ContainerRuntime interface as PodmanCLI, plus the -// config-driven backend selection the Runner startup uses to choose between -// them. Every runtime method is a typed-error stub here — the in-guest control -// plane that boots a VMM, wires the virtiofs share, and speaks the agent -// protocol over vsock lands later, behind these frozen signatures. Selecting -// the microVM backend today therefore fails loudly at first use rather than -// silently faking container behavior. +// microvm.go is the microVM ContainerRuntime backend seam: the operator config, +// the MicroVMRuntime type + its per-session state table, and the config-driven +// backend selection the Runner startup uses to choose between the microVM and +// podman backends. The lifecycle method bodies — which boot a VMM, wire the +// virtiofs share, and speak the guest control plane over vsock — live in +// microvm_lifecycle.go behind a //go:build unix tag, because the microvm +// package they call (Launch/GuestExec/VM) is itself unix-only. This file holds +// only what backend selection needs to type-check on any platform: the config +// structs, the type declaration, and SelectBackend. import ( - "context" - "errors" "fmt" "strings" - "time" + "sync" ) // MicroVMConfig is the operator-supplied wiring for the microVM backend: the -// paths to the VMM and virtiofs daemon binaries and to the guest kernel and -// rootfs images. Empty fields are tolerated at construction — the values are -// consumed when the in-guest control plane lands, and the V5 preflight names -// any missing one at startup rather than deep in a launch. +// paths to the VMM and virtiofs daemon binaries, the guest boot images, the +// per-session runtime-dir root, and the default guest sizing. Empty fields are +// tolerated at construction — the values are consumed when a session boots, and +// the V5 preflight names any missing one at startup rather than deep in a +// launch. type MicroVMConfig struct { // VMMPath is the path to the virtual machine monitor binary. VMMPath string @@ -32,6 +32,21 @@ type MicroVMConfig struct { KernelImage string // RootfsImage is the path to the guest root filesystem image. RootfsImage string + // InitrdImage is the path to the guest initramfs image. Load-bearing, not + // optional: the pinned generic kernel ships its virtio/erofs/overlay drivers + // as modules, so the initrd is what loads them and mounts the root before + // switch_root (microvm-v2a §(a)). + InitrdImage string + // RunRoot is the root under which each session's runtime dir is created + // (/microvm//), holding that session's AF_UNIX sockets — + // the layout V7 formalizes with pidfiles. + RunRoot string + // DefaultCPUs is the vCPU count each session guest boots with (hotplug-grown + // later per D5). Zero leaves it to the VMM's own default. + DefaultCPUs int + // DefaultMemoryMB is the RAM each session guest boots with, in MiB + // (hotplug-grown later per D5). Zero leaves it to the VMM's own default. + DefaultMemoryMB int } // BackendConfig selects and configures the container runtime backend. Backend @@ -45,74 +60,29 @@ type BackendConfig struct { MicroVM MicroVMConfig } -// ErrMicroVMNotImplemented is returned by every MicroVMRuntime method until the -// in-guest control plane lands. The full ContainerRuntime surface is frozen on -// the type now (so backend selection can choose it and no interface change -// lands later); the VMM boot, virtiofs share, and vsock agent transport behind -// each verb are still to come, so invoking one today is a programming error the -// sentinel names explicitly rather than a silent no-op that would fake a -// container operation that never happened. -var ErrMicroVMNotImplemented = errors.New("runtime: MicroVMRuntime is not implemented until the in-guest control plane lands") - // MicroVMRuntime is a ContainerRuntime that isolates each agent in its own -// microVM instead of a rootless container. It holds the microVM wiring the -// in-guest control plane will consume; its methods are typed-error stubs until -// that lands. +// microVM instead of a rootless container. It holds the operator wiring plus a +// per-session state table (keyed by the ContainerID Create mints), guarded by +// mu against concurrent lifecycle calls. Its method bodies live in +// microvm_lifecycle.go (//go:build unix); the microvmSession type they operate +// on is declared there too. type MicroVMRuntime struct { config MicroVMConfig + mu sync.Mutex + // sessions maps each live ContainerID to its session state. Every read and + // write is guarded by mu. Name lookups (Exists, duplicate-name refusal) scan + // this map for a matching spec.Name — a scan is cheap at one-VM-per-session + // scale and keeps a single source of truth. + sessions map[ContainerID]*microvmSession } // NewMicroVMRuntime builds a MicroVMRuntime from the supplied config, mirroring -// NewPodmanCLI's shape. +// NewPodmanCLI's shape, with an empty session table ready for Create. func NewMicroVMRuntime(cfg MicroVMConfig) *MicroVMRuntime { - return &MicroVMRuntime{config: cfg} -} - -var _ ContainerRuntime = (*MicroVMRuntime)(nil) - -// Create is unimplemented until the in-guest control plane lands. -func (m *MicroVMRuntime) Create(_ context.Context, _ ContainerSpec) (ContainerID, error) { - return "", ErrMicroVMNotImplemented -} - -// Start is unimplemented until the in-guest control plane lands. -func (m *MicroVMRuntime) Start(_ context.Context, _ ContainerID) error { - return ErrMicroVMNotImplemented -} - -// Exec is unimplemented until the in-guest control plane lands. -func (m *MicroVMRuntime) Exec(_ context.Context, _ ContainerID, _ ExecSpec) (ExecOutput, error) { - return ExecOutput{}, ErrMicroVMNotImplemented -} - -// ExecStreaming is unimplemented until the in-guest control plane lands. -func (m *MicroVMRuntime) ExecStreaming(_ context.Context, _ ContainerID, _ StreamingExecSpec) (*StreamingExec, error) { - return nil, ErrMicroVMNotImplemented -} - -// Stop is unimplemented until the in-guest control plane lands. -func (m *MicroVMRuntime) Stop(_ context.Context, _ ContainerID, _ time.Duration) error { - return ErrMicroVMNotImplemented -} - -// Remove is unimplemented until the in-guest control plane lands. -func (m *MicroVMRuntime) Remove(_ context.Context, _ ContainerID) error { - return ErrMicroVMNotImplemented -} - -// Exists is unimplemented until the in-guest control plane lands. -func (m *MicroVMRuntime) Exists(_ context.Context, _ string) (bool, error) { - return false, ErrMicroVMNotImplemented -} - -// MountLabel is unimplemented until the in-guest control plane lands. -func (m *MicroVMRuntime) MountLabel(_ context.Context, _ ContainerID) (string, error) { - return "", ErrMicroVMNotImplemented -} - -// Resize is unimplemented until the in-guest control plane lands. -func (m *MicroVMRuntime) Resize(_ context.Context, _ ContainerID, _ ResourceLimits) error { - return ErrMicroVMNotImplemented + return &MicroVMRuntime{ + config: cfg, + sessions: make(map[ContainerID]*microvmSession), + } } // SelectBackend chooses the container runtime backend from cfg. An empty or diff --git a/go/internal/runtime/microvm/launch.go b/go/internal/runtime/microvm/launch.go index b8171c9c6..9f50998ad 100644 --- a/go/internal/runtime/microvm/launch.go +++ b/go/internal/runtime/microvm/launch.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "syscall" "time" @@ -69,7 +70,7 @@ type child struct { name string cmd *exec.Cmd logPath string - waited bool // set once cmd.Wait has returned, so liveness probes and PSS skip a reaped process + waited atomic.Bool // set once cmd.Wait has returned, so liveness probes and PSS skip a reaped process } // VM is a running (or partially-started, on the Launch error path) guest and @@ -82,6 +83,12 @@ type VM struct { virtiofsd *child // nil under the net-only smoke (no --fs) passt *child + // vmmExited is closed by the sole VMM reaper (started in launch) once the + // cloud-hypervisor process has been Wait'd, so the caller can observe a + // prompt guest self-power-off instead of a zombie-blind Signal(0) poll. Nil + // only under the hermetic fail-closed path (no VMM on PATH). + vmmExited chan struct{} + consolePath string // --serial file: the guest serial console vsockSocket string // host end of the hybrid vsock (empty under the net-only smoke) @@ -215,6 +222,16 @@ func launch(ctx context.Context, cfg BootConfig, opts launchOptions) (_ *VM, err if startErr := startChild(vm.vmm); startErr != nil { return nil, fmt.Errorf("microvm: starting cloud-hypervisor: %w", startErr) } + // The sole VMM reaper owns the single cmd.Wait for cloud-hypervisor: it + // unblocks WaitVMMExit on a guest self-power-off and lets Shutdown observe + // the exit without a second Wait. The Wait error is deliberately discarded — + // a killed VMM yields an expected *exec.ExitError, mirroring waitResult. + vm.vmmExited = make(chan struct{}) + go func() { + _ = vm.vmm.cmd.Wait() // discard: a killed VMM's *exec.ExitError is the expected teardown outcome (mirrors waitResult) + vm.vmm.waited.Store(true) + close(vm.vmmExited) + }() if opts.withVsock { vm.sockets = append(vm.sockets, cfg.VsockSocket) } @@ -333,14 +350,14 @@ func (vm *VM) Health(ctx context.Context) (*compassv1.HealthResponse, error) { func (vm *VM) Shutdown(ctx context.Context) error { vm.shutdownOnce.Do(func() { var errs []error - // VMM first: kill outright, then Wait to reap. + // VMM first: kill outright, then let the sole reaper's single Wait + // complete via vmmExited (Shutdown must not Wait the VMM itself — that + // would be a second Wait on the same process). if vm.vmm != nil && vm.vmm.cmd.Process != nil { if killErr := vm.vmm.cmd.Process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { errs = append(errs, fmt.Errorf("killing cloud-hypervisor: %w", killErr)) } - if waitErr := waitProcess(vm.vmm); waitErr != nil { - errs = append(errs, waitErr) - } + <-vm.vmmExited } // Then the auxiliary daemons: SIGTERM, bounded wait, SIGKILL. for _, c := range []*child{vm.virtiofsd, vm.passt} { @@ -378,24 +395,31 @@ func reap(c *child) error { go func() { done <- c.cmd.Wait() }() select { case err := <-done: - c.waited = true + c.waited.Store(true) return waitResult(c.name, err) case <-time.After(reapGrace): if killErr := c.cmd.Process.Kill(); killErr != nil && !errors.Is(killErr, os.ErrProcessDone) { return fmt.Errorf("SIGKILL %s: %w", c.name, killErr) } - c.waited = true + c.waited.Store(true) return waitResult(c.name, <-done) } } -// waitProcess Wait's a process that has already been signalled to die and -// normalizes the "expected" exit (killed/exited non-zero) to nil — Shutdown -// killed it on purpose, so a non-nil ExitError is not a Shutdown failure. -func waitProcess(c *child) error { - err := c.cmd.Wait() - c.waited = true - return waitResult(c.name, err) +// WaitVMMExit reports whether the VMM process exited within timeout, observed +// via the reaper (not a zombie-blind Signal(0) poll): a guest that powers itself +// off makes the reaper's Wait return and close vmmExited promptly, so the caller +// sees the self-exit instead of burning the full grace window on a zombie. +func (vm *VM) WaitVMMExit(timeout time.Duration) bool { + if vm.vmm == nil || vm.vmmExited == nil { + return true + } + select { + case <-vm.vmmExited: + return true + case <-time.After(timeout): + return false + } } // waitResult swallows the ExitError a deliberately-killed process yields (a @@ -417,7 +441,7 @@ func waitResult(name string, err error) error { // is definitively gone; otherwise signal 0 probes liveness without affecting it. func (vm *VM) Running(name string) bool { c := vm.childByName(name) - if c == nil || c.cmd.Process == nil || c.waited { + if c == nil || c.cmd.Process == nil || c.waited.Load() { return false } return c.cmd.Process.Signal(syscall.Signal(0)) == nil @@ -433,7 +457,7 @@ func (vm *VM) PSS() (map[string]int64, error) { out := make(map[string]int64) var errs []error for _, c := range []*child{vm.vmm, vm.virtiofsd, vm.passt} { - if c == nil || c.cmd.Process == nil || c.waited { + if c == nil || c.cmd.Process == nil || c.waited.Load() { continue } pss, err := readPSS(c.cmd.Process.Pid) diff --git a/go/internal/runtime/microvm/launch_teardown_test.go b/go/internal/runtime/microvm/launch_teardown_test.go index a4c0bc02c..f4ed98801 100644 --- a/go/internal/runtime/microvm/launch_teardown_test.go +++ b/go/internal/runtime/microvm/launch_teardown_test.go @@ -4,6 +4,7 @@ package microvm import ( "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -80,6 +81,48 @@ sleep 30`) } } +// TestWaitVMMExitObservesPromptSelfExit pins M2's prompt-exit contract: a VMM +// that exits on its own (as the guest does on RB_POWER_OFF) is observed by +// WaitVMMExit via the sole reaper WELL UNDER the grace window — it must NOT burn +// the full timeout waiting on a zombie. It assembles a minimal VM with just a +// fake vmm child plus a manually-started reaper mirroring launch's (the full +// launch needs passt/virtiofsd), since the assertion is purely about the +// reaper→vmmExited→WaitVMMExit path. +func TestWaitVMMExitObservesPromptSelfExit(t *testing.T) { + dir := t.TempDir() + vm := &VM{ + vmm: &child{ + name: "cloud-hypervisor", + logPath: filepath.Join(dir, "cloud-hypervisor.log"), + // A self-exiting VMM stand-in: sleep briefly, then exit 0. + cmd: exec.CommandContext(t.Context(), "/bin/sh", "-c", "sleep 0.1; exit 0"), + }, + } + if err := startChild(vm.vmm); err != nil { + t.Fatalf("startChild(vmm fake): %v", err) + } + // The sole reaper, mirroring launch: it owns the single Wait and closes + // vmmExited once the process has exited. + vm.vmmExited = make(chan struct{}) + go func() { + _ = vm.vmm.cmd.Wait() // reaper mirror: a fake VMM's exit is the expected outcome + vm.vmm.waited.Store(true) + close(vm.vmmExited) + }() + + // A generous grace window: the fake exits in ~100ms, so WaitVMMExit must + // return true well before this elapses. Measure to prove it did not burn + // the timeout on a zombie. + const grace = 10 * time.Second + start := time.Now() + if !vm.WaitVMMExit(grace) { + t.Fatalf("WaitVMMExit returned false: a self-exiting VMM was not observed within %v", grace) + } + if elapsed := time.Since(start); elapsed >= grace { + t.Fatalf("WaitVMMExit took %v (>= grace %v): the self-exit was not observed promptly", elapsed, grace) + } +} + // readPidFile reads a pid a fake wrote, retrying briefly since the fake writes // it asynchronously after exec. func readPidFile(t *testing.T, path string) int { diff --git a/go/internal/runtime/microvm_lifecycle.go b/go/internal/runtime/microvm_lifecycle.go new file mode 100644 index 000000000..7e4c60ccc --- /dev/null +++ b/go/internal/runtime/microvm_lifecycle.go @@ -0,0 +1,642 @@ +//go:build unix + +package runtime + +// microvm_lifecycle.go fills the eight MicroVMRuntime lifecycle verbs behind the +// frozen ContainerRuntime signatures (microvm.go holds the type + config + +// SelectBackend). It is //go:build unix because the microvm package it drives +// (Launch/GuestExec/VM/GuestClient, all //go:build unix) is unix-only; keeping +// the bodies here lets the untagged runtime package still type-check backend +// selection on any platform. +// +// The design (record §(c)/(d)/(e)) translates each container verb onto V2a's +// boot harness plus the U3 GuestExec layer: Create allocates a session without +// booting (mirroring `podman create`), Start boots + Health-polls + nonce-binds +// + Provisions transactionally, Exec/ExecStreaming map the spec onto GuestExec, +// Stop is graceful-then-kill via the guest Signal RPC, and Remove is an +// idempotent teardown. The load-bearing invariants: the mutex-guarded session +// table, Start's tear-down-on-any-failure posture, and ExecStreaming's waitFunc +// constructing a *runtime.ExitStatusError for a signalled exit so the runner's +// isDeliberateKill recognizes a deliberate kill (OQ-G/U3b). + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "syscall" + "time" + + "connectrpc.com/connect" + + compassv1 "github.com/RigelBuild/compass/go/internal/gen/compass/v1" + "github.com/RigelBuild/compass/go/internal/gen/compass/v1/compassv1internalconnect" + "github.com/RigelBuild/compass/go/internal/runtime/microvm" +) + +// guestVsockCID is the fixed guest context id every session VM boots with. One +// VMM per session means CID uniqueness only matters per-host for observability; +// the hybrid transport addresses by socket path, not CID, so nothing routes on +// it (OQ-F, record §(c)). CIDs 0-2 are reserved, so 3 is the first usable. +const guestVsockCID uint32 = 3 + +// guestVsockPort is the fixed port guestd serves the control plane on inside +// every session VM. Per-session uniqueness is carried entirely by the AF_UNIX +// socket paths under the session runtime dir, never the port (OQ-F). This +// matches the value the V2a boot harness tests boot guestd on (testVsockPort). +const guestVsockPort uint32 = 1024 + +// workspaceFSTag is the virtio-fs tag the single read-write workspace share is +// exported under — the tag guestd mounts at /workspace (config.go FSTag). +const workspaceFSTag = "workspace" + +// workspaceMountPath is the guest path guestd always mounts the single +// workspace share at (/workspace). A single mount targeting any other +// ContainerPath is refused rather than silently remapped here (OQ-C: refuse, +// don't drop the target path). +const workspaceMountPath = "/workspace" + +// guestMAC is the per-session-fixed MAC handed to the guest virtio-net device. +// One VM per session with its own network namespace means the MAC need not be +// unique across sessions; a fixed value keeps Create allocation-free here. +const guestMAC = "12:34:56:78:9a:bc" + +// bootDeadline bounds Start's Launch→Health-OK window when the caller's ctx +// carries no deadline of its own — the V2a full-boot budget (record §T4: 60s). +const bootDeadline = 60 * time.Second + +// healthPollInterval is how often Start re-probes Health while waiting for the +// guest to report net_provisioned && workspace_mounted. A short interval keeps +// boot latency observation tight without busy-spinning the vsock. +const healthPollInterval = 200 * time.Millisecond + +// execDefaultTimeout is the per-command wall-clock cap Exec enforces host-side +// (a ctx deadline) and mirrors guest-side (ExecCall.TimeoutSeconds), matching +// PodmanCLI's defaultCommandTimeout posture: a wedged child must surface as a +// timeout error, never block the calling task forever. +const execDefaultTimeout = 120 * time.Second + +// microvmSession is one allocated microVM session's state. Created by Create +// (not yet booted), populated with the running VM handle and exec client by +// Start, and dropped by Remove. All fields are read/written under +// MicroVMRuntime.mu. +type microvmSession struct { + // id is the ContainerID Create minted (also the runtime-dir leaf name). + id ContainerID + // name is spec.Name — the Runner's stable handle, answered by Exists and + // used to refuse a duplicate-name Create (matching podman's engine). + name string + // cfg is the assembled BootConfig Start boots from. + cfg microvm.BootConfig + // uid and env are recorded from the spec at Create for the Provision RPC + // Start issues (default_exec_uid + base_env). + uid uint32 + env map[string]string + // nonce is the per-session boot nonce (raw bytes); its hex encoding rides + // the cmdline, and Start verifies guestd echoes it before opening the gate. + nonce []byte + // runtimeDir is /microvm//, holding the session's sockets. + runtimeDir string + // vm and guestExec are nil until Start boots the guest; Start sets both + // under the lock once the boot + Provision succeed. + vm *microvm.VM + guestExec *microvm.GuestExec +} + +// DuplicateNameError is a Create refused because a session with the same +// spec.Name already exists — matching podman's engine rejecting a second +// container of the same name, which createAndStart's retry cleanliness leans on. +type DuplicateNameError struct { + Name string +} + +func (e *DuplicateNameError) Error() string { + return fmt.Sprintf("microvm: a session named %q already exists", e.Name) +} + +// UnsupportedMountError is a Create refused because the spec carries a bind +// mount the microVM backend cannot express: V2b boots exactly one read-write +// workspace share, so any additional or differently-shaped mount is refused +// rather than silently dropped (OQ-C, record §(c)). +type UnsupportedMountError struct { + Mount Mount +} + +func (e *UnsupportedMountError) Error() string { + return fmt.Sprintf( + "microvm: unsupported mount %s->%s: the backend expresses exactly one read-write workspace share", + e.Mount.HostPath, e.Mount.ContainerPath) +} + +// Create allocates a session without booting it (mirroring `podman create`): it +// refuses a duplicate name, mints a random session id + runtime dir + boot +// nonce, validates the mount set down to the single workspace share, assembles +// the BootConfig, records the uid/env for Provision, and stores the session in +// the table. spec.Command and spec.CapAdd are IGNORED on this backend — a VM's +// keep-alive is the VMM + guestd PID 1, not a sleep-loop entrypoint, and +// CAP_NET_ADMIN is never granted to the workload boundary (record §(c)). No VM +// is booted here; Start does that. +func (m *MicroVMRuntime) Create(_ context.Context, spec ContainerSpec) (ContainerID, error) { + shared, err := workspaceShare(spec.Mounts) + if err != nil { + return "", err + } + + id, err := mintSessionID() + if err != nil { + return "", err + } + nonce, err := mintNonce() + if err != nil { + return "", err + } + + runtimeDir := filepath.Join(m.config.RunRoot, "microvm", string(id)) + if err := os.MkdirAll(runtimeDir, 0o700); err != nil { + return "", fmt.Errorf("microvm: creating session runtime dir %s: %w", runtimeDir, err) + } + + session := µvmSession{ + id: id, + name: spec.Name, + cfg: m.bootConfig(runtimeDir, nonce, shared), + uid: spec.UID, + env: spec.Env, + nonce: nonce, + runtimeDir: runtimeDir, + } + + m.mu.Lock() + defer m.mu.Unlock() + // Refuse a duplicate name under the same lock that inserts, so two + // concurrent Creates of the same name cannot both pass the check. + for _, existing := range m.sessions { + if existing.name == spec.Name { + // The runtime dir was created above; drop it so a refused Create + // leaves nothing behind. Removal failure is not actionable here — + // the refusal is the outcome the caller acts on. + if rmErr := os.RemoveAll(runtimeDir); rmErr != nil { + return "", errors.Join(&DuplicateNameError{Name: spec.Name}, + fmt.Errorf("microvm: cleaning up refused session dir %s: %w", runtimeDir, rmErr)) + } + return "", &DuplicateNameError{Name: spec.Name} + } + } + m.sessions[id] = session + return id, nil +} + +// bootConfig assembles the microvm.BootConfig for a session: boot images from +// the operator config, distinct AF_UNIX socket paths inside the runtime dir, +// the fixed CID/port, the workspace share as FSSharedDir, the default guest +// sizing, and the boot nonce carried on the cmdline as lowercase hex under the +// compass.boot_nonce key guestd parses. Split out so spec→BootConfig assembly +// is unit-testable without booting. +func (m *MicroVMRuntime) bootConfig(runtimeDir string, nonce []byte, shared Mount) microvm.BootConfig { + return microvm.BootConfig{ + Kernel: m.config.KernelImage, + Initrd: m.config.InitrdImage, + Rootfs: m.config.RootfsImage, + Cmdline: "compass.boot_nonce=" + hex.EncodeToString(nonce), + VsockCID: guestVsockCID, + VsockPort: guestVsockPort, + VsockSocket: filepath.Join(runtimeDir, "vsock.sock"), + FSTag: workspaceFSTag, + FSSocket: filepath.Join(runtimeDir, "virtiofsd.sock"), + FSSharedDir: shared.HostPath, + CPUs: m.config.DefaultCPUs, + MemoryMB: m.config.DefaultMemoryMB, + Net: microvm.NetConfig{ + VhostUserSocket: filepath.Join(runtimeDir, "net.sock"), + MAC: guestMAC, + }, + } +} + +// workspaceShare validates the spec's mount set down to the single read-write +// workspace share the microVM backend can express, returning that mount. An +// empty mount set yields a zero Mount (FSSharedDir left empty — a guest boots +// with an empty workspace tree). More than one mount, a read-only mount, or a +// single mount targeting a ContainerPath other than /workspace is refused with +// an UnsupportedMountError naming the offending mount (OQ-C: refuse, don't +// drop). Split out so mount validation is unit-testable. +func workspaceShare(mounts []Mount) (Mount, error) { + switch len(mounts) { + case 0: + return Mount{}, nil + case 1: + if mounts[0].ReadOnly { + return Mount{}, &UnsupportedMountError{Mount: mounts[0]} + } + if mounts[0].ContainerPath != workspaceMountPath { + return Mount{}, &UnsupportedMountError{Mount: mounts[0]} + } + return mounts[0], nil + default: + // Refuse the whole spec, naming the first mount beyond the one share + // the backend can express. + return Mount{}, &UnsupportedMountError{Mount: mounts[1]} //nolint:gosec // G602 false positive: the default branch is reached only when len(mounts) >= 2, so index 1 is in range + } +} + +// mintSessionID mints a random 16-byte hex session id used as the ContainerID +// and the runtime-dir leaf. There is no engine to print an id, so the backend +// generates one; hex keeps it filesystem-safe. +func mintSessionID() (ContainerID, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("microvm: minting session id: %w", err) + } + return ContainerID(hex.EncodeToString(b[:])), nil +} + +// mintNonce mints a random 16-byte boot nonce (raw bytes; the cmdline carries +// its hex encoding). It binds the guest answering Start's Health handshake to +// THIS BootConfig, catching a stale VMM on a recycled socket path (record §(e)). +func mintNonce() ([]byte, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return nil, fmt.Errorf("microvm: minting boot nonce: %w", err) + } + return b, nil +} + +// Start boots and provisions the session transactionally: Launch, poll Health +// under a boot deadline until net_provisioned && workspace_mounted, verify the +// echoed boot nonce binds this guest to the BootConfig, then Provision to open +// the exec gate. Any step failing tears down whatever booted (vm.Shutdown) +// before returning — on this backend the boot IS Start, so Start cleans its own +// partial boot and Remove stays idempotent (record §(c)). On success the VM +// handle + GuestExec are stored on the session under the lock. +func (m *MicroVMRuntime) Start(ctx context.Context, id ContainerID) error { + session, err := m.session(id) + if err != nil { + return err + } + + vm, err := microvm.Launch(ctx, session.cfg) + if err != nil { + return fmt.Errorf("microvm: launching session %s: %w", id, err) + } + // From here any failure must tear down the booted VM before returning: + // booted is cleared once ownership transfers to the session table. + booted := true + defer func() { + if booted { + // Best-effort teardown of a partial boot; the returned start error + // is what the caller acts on, so a Shutdown error is not surfaced. + _ = vm.Shutdown(context.WithoutCancel(ctx)) + } + }() + + if err := m.awaitHealthy(ctx, vm, session.nonce); err != nil { + return err + } + + if session.uid == 0 { + return fmt.Errorf("microvm: session %s has a zero exec uid; Provision requires a non-zero default_exec_uid", id) + } + client := microvm.GuestClient(session.cfg.VsockSocket, session.cfg.VsockPort) + if _, err := client.Provision(ctx, connect.NewRequest(&compassv1.ProvisionRequest{ + DefaultExecUid: session.uid, + BaseEnv: session.env, + })); err != nil { + return fmt.Errorf("microvm: provisioning session %s: %w", id, err) + } + + m.mu.Lock() + // Re-check membership under the same lock the store happens under: a + // concurrent Remove may have won the race and deleted the entry while this + // Start was booting. If so, do NOT store onto the orphaned session (that + // would strand a live VMM+daemons); leave booted=true so the deferred + // Shutdown tears the freshly-booted VM down, and return an error. + if _, ok := m.sessions[id]; !ok { + m.mu.Unlock() + return fmt.Errorf("microvm: session %s was removed during Start", id) + } + session.vm = vm + session.guestExec = microvm.NewGuestExec(client) + m.mu.Unlock() + booted = false // ownership transferred to the session; the defer must not tear it down + return nil +} + +// awaitHealthy polls the guest's Health until it reports net_provisioned && +// workspace_mounted (the V2a fail-closed readiness proof) within the boot +// deadline, then verifies the echoed boot_nonce equals the minted nonce before +// returning — a mismatch is an error (§(e) identity binding). The deadline +// derives from ctx when it carries one, else bootDeadline. +func (m *MicroVMRuntime) awaitHealthy(ctx context.Context, vm *microvm.VM, nonce []byte) error { + pollCtx, cancel := bootPollContext(ctx) + defer cancel() + + ticker := time.NewTicker(healthPollInterval) + defer ticker.Stop() + + for { + resp, err := vm.Health(pollCtx) + if err == nil && resp.GetNetProvisioned() && resp.GetWorkspaceMounted() { + if !bytes.Equal(resp.GetBootNonce(), nonce) { + return fmt.Errorf( + "microvm: boot nonce mismatch: guest echoed %x, minted %x (stale VMM on a recycled socket?)", + resp.GetBootNonce(), nonce) + } + return nil + } + select { + case <-pollCtx.Done(): + if err != nil { + return fmt.Errorf("microvm: guest did not become healthy before the boot deadline: %w", err) + } + return fmt.Errorf("microvm: guest did not become healthy before the boot deadline: %s", + "net_provisioned && workspace_mounted never held") + case <-ticker.C: + } + } +} + +// bootPollContext derives the Health-poll deadline: the caller's ctx deadline +// when it has one (so the boot honors caller cancellation/timeout), else a +// fresh bootDeadline-bounded context. +func bootPollContext(ctx context.Context) (context.Context, context.CancelFunc) { + if _, ok := ctx.Deadline(); ok { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, bootDeadline) +} + +// Exec runs one command to completion in the session guest, mapping the spec +// onto microvm.ExecCall and the result back onto ExecOutput. A non-zero exit is +// a SUCCESSFUL call (captured in ExecOutput.ExitCode), never an error; a guest +// refusal or transport failure is an error, and a host-side timeout is mapped +// to a *runtime.TimeoutError so requireSuccess/atStage callers behave +// identically to the podman path (record §(c)). +func (m *MicroVMRuntime) Exec(ctx context.Context, id ContainerID, spec ExecSpec) (ExecOutput, error) { + guestExec, err := m.startedExec(id) + if err != nil { + return ExecOutput{}, err + } + call, err := execCall(spec) + if err != nil { + return ExecOutput{}, err + } + + result, err := guestExec.Exec(ctx, call) + if err != nil { + var timeout *microvm.TimeoutError + if errors.As(err, &timeout) { + return ExecOutput{}, &TimeoutError{ + Summary: "microvm exec", + Timeout: timeout.Timeout, + } + } + return ExecOutput{}, fmt.Errorf("microvm: exec in session %s: %w", id, err) + } + return ExecOutput{ + Stdout: string(result.Stdout), + Stderr: string(result.Stderr), + ExitCode: result.ExitCode, + }, nil +} + +// execCall maps an ExecSpec onto a microvm.ExecCall: User (a numeric-string uid +// on every callsite) parses to a *uint32 UID (a non-numeric User is a host-side +// error), Stdin *string becomes []byte, and the per-command timeout is set so +// guestd mirrors the host-side ctx deadline. Split out so spec→ExecCall mapping +// is unit-testable. +func execCall(spec ExecSpec) (microvm.ExecCall, error) { + uid, err := parseUID(spec.User) + if err != nil { + return microvm.ExecCall{}, err + } + var stdin []byte + if spec.Stdin != nil { + stdin = []byte(*spec.Stdin) + } + return microvm.ExecCall{ + Command: spec.Command, + UID: uid, + Workdir: spec.Workdir, + Env: spec.Env, + Stdin: stdin, + TimeoutSeconds: uint32(execDefaultTimeout.Seconds()), + }, nil +} + +// parseUID parses a --user value (a numeric-string uid on every Runner +// callsite, e.g. AsUser(strconv.FormatUint(uid))) into a *uint32. A nil User +// leaves the uid nil (the session default set by Provision); a non-numeric User +// is a host-side error rather than a guest-side refusal. Split out so numeric- +// uid parsing is unit-testable. +func parseUID(user *string) (*uint32, error) { + if user == nil { + return nil, nil //nolint:nilnil // a nil *uint32 is the meaningful "no uid override" (the session default set by Provision), not an error condition + } + parsed, err := strconv.ParseUint(*user, 10, 32) + if err != nil { + return nil, fmt.Errorf("microvm: exec user %q is not a numeric uid: %w", *user, err) + } + uid := uint32(parsed) + return &uid, nil +} + +// exitError maps a guest ExitStatus onto the portable exit error contract: a +// signalled exit carries the signal (isDeliberateKill true), a non-zero code +// carries the code, a clean exit is nil (OQ-G/U3b). +func exitError(st microvm.ExitStatus) error { + switch { + case st.Signal != 0: + return &ExitStatusError{Code: st.Code, Signal: syscall.Signal(st.Signal)} + case st.Code != 0: + return &ExitStatusError{Code: st.Code} + default: + return nil + } +} + +// ExecStreaming starts a long-lived streaming exec in the session guest, +// mapping the spec onto microvm.StreamCall and the GuestStream onto a +// *StreamingExec. ExecStream awaits the ExecStarted frame, so a spawn failure +// surfaces as the returned error. The ChildHandle is built over the remote +// exec's kill/wait pair: killFunc issues a bounded SIGKILL Signal (never +// blocking teardown), and waitFunc maps the guest exit onto nil / a +// *runtime.ExitStatusError so the runner's isDeliberateKill recognizes a +// signalled exit as a deliberate kill (OQ-G/U3b, record §(c)). +func (m *MicroVMRuntime) ExecStreaming(ctx context.Context, id ContainerID, spec StreamingExecSpec) (*StreamingExec, error) { + guestExec, err := m.startedExec(id) + if err != nil { + return nil, err + } + uid, err := parseUID(spec.User) + if err != nil { + return nil, err + } + + gs, err := guestExec.ExecStream(ctx, microvm.StreamCall{ + Command: spec.Command, + UID: uid, + Workdir: spec.Workdir, + Env: spec.Env, + }) + if err != nil { + return nil, &SpawnError{Program: "microvm guest exec", Err: err} + } + + killFunc := func() error { //nolint:contextcheck // GuestStream.Kill issues a self-bounded SIGKILL Signal RPC and takes no ctx by design (mirrors podman's local-cancel Kill; newChildHandleFuncs's kill is a func() error) + // Kill is bounded by killSignalTimeout inside GuestStream and never + // blocks teardown past it; a transport error is returned but the + // teardown caller ignores it (the VMM-kill escalation is the backstop). + return gs.Kill(int(syscall.SIGKILL)) + } + waitFunc := func() error { return exitError(gs.Wait()) } + return &StreamingExec{ + IO: StreamingIO{Stdin: gs.Stdin, Stdout: gs.Stdout, Stderr: gs.Stderr}, + Process: newChildHandleFuncs(killFunc, waitFunc), + }, nil +} + +// Stop stops the session VM gracefully then forcibly, mirroring podman's +// --time semantics. It sends the guest-stop Signal RPC (empty exec_id targets +// the guest itself: guestd cancels its serving ctx, SIGTERMs its children, +// drains, and reboot(RB_POWER_OFF)s so the VMM observes shutdown), then awaits a +// real VMM exit up to timeout. Past the timeout it kills the VMM outright via +// vm.Shutdown (which also reaps the daemons and removes the sockets). A session +// that never started (no VM handle) is a no-op success (record §(d)). +func (m *MicroVMRuntime) Stop(ctx context.Context, id ContainerID, timeout time.Duration) error { + session, err := m.session(id) + if err != nil { + return err + } + m.mu.Lock() + vm := session.vm + m.mu.Unlock() + if vm == nil { + return nil // never started: nothing to stop + } + + // The graceful preamble: ask the guest to power itself off. A failed Signal + // is not fatal — the VMM-kill escalation below is the backstop — but it is + // wrapped into the deadline wait's outcome rather than silently dropped. + client := microvm.GuestClient(session.cfg.VsockSocket, session.cfg.VsockPort) + signalErr := stopGuest(ctx, client) + + // Await a real VMM exit up to timeout (a SIGTERM-honoring guest powers off + // before this elapses, observed via the reaper); past it, kill the VMM + // outright. + if vm.WaitVMMExit(timeout) { + return vm.Shutdown(context.WithoutCancel(ctx)) // reap daemons + remove sockets + } + if err := vm.Shutdown(context.WithoutCancel(ctx)); err != nil { + return errors.Join(fmt.Errorf("microvm: stopping session %s: %w", id, err), signalErr) + } + return nil +} + +// stopGuest sends the guest-stop Signal RPC (empty exec_id, SIGTERM) that tells +// guestd to drain and power off. The returned error is informational — the +// caller escalates to a VMM kill regardless — so it is threaded into the Stop +// error rather than handled here. +func stopGuest(ctx context.Context, client compassv1internalconnect.GuestControlClient) error { + _, err := client.Signal(ctx, connect.NewRequest(&compassv1.SignalRequest{ + ExecId: "", + Signal: int32(syscall.SIGTERM), + })) + if err != nil { + return fmt.Errorf("microvm: sending guest stop signal: %w", err) + } + return nil +} + +// Remove force-kills the session VM if still running (vm.Shutdown is +// sync.Once-guarded, safe to call twice), deletes the runtime dir, and drops +// the session-table entry. It is idempotent: a Remove of an unknown or +// already-removed id is not an error (matching `podman rm --force`), and a +// session that never started is torn down to just its dir + entry (record §(d)). +func (m *MicroVMRuntime) Remove(ctx context.Context, id ContainerID) error { + m.mu.Lock() + session, ok := m.sessions[id] + if !ok { + m.mu.Unlock() + return nil // unknown/already-removed: idempotent no-op + } + vm := session.vm + delete(m.sessions, id) + m.mu.Unlock() + + var errs []error + if vm != nil { + if err := vm.Shutdown(context.WithoutCancel(ctx)); err != nil { + errs = append(errs, fmt.Errorf("microvm: shutting down session %s: %w", id, err)) + } + } + if err := os.RemoveAll(session.runtimeDir); err != nil { + errs = append(errs, fmt.Errorf("microvm: removing session dir %s: %w", session.runtimeDir, err)) + } + return errors.Join(errs...) +} + +// Exists answers a NAME query from the session table: true if a session with +// spec.Name == name exists in any state, else false. It keys on spec.Name, NOT +// the random session id, so the Runner's stable handle resolves (record §(c)). +func (m *MicroVMRuntime) Exists(_ context.Context, name string) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, session := range m.sessions { + if session.name == name { + return true, nil + } + } + return false, nil +} + +// MountLabel returns the empty label unconditionally: the microVM backend has +// no SELinux mount label to report (the workspace is a virtio-fs share, not a +// relabeled bind mount), and the config materializer treats an empty label as +// skip-chcon (the parent's Q-mountlabel deferral, record §(c)). An unknown id +// is not distinguished — the empty answer is correct for it too. +func (m *MicroVMRuntime) MountLabel(_ context.Context, _ ContainerID) (string, error) { + return "", nil +} + +// Resize mirrors PodmanCLI.Resize: it returns the shared ErrResizeNotImplemented +// sentinel until C3 fills in resize-in-place behind the S1-frozen seam (the +// C3/D5 deferral, record §(c)). It is not a microVM-specific unimplemented +// verb, so it shares the podman backend's sentinel. +func (m *MicroVMRuntime) Resize(_ context.Context, _ ContainerID, _ ResourceLimits) error { + return ErrResizeNotImplemented +} + +// session looks up a session by id under the lock, returning a stage-agnostic +// error if it is absent. +func (m *MicroVMRuntime) session(id ContainerID) (*microvmSession, error) { + m.mu.Lock() + defer m.mu.Unlock() + session, ok := m.sessions[id] + if !ok { + return nil, fmt.Errorf("microvm: no session %s", id) + } + return session, nil +} + +// startedExec looks up a session's GuestExec client under the lock, erroring if +// the session is absent or not yet started (Exec/ExecStreaming both require a +// booted, provisioned guest). +func (m *MicroVMRuntime) startedExec(id ContainerID) (*microvm.GuestExec, error) { + m.mu.Lock() + defer m.mu.Unlock() + session, ok := m.sessions[id] + if !ok { + return nil, fmt.Errorf("microvm: no session %s", id) + } + if session.guestExec == nil { + return nil, fmt.Errorf("microvm: session %s is not started", id) + } + return session.guestExec, nil +} + +var _ ContainerRuntime = (*MicroVMRuntime)(nil) diff --git a/go/internal/runtime/microvm_lifecycle_microvm_test.go b/go/internal/runtime/microvm_lifecycle_microvm_test.go new file mode 100644 index 000000000..4b071984d --- /dev/null +++ b/go/internal/runtime/microvm_lifecycle_microvm_test.go @@ -0,0 +1,220 @@ +//go:build microvm && unix + +package runtime + +// The KVM-gated microVM lifecycle e2e suite (record §T4/U4): it drives a real +// MicroVMRuntime through Create→Start→Exec→Stop→Remove on live hardware, and +// asserts the fail-closed Start-teardown and graceful-Stop invariants. Every +// test calls microvmtest.Require(t) first, mirroring +// microvm/boot_microvm_test.go: on a KVM-less box it SKIPS (unless +// COMPASS_REQUIRE_MICROVM=1 forces a hard fail), so the suite is only real where +// /dev/kvm is openable and the guest images are exported into the env. + +import ( + "context" + "errors" + "io" + "os" + "testing" + "time" + + "github.com/RigelBuild/compass/go/internal/microvmtest" +) + +// e2eConfig builds a MicroVMConfig from the resolved test env and a fresh, SHORT +// runroot, so every session's sockets live inside the test's own scratch tree +// and are removed with it. The runroot must be short because the per-session +// socket paths under it are AF_UNIX sun_path-budgeted: the widest is +// /microvm/<32-hex session id>/virtiofsd.sock, a 56-byte tail, so a +// t.TempDir() root (which embeds the test-function name, e.g. the 39-char +// TestMicroVMExecStreamingKillSignalsExit → a 114-byte socket path) overflows +// the 107-byte Linux cap and virtiofsd's bind(2) fails with a bare EINVAL — +// the socket never appears and the boot times out. A short fixed root keeps the +// worst-case path well under the cap. Production runroots are short and +// startup-budget-checked (run.go validateRuntimeDir); this mirrors that. +func e2eConfig(t *testing.T, env microvmtest.Env) MicroVMConfig { + t.Helper() + //nolint:usetesting // t.TempDir embeds the long test-function name, which overflows the 107-byte AF_UNIX sun_path budget for the per-session virtiofsd/vsock/net sockets — the very failure this short root prevents. + runRoot, err := os.MkdirTemp("", "cvm") + if err != nil { + t.Fatalf("creating short microvm runroot: %v", err) + } + t.Cleanup(func() { + if err := os.RemoveAll(runRoot); err != nil { + t.Errorf("removing microvm runroot %s: %v", runRoot, err) + } + }) + return MicroVMConfig{ + VMMPath: env.VMMPath, + VirtiofsdPath: env.VirtiofsdPath, + KernelImage: env.KernelImage, + RootfsImage: env.RootfsImage, + InitrdImage: env.InitrdImage, + RunRoot: runRoot, + DefaultCPUs: 2, + DefaultMemoryMB: 1024, + } +} + +// TestMicroVMLifecycleEndToEnd is the U4 deliverable: allocate a session +// (Create, no boot), boot + provision it (Start), run a command capturing its +// output (Exec echo), stop it gracefully (Stop), and remove it (Remove) — the +// full ContainerRuntime verb sequence against a live guest. +func TestMicroVMLifecycleEndToEnd(t *testing.T) { + env := microvmtest.Require(t) + m := NewMicroVMRuntime(e2eConfig(t, env)) + + workspace := t.TempDir() + spec := ContainerSpec{ + Name: "e2e-agent", + UID: 1000, + Mounts: []Mount{{HostPath: workspace, ContainerPath: "/workspace"}}, + } + + id, err := m.Create(t.Context(), spec) + if err != nil { + t.Fatalf("Create: %v", err) + } + // Remove is the backstop teardown even if a later step fails midway. + t.Cleanup(func() { + if rmErr := m.Remove(context.WithoutCancel(t.Context()), id); rmErr != nil { + t.Errorf("Remove (cleanup): %v", rmErr) + } + }) + + if err := m.Start(t.Context(), id); err != nil { + t.Fatalf("Start: %v", err) + } + + out, err := m.Exec(t.Context(), id, NewExecSpec("echo", "hello-guest").AsUser("1000")) + if err != nil { + t.Fatalf("Exec: %v", err) + } + if !out.Success() { + t.Fatalf("Exec exit = %d, stderr = %q, want exit 0", out.ExitCode, out.Stderr) + } + if got := out.Stdout; got != "hello-guest\n" { + t.Fatalf("Exec stdout = %q, want %q", got, "hello-guest\n") + } + + if err := m.Stop(t.Context(), id, 10*time.Second); err != nil { + t.Fatalf("Stop: %v", err) + } + if err := m.Remove(t.Context(), id); err != nil { + t.Fatalf("Remove: %v", err) + } + if _, err := m.session(id); err == nil { + t.Fatal("session still in table after Remove") + } +} + +// TestMicroVMStartFailureLeavesNoState is the transactional-Start negative: a +// Start against a bad rootfs path must tear down its partial boot (no orphan +// processes) AND leave the session in a not-started state so a subsequent Remove +// still cleans up its runtime dir. The runtime dir itself is created by Create +// and removed by Remove; Start's teardown covers the booted VM, not the dir. +func TestMicroVMStartFailureLeavesNoState(t *testing.T) { + env := microvmtest.Require(t) + cfg := e2eConfig(t, env) + // A raw-sized but non-erofs rootfs: CH boots the kernel, then the initrd's + // erofs mount fails-closed — the guest never reaches Health, so Start's boot + // deadline elapses and it tears the partial boot down (mirrors + // microvm/boot_microvm_test.go TestCorruptRootfsFailsClosed). + corrupt := t.TempDir() + "/corrupt.erofs" + f, createErr := os.Create(corrupt) + if createErr != nil { + t.Fatalf("creating corrupt rootfs: %v", createErr) + } + if truncErr := f.Truncate(16 << 20); truncErr != nil { + _ = f.Close() // cleanup on an already-failing setup path + t.Fatalf("sizing corrupt rootfs: %v", truncErr) + } + if closeErr := f.Close(); closeErr != nil { + t.Fatalf("closing corrupt rootfs: %v", closeErr) + } + cfg.RootfsImage = corrupt + m := NewMicroVMRuntime(cfg) + + workspace := t.TempDir() + id, err := m.Create(t.Context(), ContainerSpec{ + Name: "e2e-badboot", + UID: 1000, + Mounts: []Mount{{HostPath: workspace, ContainerPath: "/workspace"}}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + + // A short deadline: the boot can never become healthy, so bound the wait + // well under the -timeout rather than burning the full 60 s bootDeadline. + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + if startErr := m.Start(ctx, id); startErr == nil { + t.Fatal("Start against a corrupt rootfs succeeded; want a fail-closed error") + } + + // The session must not be marked started (no exec client), and Remove must + // still clean it up idempotently. + session, err := m.session(id) + if err != nil { + t.Fatalf("session missing after a failed Start: %v", err) + } + if session.guestExec != nil { + t.Fatal("session has an exec client after a failed Start; Start must not open the gate on a torn-down boot") + } + if rmErr := m.Remove(t.Context(), id); rmErr != nil { + t.Fatalf("Remove after a failed Start: %v", rmErr) + } + if _, statErr := os.Stat(session.runtimeDir); !os.IsNotExist(statErr) { + t.Fatalf("runtime dir %s not removed after Remove (stat err %v)", session.runtimeDir, statErr) + } +} + +// TestMicroVMExecStreamingKillSignalsExit exercises U4's ExecStreaming wiring +// live (M3's OQ-G contract end to end): start a long-running streaming command, +// Kill it via the ChildHandle, and assert Wait maps the guest's signalled exit +// onto a *ExitStatusError with a non-zero Signal — the kill/wait/stream path the +// hermetic TestExitErrorMapping cannot reach. +func TestMicroVMExecStreamingKillSignalsExit(t *testing.T) { + env := microvmtest.Require(t) + m := NewMicroVMRuntime(e2eConfig(t, env)) + + workspace := t.TempDir() + id, err := m.Create(t.Context(), ContainerSpec{ + Name: "e2e-stream-kill", + UID: 1000, + Mounts: []Mount{{HostPath: workspace, ContainerPath: "/workspace"}}, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + t.Cleanup(func() { _ = m.Remove(t.Context(), id) }) + + if err := m.Start(t.Context(), id); err != nil { + t.Fatalf("Start: %v", err) + } + + stream, err := m.ExecStreaming(t.Context(), id, StreamingExecSpec{ + Command: []string{"sleep", "300"}, + User: strPtr("1000"), + }) + if err != nil { + t.Fatalf("ExecStreaming: %v", err) + } + // Drain stdout/stderr so the stream is not blocked on a full pipe while we + // wait for the kill to land. + go func() { _, _ = io.Copy(io.Discard, stream.IO.Stdout) }() + go func() { _, _ = io.Copy(io.Discard, stream.IO.Stderr) }() + + if err := stream.Process.Kill(); err != nil { + t.Fatalf("Kill: %v", err) + } + err = stream.Process.Wait() + var exitStatus *ExitStatusError + if !errors.As(err, &exitStatus) { + t.Fatalf("Wait error = %v (%T), want *ExitStatusError", err, err) + } + if exitStatus.Signal == 0 { + t.Fatalf("ExitStatusError.Signal = 0, want a non-zero kill signal (%+v)", exitStatus) + } +} diff --git a/go/internal/runtime/microvm_lifecycle_test.go b/go/internal/runtime/microvm_lifecycle_test.go new file mode 100644 index 000000000..37af05c2d --- /dev/null +++ b/go/internal/runtime/microvm_lifecycle_test.go @@ -0,0 +1,376 @@ +//go:build unix + +package runtime + +// The microVM lifecycle suite: hermetic, no KVM, no subprocess. It exercises +// the pure/mappable logic the lifecycle methods are built from — spec→BootConfig +// assembly, spec→ExecCall mapping, numeric-uid parsing, mount validation, the +// session table, duplicate-name refusal, Exists by name, idempotent Remove, +// MountLabel, and Resize — WITHOUT booting a guest (Create/Start/Exec all need a +// booted VM, so the full lifecycle is KVM-gated in +// microvm_lifecycle_microvm_test.go). It is //go:build unix because the +// lifecycle file it tests is unix-only. + +import ( + "context" + "encoding/hex" + "errors" + "strings" + "syscall" + "testing" + + "github.com/RigelBuild/compass/go/internal/runtime/microvm" +) + +// TestBootConfigAssembly pins spec→BootConfig assembly: the image paths come +// from config, every AF_UNIX socket lives under the session runtime dir, the +// workspace mount becomes FSSharedDir, the CID/port are the fixed values, the +// sizing comes from config, and the boot nonce rides the cmdline as lowercase +// hex under the compass.boot_nonce key. +func TestBootConfigAssembly(t *testing.T) { + runRoot := t.TempDir() + m := NewMicroVMRuntime(MicroVMConfig{ + KernelImage: "/img/kernel", + RootfsImage: "/img/rootfs", + InitrdImage: "/img/initrd", + RunRoot: runRoot, + DefaultCPUs: 4, + DefaultMemoryMB: 2048, + }) + nonce := []byte{0xde, 0xad, 0xbe, 0xef} + share := Mount{HostPath: "/host/checkout", ContainerPath: "/workspace"} + runtimeDir := runRoot + "/microvm/sess1" + + cfg := m.bootConfig(runtimeDir, nonce, share) + + if cfg.Kernel != "/img/kernel" || cfg.Initrd != "/img/initrd" || cfg.Rootfs != "/img/rootfs" { + t.Fatalf("boot images = %q/%q/%q, want the config paths", cfg.Kernel, cfg.Initrd, cfg.Rootfs) + } + if cfg.FSSharedDir != "/host/checkout" { + t.Fatalf("FSSharedDir = %q, want the workspace mount host path", cfg.FSSharedDir) + } + if cfg.FSTag != workspaceFSTag { + t.Fatalf("FSTag = %q, want %q", cfg.FSTag, workspaceFSTag) + } + if cfg.VsockCID != guestVsockCID || cfg.VsockPort != guestVsockPort { + t.Fatalf("CID/port = %d/%d, want %d/%d", cfg.VsockCID, cfg.VsockPort, guestVsockCID, guestVsockPort) + } + if cfg.CPUs != 4 || cfg.MemoryMB != 2048 { + t.Fatalf("sizing = %d cpus / %d MB, want 4 / 2048", cfg.CPUs, cfg.MemoryMB) + } + for _, sock := range []string{cfg.VsockSocket, cfg.FSSocket, cfg.Net.VhostUserSocket} { + if !strings.HasPrefix(sock, runtimeDir+"/") { + t.Fatalf("socket %q is not under the session runtime dir %q", sock, runtimeDir) + } + } + wantToken := "compass.boot_nonce=" + hex.EncodeToString(nonce) + if !strings.Contains(cfg.Cmdline, wantToken) { + t.Fatalf("cmdline = %q, want it to carry %q", cfg.Cmdline, wantToken) + } +} + +// TestCreateAllocatesWithoutBoot: Create records a session in the table without +// booting (no VM handle, no exec client), and the returned id resolves. +func TestCreateAllocatesWithoutBoot(t *testing.T) { + m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) + id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-1", UID: 1000}) + if err != nil { + t.Fatalf("Create: %v", err) + } + session, err := m.session(id) + if err != nil { + t.Fatalf("session %s not in table after Create: %v", id, err) + } + if session.vm != nil || session.guestExec != nil { + t.Fatal("Create booted the session; it must allocate without booting") + } + if session.name != "agent-1" || session.uid != 1000 { + t.Fatalf("session name/uid = %q/%d, want agent-1/1000", session.name, session.uid) + } + if len(session.nonce) == 0 { + t.Fatal("Create did not mint a boot nonce") + } +} + +// TestCreateRefusesDuplicateName: a second Create with a name already in the +// table is refused with a typed DuplicateNameError naming the collision. +func TestCreateRefusesDuplicateName(t *testing.T) { + m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) + if _, err := m.Create(context.Background(), ContainerSpec{Name: "dup", UID: 1000}); err != nil { + t.Fatalf("first Create: %v", err) + } + _, err := m.Create(context.Background(), ContainerSpec{Name: "dup", UID: 1000}) + var dupErr *DuplicateNameError + if !errors.As(err, &dupErr) { + t.Fatalf("second Create err = %v, want *DuplicateNameError", err) + } + if dupErr.Name != "dup" { + t.Fatalf("DuplicateNameError.Name = %q, want dup", dupErr.Name) + } +} + +// TestCreateRefusesInexpressibleMount: a spec carrying a mount the backend +// cannot express (more than one, or a read-only mount) is refused with a typed +// UnsupportedMountError naming the offending mount (OQ-C: refuse, don't drop). +func TestCreateRefusesInexpressibleMount(t *testing.T) { + m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) + tests := []struct { + name string + mounts []Mount + wantMount Mount + }{ + { + name: "two mounts", + mounts: []Mount{ + {HostPath: "/a", ContainerPath: "/workspace"}, + {HostPath: "/b", ContainerPath: "/config", ReadOnly: true}, + }, + wantMount: Mount{HostPath: "/b", ContainerPath: "/config", ReadOnly: true}, + }, + { + name: "single read-only mount", + mounts: []Mount{{HostPath: "/a", ContainerPath: "/config", ReadOnly: true}}, + wantMount: Mount{HostPath: "/a", ContainerPath: "/config", ReadOnly: true}, + }, + { + name: "single read-write mount at non-workspace path", + mounts: []Mount{{HostPath: "/a", ContainerPath: "/config"}}, + wantMount: Mount{HostPath: "/a", ContainerPath: "/config"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := m.Create(context.Background(), ContainerSpec{Name: tt.name, UID: 1000, Mounts: tt.mounts}) + var mountErr *UnsupportedMountError + if !errors.As(err, &mountErr) { + t.Fatalf("Create err = %v, want *UnsupportedMountError", err) + } + if mountErr.Mount != tt.wantMount { + t.Fatalf("UnsupportedMountError.Mount = %+v, want %+v", mountErr.Mount, tt.wantMount) + } + }) + } +} + +// TestWorkspaceShare: the single supported mount passes through; zero mounts +// yield an empty share; the refusal cases mirror Create's mount validation. +func TestWorkspaceShare(t *testing.T) { + single := Mount{HostPath: "/host", ContainerPath: "/workspace"} + got, err := workspaceShare([]Mount{single}) + if err != nil { + t.Fatalf("single workspace mount: unexpected err %v", err) + } + if got != single { + t.Fatalf("share = %+v, want %+v", got, single) + } + + got, err = workspaceShare(nil) + if err != nil || (got != Mount{}) { + t.Fatalf("no mounts = (%+v, %v), want (zero Mount, nil)", got, err) + } + + rw := Mount{HostPath: "/host", ContainerPath: "/config"} + if _, err := workspaceShare([]Mount{rw}); err == nil { + t.Fatalf("single read-write mount at /config: want UnsupportedMountError, got nil") + } else { + var mountErr *UnsupportedMountError + if !errors.As(err, &mountErr) { + t.Fatalf("err = %v, want *UnsupportedMountError", err) + } + if mountErr.Mount != rw { + t.Fatalf("UnsupportedMountError.Mount = %+v, want %+v", mountErr.Mount, rw) + } + } +} + +// TestExecCallMapping pins spec→ExecCall: a numeric User parses to a *uint32 +// UID, Stdin *string becomes []byte, and command/workdir/env carry through. +func TestExecCallMapping(t *testing.T) { + stdin := "secret-body" + workdir := "/workspace" + spec := ExecSpec{ + Command: []string{"sh", "-s"}, + User: strPtr("1000"), + Workdir: &workdir, + Env: map[string]string{"K": "V"}, + Stdin: &stdin, + } + call, err := execCall(spec) + if err != nil { + t.Fatalf("execCall: %v", err) + } + if call.UID == nil || *call.UID != 1000 { + t.Fatalf("UID = %v, want *1000", call.UID) + } + if string(call.Stdin) != stdin { + t.Fatalf("Stdin = %q, want %q", call.Stdin, stdin) + } + if call.Workdir == nil || *call.Workdir != workdir { + t.Fatalf("Workdir = %v, want %q", call.Workdir, workdir) + } + if len(call.Command) != 2 || call.Command[0] != "sh" { + t.Fatalf("Command = %v, want [sh -s]", call.Command) + } + if call.Env["K"] != "V" { + t.Fatalf("Env = %v, want K=V", call.Env) + } + if call.TimeoutSeconds != uint32(execDefaultTimeout.Seconds()) { + t.Fatalf("TimeoutSeconds = %d, want %d", call.TimeoutSeconds, uint32(execDefaultTimeout.Seconds())) + } +} + +// TestExecCallNilStdinIsNil: a nil Stdin maps to a nil []byte (not an empty +// slice), so the guest never feeds an empty body to a command that expects no +// stdin. +func TestExecCallNilStdinIsNil(t *testing.T) { + call, err := execCall(ExecSpec{Command: []string{"true"}}) + if err != nil { + t.Fatalf("execCall: %v", err) + } + if call.Stdin != nil { + t.Fatalf("Stdin = %v, want nil", call.Stdin) + } + if call.UID != nil { + t.Fatalf("UID = %v, want nil (no User)", call.UID) + } +} + +// TestParseUID: nil User → nil UID; a numeric User → the parsed uid; a +// non-numeric User → a host-side error. +func TestParseUID(t *testing.T) { + if uid, err := parseUID(nil); err != nil || uid != nil { + t.Fatalf("parseUID(nil) = (%v, %v), want (nil, nil)", uid, err) + } + uid, err := parseUID(strPtr("1000")) + if err != nil || uid == nil || *uid != 1000 { + t.Fatalf("parseUID(1000) = (%v, %v), want (*1000, nil)", uid, err) + } + if _, err := parseUID(strPtr("agent")); err == nil { + t.Fatal("parseUID(agent) err = nil, want a non-numeric-uid error") + } +} + +// TestExistsByName: Exists answers a NAME query from the table — a created +// session's spec.Name is present, an unknown name is absent, and the random +// session id is NOT a name match. +func TestExistsByName(t *testing.T) { + m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) + id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-x", UID: 1000}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + present, err := m.Exists(context.Background(), "agent-x") + if err != nil || !present { + t.Fatalf("Exists(agent-x) = (%v, %v), want (true, nil)", present, err) + } + absent, err := m.Exists(context.Background(), "nope") + if err != nil || absent { + t.Fatalf("Exists(nope) = (%v, %v), want (false, nil)", absent, err) + } + // The random session id is not a name — Exists must not match on it. + byID, err := m.Exists(context.Background(), string(id)) + if err != nil || byID { + t.Fatalf("Exists() = (%v, %v), want (false, nil): Exists keys on spec.Name", byID, err) + } +} + +// TestRemoveIdempotent: Remove of an unknown id is a no-op success, and Remove +// of a never-started session tears down its dir + table entry without error. +func TestRemoveIdempotent(t *testing.T) { + m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) + if err := m.Remove(context.Background(), ContainerID("never-existed")); err != nil { + t.Fatalf("Remove(unknown) = %v, want nil", err) + } + + id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-r", UID: 1000}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := m.Remove(context.Background(), id); err != nil { + t.Fatalf("Remove(never-started) = %v, want nil", err) + } + if _, err := m.session(id); err == nil { + t.Fatal("session still in table after Remove") + } + // A second Remove is still a no-op success. + if err := m.Remove(context.Background(), id); err != nil { + t.Fatalf("second Remove = %v, want nil (idempotent)", err) + } +} + +// TestMountLabelEmpty: MountLabel returns the empty label (skip-chcon) for any +// id, per the parent's Q-mountlabel deferral. +func TestMountLabelEmpty(t *testing.T) { + m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) + label, err := m.MountLabel(context.Background(), ContainerID("anything")) + if err != nil || label != "" { + t.Fatalf("MountLabel = (%q, %v), want (\"\", nil)", label, err) + } +} + +// TestResizeNotImplemented: Resize returns the shared ErrResizeNotImplemented +// sentinel, matching PodmanCLI.Resize (the C3/D5 deferral). +func TestResizeNotImplemented(t *testing.T) { + m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) + if err := m.Resize(context.Background(), ContainerID("c"), ResourceLimits{}); !errors.Is(err, ErrResizeNotImplemented) { + t.Fatalf("Resize err = %v, want ErrResizeNotImplemented", err) + } +} + +// TestStartUnknownSession: Start on an id with no session errors rather than +// booting. +func TestStartUnknownSession(t *testing.T) { + m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) + if err := m.Start(context.Background(), ContainerID("ghost")); err == nil { + t.Fatal("Start(unknown) err = nil, want a no-session error") + } +} + +// TestExecUnstartedSession: Exec on a created-but-not-started session errors +// (no exec client yet) rather than panicking. +func TestExecUnstartedSession(t *testing.T) { + m := NewMicroVMRuntime(MicroVMConfig{RunRoot: t.TempDir()}) + id, err := m.Create(context.Background(), ContainerSpec{Name: "agent-e", UID: 1000}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if _, err := m.Exec(context.Background(), id, NewExecSpec("true")); err == nil { + t.Fatal("Exec on an unstarted session err = nil, want a not-started error") + } +} + +// TestExitErrorMapping pins ExecStreaming's waitFunc ExitStatus→error contract +// (OQ-G/U3b): a signalled exit carries the signal (so isDeliberateKill sees +// Signal!=0), a non-zero code carries the code, a clean exit is nil. +func TestExitErrorMapping(t *testing.T) { + // (a) signalled exit → *ExitStatusError with the signal. + err := exitError(microvm.ExitStatus{Signal: int(syscall.SIGKILL)}) + var signalled *ExitStatusError + if !errors.As(err, &signalled) { + t.Fatalf("signalled exit err = %v, want *ExitStatusError", err) + } + if signalled.Signal != syscall.SIGKILL { + t.Fatalf("Signal = %v, want SIGKILL", signalled.Signal) + } + if signalled.Signal == 0 { + t.Fatal("signalled exit must have Signal != 0 so isDeliberateKill recognizes it") + } + + // (b) non-zero code → *ExitStatusError with the code, no signal. + err = exitError(microvm.ExitStatus{Code: 3}) + var coded *ExitStatusError + if !errors.As(err, &coded) { + t.Fatalf("non-zero code err = %v, want *ExitStatusError", err) + } + if coded.Code != 3 || coded.Signal != 0 { + t.Fatalf("coded = {Code:%d Signal:%d}, want {Code:3 Signal:0}", coded.Code, coded.Signal) + } + + // (c) clean exit → nil. + if err := exitError(microvm.ExitStatus{}); err != nil { + t.Fatalf("clean exit err = %v, want nil", err) + } +} + +func strPtr(s string) *string { return &s } diff --git a/go/internal/runtime/microvm_test.go b/go/internal/runtime/microvm_test.go index 356158237..23b1f8d4a 100644 --- a/go/internal/runtime/microvm_test.go +++ b/go/internal/runtime/microvm_test.go @@ -1,17 +1,16 @@ package runtime -// The microVM backend seam suite: hermetic, no subprocess. These pin the -// backend-selection contract (the transitional podman default, the microVM -// opt-in, the loud rejection of an unknown backend) and the not-implemented -// posture every MicroVMRuntime method holds until the in-guest control plane -// lands. +// The microVM backend-selection suite: hermetic, no subprocess, no build tag. +// It pins the backend-selection contract (the transitional podman default, the +// microVM opt-in, the loud rejection of an unknown backend) — the part of the +// microVM seam that must type-check and run on any platform. The lifecycle +// method behavior (spec→BootConfig, spec→ExecCall, the session table, mount +// refusal, idempotent Remove) lives in microvm_lifecycle_test.go behind +// //go:build unix, matching the unix-only lifecycle file it exercises. import ( - "context" - "errors" "strings" "testing" - "time" ) // SelectBackend defaults to podman: an empty or explicit "podman" backend must @@ -55,32 +54,3 @@ func TestSelectBackendUnknown(t *testing.T) { t.Fatalf("SelectBackend(bogus) err = %q, want it to name the bad value", err) } } - -// Every MicroVMRuntime method returns ErrMicroVMNotImplemented until the -// in-guest control plane lands. This pins the not-implemented contract so the -// task filling in each verb deliberately deletes its row here rather than -// silently regressing past a faked container operation. -func TestMicroVMRuntimeNotImplemented(t *testing.T) { - ctx := context.Background() - m := NewMicroVMRuntime(MicroVMConfig{}) - - tests := []struct { - name string - call func() error - }{ - {"Create", func() error { _, err := m.Create(ctx, ContainerSpec{}); return err }}, - {"Start", func() error { return m.Start(ctx, ContainerID("c")) }}, - {"Exec", func() error { _, err := m.Exec(ctx, ContainerID("c"), ExecSpec{}); return err }}, - {"ExecStreaming", func() error { _, err := m.ExecStreaming(ctx, ContainerID("c"), StreamingExecSpec{}); return err }}, - {"Stop", func() error { return m.Stop(ctx, ContainerID("c"), time.Second) }}, - {"Remove", func() error { return m.Remove(ctx, ContainerID("c")) }}, - {"Exists", func() error { _, err := m.Exists(ctx, "c"); return err }}, - {"MountLabel", func() error { _, err := m.MountLabel(ctx, ContainerID("c")); return err }}, - {"Resize", func() error { return m.Resize(ctx, ContainerID("c"), ResourceLimits{}) }}, - } - for _, tt := range tests { - if err := tt.call(); !errors.Is(err, ErrMicroVMNotImplemented) { - t.Errorf("%s err = %v, want ErrMicroVMNotImplemented", tt.name, err) - } - } -}