diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d776fd9..12bddb21 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1674,21 +1674,44 @@ jobs: # Run the darwin-tagged unit suite for the shell entrypoint — this is # the ONLY lane that executes it. The moon `compass-go:test` lane runs # untagged (`go test ./...`), which compiles the non-gtk4 stub and - # excludes main_test.go; the gtk4-e2e lane compiles the gtk4 build but - # `-run E2E`-filters, so it never executes TestDistDirForExecutable. - # That test defends the .app dist-resolution contract (the resolver - # returns Contents/Resources/dist under a Contents/MacOS executable, - # else dist beside it), which is exactly the behavioral change this - # lane ships — so its regression guard lives here or nowhere. + # excludes main_test.go, machine_test.go and embedded_test.go; the + # gtk4-e2e lane compiles the gtk4 build but `-run E2E`-filters, so it + # never reaches any of them. These tests defend contracts that ARE the + # darwin behaviour this lane ships, so their regression guards live + # here or nowhere: + # - DistDirForExecutable: the .app dist-resolution contract + # (Contents/Resources/dist under a Contents/MacOS executable, + # else dist beside it). + # - Machine*/EnsureMachine*: the podman-machine probe + ensure step, + # including that an unclassifiable CLI answer provisions nothing. + # - RealPreflightDeps*/ClassifyPreflight*: that the darwin machine + # adapter is actually WIRED and that an unmet machine check is + # fatal. This is the pair that catches the silent-skip regression + # (a nil adapter making the check vanish into an all-green + # preflight), so it is the last thing that should run nowhere. + # - BringUpTimeout*: that darwin keeps a window a cold + # `podman machine init` can fit inside. # `-run` alone exits 0 when it matches nothing (a rename → false - # green), so require the test's own PASS line — a rename or skip reds. + # green), so require each group's own PASS line — a rename or skip + # reds. The filter is explicit rather than the whole package because + # the package also holds GUI E2E tests that need a display. CGO_ENABLED=1 go -C go test -trimpath \ - -run 'TestDistDirForExecutable' -count=1 -v \ + -run 'TestDistDirForExecutable|TestMachineReady|TestEnsureMachineReady|TestMachineResourceFloorIsExplicit|TestRealPreflightDeps|TestClassifyPreflight|TestBringUpTimeout' \ + -count=1 -v \ ./cmd/compass-app/ | tee /tmp/darwin-unit.log - grep -q '^--- PASS: TestDistDirForExecutable' /tmp/darwin-unit.log || { - echo "::error::darwin: TestDistDirForExecutable did not run+pass (renamed or skipped?)" - exit 1 - } + for t in TestDistDirForExecutable \ + TestMachineReadyRunning \ + TestMachineReadyNoMachine \ + TestEnsureMachineReadyNoMachineProvisions \ + TestEnsureMachineReadyUnclassifiedDoesNotProvision \ + TestRealPreflightDepsWiresDarwinMachineAdapter \ + TestClassifyPreflightUnwiredDarwinMachineIsFatal \ + TestBringUpTimeoutBudgetsDarwinColdProvisioning; do + grep -q "^--- PASS: $t" /tmp/darwin-unit.log || { + echo "::error::darwin: $t did not run+pass (renamed or skipped?)" + exit 1 + } + done # The UI dist the .app stages into Contents/Resources/dist. moon run compass-ui:build diff --git a/go/cmd/compass-app/embedded.go b/go/cmd/compass-app/embedded.go index 8b8e8830..3a7d50d8 100644 --- a/go/cmd/compass-app/embedded.go +++ b/go/cmd/compass-app/embedded.go @@ -373,21 +373,37 @@ func resolveImage(flagValue string) string { // the app-side DSN duplicate are gone (§A2 reconciliation 1): under DL-260 // postgres is a container the stack itself starts, so a pre-`up` reachability // probe has no signal on the cold-start path — `up`-Ready is the DB -// verification. On darwin the machine adapter is wired by T-6; a nil -// MachineReady here leaves that check absent until then (design §A5). +// verification. MachineReady comes from the per-OS machineReadyAdapter: on +// darwin it is the podman-machine ensure step (provision or start the Linux VM, +// then re-probe — design §A5), and on linux it is nil because there is no +// machine. The preflight core keys the check off GOOS and FAILS on darwin when +// the adapter is nil, so this wiring cannot regress into a silently-skipped +// check. func realPreflight(image string) func(ctx context.Context) error { - deps := preflight.Deps{ - GOOS: runtime.GOOS, - PodmanRootless: podmanRootless, - PodmanVersion: podmanVersionAtLeastFloor, - ImagePresent: imagePresent, - } + deps := realPreflightDeps(runtime.GOOS) params := preflight.Params{AgentImage: image} return func(ctx context.Context) error { return classifyPreflight(deps.Run(ctx, params)) } } +// realPreflightDeps assembles the Deps literal for the given host OS. It takes +// goos as an argument, rather than reading runtime.GOOS itself, so a test +// running on ANY host can assert what the darwin wiring carries — the machine +// check going missing on darwin is the exact regression this seam exists to +// catch, and it is unobservable from a linux test if the builder resolves its +// own OS. The one goos value feeds both the core's check selection and the +// machine adapter, so the two cannot disagree about which host this is. +func realPreflightDeps(goos string) preflight.Deps { + return preflight.Deps{ + GOOS: goos, + PodmanRootless: podmanRootless, + PodmanVersion: podmanVersionAtLeastFloor, + MachineReady: machineReadyAdapter(goos), + ImagePresent: imagePresent, + } +} + // classifyPreflight splits the preflight results by severity at the wiring // boundary and returns only the FATAL failures folded into one legible error // (nil when none are fatal). diff --git a/go/cmd/compass-app/embedded_test.go b/go/cmd/compass-app/embedded_test.go index 9eb033b0..2a113082 100644 --- a/go/cmd/compass-app/embedded_test.go +++ b/go/cmd/compass-app/embedded_test.go @@ -489,6 +489,72 @@ func TestClassifyPreflightHostCapFatalEvenWithAdvisoryUnmet(t *testing.T) { } } +// TestRealPreflightDepsWiresDarwinMachineAdapter is the regression guard for the +// silent skip: realPreflightDeps must carry a non-nil MachineReady on darwin. It +// asserts the WIRING, not a probe result, so it fails on a linux CI host the +// moment the adapter is dropped from the Deps literal — the defect was +// invisible precisely because a missing adapter produced no failing check. +func TestRealPreflightDepsWiresDarwinMachineAdapter(t *testing.T) { + deps := realPreflightDeps("darwin") + if deps.MachineReady == nil { + t.Fatal("realPreflightDeps left MachineReady nil on darwin; the machine check would be a wiring failure") + } + if deps.GOOS != "darwin" { + t.Errorf("GOOS = %q, want the injected darwin", deps.GOOS) + } +} + +// TestRealPreflightDepsLeavesLinuxMachineUnwired: linux podman is native, so +// there is no machine adapter — the core keys the check off GOOS and omits it +// here. This pins that closing the darwin hole did not add a bogus linux check. +// It asserts only the WIRING: running deps here would shell the real podman +// probes, and the core package already owns the absent-on-linux assertion +// hermetically (preflight.TestRunMachineCheckAbsentOnLinux). +func TestRealPreflightDepsLeavesLinuxMachineUnwired(t *testing.T) { + deps := realPreflightDeps("linux") + if deps.MachineReady != nil { + t.Fatal("realPreflightDeps wired a machine adapter on linux; there is no machine to check") + } + if deps.GOOS != "linux" { + t.Errorf("GOOS = %q, want the injected linux", deps.GOOS) + } +} + +// TestClassifyPreflightMachineUnmetIsFatal verifies — rather than assumes — that +// a failing machine check reaches the FATAL fold. classifyPreflight special-cases +// only CheckImage as advisory, so the machine check falls to the default arm; +// this exercises that path end-to-end so the doc comment's "fatal on darwin" +// claim is enforced by a test rather than by reading the switch. +func TestClassifyPreflightMachineUnmetIsFatal(t *testing.T) { + machineErr := errors.New("no podman machine exists") + deps := classifyDeps() + deps.GOOS = "darwin" + deps.MachineReady = func(context.Context) error { return machineErr } + err := classify(t, deps) + if err == nil { + t.Fatal("machine unmet on darwin: classify err = nil, want fatal") + } + if !strings.Contains(err.Error(), machineErr.Error()) { + t.Errorf("fatal error %q does not carry the machine failure", err.Error()) + } +} + +// TestClassifyPreflightUnwiredDarwinMachineIsFatal: the wiring defect itself is +// fatal, not advisory — a darwin build whose machine adapter went missing +// refuses to launch instead of proceeding on an unverified host. +func TestClassifyPreflightUnwiredDarwinMachineIsFatal(t *testing.T) { + deps := classifyDeps() + deps.GOOS = "darwin" + deps.MachineReady = nil + err := classify(t, deps) + if err == nil { + t.Fatal("unwired machine adapter on darwin: classify err = nil, want fatal") + } + if !strings.Contains(err.Error(), "no podman machine adapter is wired") { + t.Errorf("fatal error %q does not name the wiring defect", err.Error()) + } +} + // TestRunStackUpDeadlineExceededNamesBringUpWindow: when the child fails because // the context deadline was exceeded, the error names the bring-up window (the // likely cause) rather than surfacing a bare deadline error. Driven with an diff --git a/go/cmd/compass-app/machine.go b/go/cmd/compass-app/machine.go new file mode 100644 index 00000000..26c26b75 --- /dev/null +++ b/go/cmd/compass-app/machine.go @@ -0,0 +1,423 @@ +//go:build (linux && gtk4) || darwin + +// The podman-machine probe and ensure step behind an injected seam. On macOS the +// podman CLI drives a Linux VM ("the machine") and a fresh Mac has no machine at +// all, so embedded mode must both DETECT the machine's state and PROVISION it — +// mirroring how `compass-stack up` ensures the agent image and the database +// rather than gating on them. The OS choice is a parameter, not a build tag +// (machineReadyAdapter takes the GOOS the preflight core is already keyed on), +// so the darwin wiring is reachable from a test on any host — a build-tagged +// darwin adapter would make the regression this closes untestable on every +// lane that actually runs. The classification, the error copy, and the ensure +// orchestration are inverted over machineDeps, so all four states (no machine +// / stopped / running / init fails) are unit-testable with no podman present. +// +// Every shape this file reads out of the podman CLI is an ASSUMPTION about +// external behavior — the design record marks the `machine inspect` socket path +// and the `machine ls --format json` no-machine-vs-stopped distinction as +// spike-verified, and the spike has not run. So the parsing is deliberately +// defensive and TOLERANT of shape drift (both the `Running` bool and the `State` +// string are accepted; a missing field degrades, never panics), and an +// unparseable answer is classified UNKNOWN, which is never ready. The failure +// copy always names the podman command the operator can run themselves. +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "os/exec" //nolint:depguard // podman machine seam: fixed-arg `podman machine ls|inspect|init|start` subprocesses + "strconv" + "strings" +) + +// podmanBin is the podman executable name, resolved on PATH. +const podmanBin = "podman" + +// The machine resource floor passed to `podman machine init`. `podman machine +// init`'s own defaults are modest (2 GiB of memory), and the embedded stack runs +// FOUR containers inside the VM — postgres, the collector, the server, and an +// agent container per session — so the default leaves the machine thrashing or +// OOM-killing the agent under a normal session. The floor below is sized for +// that set with headroom for a second concurrent agent; the disk figure covers +// the pulled images (agent + postgres + collector) plus the postgres data +// directory's growth over a long-lived install. +// +// UNVERIFIED: no macOS host has run the spike, so this floor is a reasoned +// choice, not a measured one, and the units are the podman CLI's documented ones +// (--memory in MiB, --disk-size in GiB). The spike is what turns it into a +// number recorded in the self-host doc. +const ( + machineMemoryFloorMiB = 8192 + machineDiskFloorGiB = 100 +) + +// machineStatus is the classified state of the podman machine. The zero value is +// machineUnknown so a failed classification can never read as ready. +type machineStatus int + +const ( + // machineUnknown: the podman CLI's answer could not be classified (it failed, + // or its output did not parse). Never treated as ready. + machineUnknown machineStatus = iota + // machineAbsent: no machine exists at all — the fresh-Mac state. Fixed by init. + machineAbsent + // machineStopped: a machine exists but is not running. Fixed by start. + machineStopped + // machineRunning: the machine reports itself running; its socket still has to + // be reachable before the machine counts as ready. + machineRunning +) + +// machineDeps is the seam the machine probe and ensure step are inverted over: +// one field per genuine external effect, exactly as preflight.Deps inverts the +// host checks it runs. The real adapters shell the podman CLI +// (realMachineDeps); tests supply deterministic stubs. +type machineDeps struct { + // list runs `podman machine ls --format json` and returns its stdout. Its + // presence-or-absence is the one thing the classification depends on: an + // empty list means no machine exists. What `inspect` does for a stopped + // machine is one of the unspiked assumptions this file's doc flags, so + // nothing is inferred from it failing. + list func(ctx context.Context) ([]byte, error) + // inspect runs `podman machine inspect` (no machine named, so the DEFAULT + // machine) and returns its stdout: the authoritative state plus the host-side + // API socket path the VM forwards. + inspect func(ctx context.Context) ([]byte, error) + // initMachine runs `podman machine init` with the resource floor. On a fresh + // host this DOWNLOADS a VM image and takes minutes. + initMachine func(ctx context.Context) error + // startMachine runs `podman machine start` on the default machine. + startMachine func(ctx context.Context) error + // dialSocket proves the machine's forwarded API socket is actually reachable + // from the host (a running machine whose socket does not answer is not ready). + dialSocket func(ctx context.Context, path string) error +} + +// machineInfo is one classification of the machine: its status, the machine name +// to name in operator copy, and the host-side API socket path when known. +type machineInfo struct { + status machineStatus + name string + socket string +} + +// machineListEntry is the subset of `podman machine ls --format json` this code +// reads. Running is a pointer and State is a string because the field set +// differs across podman versions and this parse must not depend on either being +// present: presence in the list is what establishes existence, and running-ness +// is read from whichever field the CLI supplied. +// +// A machine mid-start is deliberately NOT a distinct case. It classifies as +// stopped and gets a `machine start`, which is a no-op on a machine already +// coming up — one redundant command on a rare path, against a third state to +// carry through the whole ensure step. +type machineListEntry struct { + Name string `json:"Name"` + Running *bool `json:"Running"` + State string `json:"State"` + Default bool `json:"Default"` +} + +// running reports whether this entry says the machine is up, accepting either +// the boolean or the string spelling. +func (e machineListEntry) running() bool { + if e.Running != nil && *e.Running { + return true + } + return strings.EqualFold(strings.TrimSpace(e.State), "running") +} + +// machineInspectEntry is the subset of `podman machine inspect` this code reads: +// the state and the forwarded podman API socket path +// (.ConnectionInfo.PodmanSocket.Path). +type machineInspectEntry struct { + Name string `json:"Name"` + State string `json:"State"` + ConnectionInfo struct { + PodmanSocket struct { + Path string `json:"Path"` + } `json:"PodmanSocket"` + } `json:"ConnectionInfo"` +} + +// errMachineUnclassified is the sentinel for "the podman CLI's answer could not +// be turned into a state". Callers wrap it with the command to run; it exists so +// the ensure step can tell an unclassifiable answer (do nothing, surface it) +// from a state it knows how to fix. +var errMachineUnclassified = errors.New("the podman machine state could not be determined") + +// probeMachine classifies the machine from the podman CLI. It calls `machine ls` +// FIRST — that is the only call that separates no-machine from stopped-machine — +// and then `machine inspect` for the authoritative state and the socket path of +// the default machine. A CLI or parse failure yields machineUnknown with an +// error naming the command to run by hand; it never guesses ready. +func probeMachine(ctx context.Context, d machineDeps) (machineInfo, error) { + out, err := d.list(ctx) + if err != nil { + return machineInfo{}, fmt.Errorf("%w: `%s machine ls --format json` failed: %w", + errMachineUnclassified, podmanBin, err) + } + var listed []machineListEntry + if err := json.Unmarshal(out, &listed); err != nil { + return machineInfo{}, fmt.Errorf( + "%w: `%s machine ls --format json` output did not parse (%w); run it by hand to see what podman reports", + errMachineUnclassified, podmanBin, err) + } + if len(listed) == 0 { + return machineInfo{status: machineAbsent}, nil + } + + // A machine exists. Prefer the default entry for the name, since the podman + // CLI resolves its connection to the default machine. + entry := listed[0] + for _, e := range listed { + if e.Default { + entry = e + break + } + } + + insp, err := inspectMachine(ctx, d) + if err != nil { + return machineInfo{name: entry.Name}, err + } + name := insp.Name + if name == "" { + name = entry.Name + } + + // Running-ness: inspect's state is authoritative when it says running; + // otherwise fall back to the list entry, so a podman version that omits + // State from one of the two commands still classifies. + running := strings.EqualFold(strings.TrimSpace(insp.State), "running") || entry.running() + if !running { + return machineInfo{status: machineStopped, name: name}, nil + } + return machineInfo{ + status: machineRunning, + name: name, + socket: strings.TrimSpace(insp.ConnectionInfo.PodmanSocket.Path), + }, nil +} + +// inspectMachine runs the inspect seam and pulls out the single entry for the +// default machine. inspect returns a JSON ARRAY even for one machine; an empty +// array or a parse failure is unclassified, never ready. +func inspectMachine(ctx context.Context, d machineDeps) (machineInspectEntry, error) { + out, err := d.inspect(ctx) + if err != nil { + return machineInspectEntry{}, fmt.Errorf("%w: `%s machine inspect` failed: %w", + errMachineUnclassified, podmanBin, err) + } + var entries []machineInspectEntry + if err := json.Unmarshal(out, &entries); err != nil { + return machineInspectEntry{}, fmt.Errorf( + "%w: `%s machine inspect` output did not parse (%w); run it by hand to see what podman reports", + errMachineUnclassified, podmanBin, err) + } + if len(entries) == 0 { + return machineInspectEntry{}, fmt.Errorf( + "%w: `%s machine inspect` described no machine even though `%s machine ls` listed one", + errMachineUnclassified, podmanBin, podmanBin) + } + return entries[0], nil +} + +// machineReady is the probe half: nil when the machine is up AND its forwarded +// API socket answers, and otherwise an error whose copy distinguishes the three +// failing states the operator can act on — no machine, a stopped machine, and a +// running machine with an unreachable socket — each naming the command to run. +func machineReady(ctx context.Context, d machineDeps) error { + info, err := probeMachine(ctx, d) + if err != nil { + return err + } + switch info.status { + case machineAbsent: + return fmt.Errorf("no podman machine exists; create one with `%s machine init --memory %d --disk-size %d` "+ + "(the first run downloads a VM image and takes several minutes)", + podmanBin, machineMemoryFloorMiB, machineDiskFloorGiB) + case machineStopped: + return fmt.Errorf("the podman machine %q exists but is not running; start it with `%s machine start %s`", + info.name, podmanBin, info.name) + case machineRunning: + return machineSocketReachable(ctx, d, info) + case machineUnknown: + return machineStateUnreadable() + default: + return machineStateUnreadable() + } +} + +// machineStateUnreadable is the error for a machine state the podman CLI would +// not tell us. Shared by the probe and the ensure step so both report the same +// copy, and pointing at the command whose output could not be classified. +func machineStateUnreadable() error { + return fmt.Errorf("%w; run `%s machine ls --format json` to see what podman reports", + errMachineUnclassified, podmanBin) +} + +// machineSocketReachable checks the third failing state: the machine is running +// but the API socket the VM forwards to the host does not answer. An empty path +// counts as unreachable — a running machine that reports no socket is exactly +// the unparseable-response case, and treating it as ready is what would produce +// a green preflight followed by an undiagnosable failure. +func machineSocketReachable(ctx context.Context, d machineDeps, info machineInfo) error { + if info.socket == "" { + return fmt.Errorf("the podman machine %q is running but `%s machine inspect` reported no API socket path; "+ + "restart it with `%s machine stop %s && %s machine start %s`", + info.name, podmanBin, podmanBin, info.name, podmanBin, info.name) + } + if err := d.dialSocket(ctx, info.socket); err != nil { + return fmt.Errorf("the podman machine %q is running but its API socket %s is unreachable (%w); "+ + "restart it with `%s machine stop %s && %s machine start %s`", + info.name, info.socket, err, podmanBin, info.name, podmanBin, info.name) + } + return nil +} + +// ensureMachineReady is what the darwin MachineReady adapter wires: it makes the +// machine ready rather than merely reporting on it. On no-machine it inits then +// starts; on a stopped machine it starts; then it RE-PROBES, because the +// authority on readiness is the probe, never the exit status of init/start. A +// state it cannot fix (an unclassifiable CLI answer, or a running machine whose +// socket does not answer) is surfaced from the probe unchanged. +// +// The init download is minutes long and runs under the caller's context, which +// the embedded pipeline bounds with its bring-up window. On darwin that window +// is sized for a cold provision (bringUpTimeoutFor in main.go), so a healthy +// first run fits inside it. The copy on the failure path still names the init +// command, so an operator who does exhaust the window gets something to run by +// hand rather than a bare deadline error. +func ensureMachineReady(ctx context.Context, d machineDeps) error { + info, err := probeMachine(ctx, d) + if err != nil { + return err + } + switch info.status { + case machineAbsent: + if err := d.initMachine(ctx); err != nil { + return fmt.Errorf("provisioning a podman machine with `%s machine init --memory %d --disk-size %d` "+ + "failed (%w); run it by hand — the first run downloads a VM image and takes several minutes", + podmanBin, machineMemoryFloorMiB, machineDiskFloorGiB, err) + } + if err := d.startMachine(ctx); err != nil { + return fmt.Errorf("the podman machine was created but `%s machine start` failed (%w); "+ + "run it by hand to see what podman reports", podmanBin, err) + } + case machineStopped: + if err := d.startMachine(ctx); err != nil { + return fmt.Errorf("starting the podman machine %q with `%s machine start %s` failed (%w); "+ + "run it by hand to see what podman reports", info.name, podmanBin, info.name, err) + } + case machineRunning: + // Nothing to provision; a running machine only needs its socket checked, + // which the re-probe below does. + case machineUnknown: + // Not something init/start can fix, and re-probing would only ask the + // same unintelligible question again — reporting the second answer + // instead of the first, which is worse if the machine changed state + // between the two. Report what the probe already told us. + return machineStateUnreadable() + default: + return machineStateUnreadable() + } + return machineReady(ctx, d) +} + +// machineReadyAdapter returns the preflight.Deps.MachineReady adapter for the +// given host OS: on darwin the podman-machine ENSURE step (provision or start +// the Linux VM, then re-probe), and nil elsewhere — linux podman is native, so +// there is no machine and the preflight core omits the check. +// +// The OS is a PARAMETER rather than a build tag, matching preflight.Deps.GOOS: +// a build-tagged darwin-only adapter would be uncompilable from a linux test, so +// the very regression this closes — a darwin build reaching preflight with no +// machine adapter — could not be tested anywhere the CI actually runs. Keyed off +// GOOS instead, a linux host can assert the darwin wiring. +func machineReadyAdapter(goos string) func(ctx context.Context) error { + if goos != "darwin" { + return nil + } + deps := realMachineDeps() + return func(ctx context.Context) error { + return ensureMachineReady(ctx, deps) + } +} + +// realMachineDeps builds the machine seam over the real podman CLI. Each field +// is one fixed-argv subprocess; the argv carries no caller-supplied strings +// except the resource floor constants, so there is nothing to inject into it. +func realMachineDeps() machineDeps { + return machineDeps{ + list: func(ctx context.Context) ([]byte, error) { + return machineOutput(ctx, "ls", "--format", "json") + }, + inspect: func(ctx context.Context) ([]byte, error) { + return machineOutput(ctx, "inspect") + }, + initMachine: func(ctx context.Context) error { + return machineRun(ctx, "init", + "--memory", strconv.Itoa(machineMemoryFloorMiB), + "--disk-size", strconv.Itoa(machineDiskFloorGiB)) + }, + startMachine: func(ctx context.Context) error { + return machineRun(ctx, "start") + }, + dialSocket: dialUnixSocket, + } +} + +// machineOutput runs `podman machine ` and returns its STDOUT only — +// the JSON readers must not be fed podman's warnings — wrapping a failure with +// the captured stderr so the copy names why podman refused. +func machineOutput(ctx context.Context, args ...string) ([]byte, error) { + //nolint:gosec // G204: fixed argv. Every caller is a closure in + // realMachineDeps passing literal subcommands and the two resource-floor + // constants, so nothing caller-supplied reaches the argv. + cmd := exec.CommandContext(ctx, podmanBin, append([]string{"machine"}, args...)...) + out, err := cmd.Output() + if err != nil { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { + if msg := strings.TrimSpace(string(exitErr.Stderr)); msg != "" { + return nil, fmt.Errorf("%w: %s", err, msg) + } + } + return nil, err + } + return out, nil +} + +// machineRun runs a `podman machine ` mutation and discards its output, +// wrapping a failure with the combined output so the copy names why podman +// refused (init and start report their progress and their reasons there). +func machineRun(ctx context.Context, args ...string) error { + //nolint:gosec // G204: fixed argv, same as machineOutput above. + cmd := exec.CommandContext(ctx, podmanBin, append([]string{"machine"}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + if msg := strings.TrimSpace(string(out)); msg != "" { + return fmt.Errorf("%w: %s", err, msg) + } + return err + } + return nil +} + +// dialUnixSocket proves the forwarded podman API socket answers a connect. A +// stat would only prove the file exists, which a stale forward from a +// half-stopped machine also satisfies. +func dialUnixSocket(ctx context.Context, path string) error { + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "unix", path) + if err != nil { + return err + } + // The connect itself is the whole signal; nothing is written or read, and a + // close error on a socket we only probed is not actionable. + _ = conn.Close() + return nil +} diff --git a/go/cmd/compass-app/machine_test.go b/go/cmd/compass-app/machine_test.go new file mode 100644 index 00000000..5e56814a --- /dev/null +++ b/go/cmd/compass-app/machine_test.go @@ -0,0 +1,371 @@ +//go:build (linux && gtk4) || darwin + +package main + +import ( + "context" + "errors" + "strings" + "testing" +) + +// Fixtures shaped like the podman CLI output the machine code parses. Every one +// of these shapes is an ASSUMPTION about external podman behavior (the design +// record marks the inspect socket path and the `machine ls --format json` +// no-machine-vs-stopped distinction as spike-verified, and no macOS host has run +// the spike), so the parse is written to tolerate drift and the tests pin the +// tolerance, not one exact vendor shape. +const ( + listEmpty = `[]` + listRunning = `[{"Name":"podman-machine-default","Default":true,"Running":true}]` + listStopped = `[{"Name":"podman-machine-default","Default":true,"Running":false}]` + listStateOnly = `[{"Name":"podman-machine-default","Default":true,"State":"running"}]` + inspectRunning = `[{"Name":"podman-machine-default","State":"running",` + + `"ConnectionInfo":{"PodmanSocket":{"Path":"/tmp/podman.sock"}}}]` + inspectStopped = `[{"Name":"podman-machine-default","State":"stopped","ConnectionInfo":{}}]` + inspectNoSocket = `[{"Name":"podman-machine-default","State":"running","ConnectionInfo":{}}]` + inspectEmptyList = `[]` +) + +// stubMachineDeps returns a machineDeps whose every effect succeeds against a +// running machine with a reachable socket. Tests override one field at a time. +// The counters let a test assert the ensure step's ORDER and idempotence (that a +// running machine is never re-initialized). +type machineRecorder struct { + listCalls int + inspectCalls int + initCalls int + startCalls int + dialCalls int + dialed string + // listOut is returned by list; it is a field so the ensure step's re-probe + // can observe a DIFFERENT state than the first probe, which is how a real + // init/start becomes visible. + listOut string + inspectOut string +} + +func stubMachineDeps(rec *machineRecorder) machineDeps { + return machineDeps{ + list: func(context.Context) ([]byte, error) { + rec.listCalls++ + return []byte(rec.listOut), nil + }, + inspect: func(context.Context) ([]byte, error) { + rec.inspectCalls++ + return []byte(rec.inspectOut), nil + }, + initMachine: func(context.Context) error { + rec.initCalls++ + // A real init creates the machine, so the next probe sees it stopped. + rec.listOut = listStopped + rec.inspectOut = inspectStopped + return nil + }, + startMachine: func(context.Context) error { + rec.startCalls++ + rec.listOut = listRunning + rec.inspectOut = inspectRunning + return nil + }, + dialSocket: func(_ context.Context, path string) error { + rec.dialCalls++ + rec.dialed = path + return nil + }, + } +} + +// runningRecorder is the all-good starting state: a machine that exists and runs. +func runningRecorder() *machineRecorder { + return &machineRecorder{listOut: listRunning, inspectOut: inspectRunning} +} + +// TestMachineReadyRunning: the running state — the probe passes and it proves +// readiness by DIALING the forwarded socket inspect reported, not by trusting +// the state string alone. +func TestMachineReadyRunning(t *testing.T) { + rec := runningRecorder() + if err := machineReady(context.Background(), stubMachineDeps(rec)); err != nil { + t.Fatalf("machineReady on a running machine = %v, want nil", err) + } + if rec.dialCalls != 1 { + t.Errorf("dial calls = %d, want 1 (readiness must probe the socket)", rec.dialCalls) + } + if rec.dialed != "/tmp/podman.sock" { + t.Errorf("dialed %q, want the inspect ConnectionInfo.PodmanSocket.Path", rec.dialed) + } +} + +// TestMachineReadyStateStringOnly: a podman version that reports running-ness as +// a State string rather than a Running bool still classifies as running — the +// parse must not depend on either single spelling. +func TestMachineReadyStateStringOnly(t *testing.T) { + rec := &machineRecorder{listOut: listStateOnly, inspectOut: inspectRunning} + if err := machineReady(context.Background(), stubMachineDeps(rec)); err != nil { + t.Fatalf("machineReady with a State-only list entry = %v, want nil", err) + } +} + +// TestMachineReadyNoMachine: the fresh-Mac state. `machine ls` lists nothing, so +// the copy must say no machine exists and name the init command — and must NOT +// have consulted inspect (which fails identically for absent and stopped). +func TestMachineReadyNoMachine(t *testing.T) { + rec := &machineRecorder{listOut: listEmpty} + err := machineReady(context.Background(), stubMachineDeps(rec)) + if err == nil { + t.Fatal("machineReady with no machine = nil, want an error") + } + for _, tok := range []string{"no podman machine exists", "machine init", "--memory", "--disk-size"} { + if !strings.Contains(err.Error(), tok) { + t.Errorf("no-machine copy %q missing %q", err.Error(), tok) + } + } + if rec.inspectCalls != 0 { + t.Errorf("inspect calls = %d, want 0 (absence is established by ls alone)", rec.inspectCalls) + } +} + +// TestMachineReadyStopped: a machine exists but is not running — distinguishable +// from no-machine, and the copy names start (not init) plus the machine name. +func TestMachineReadyStopped(t *testing.T) { + rec := &machineRecorder{listOut: listStopped, inspectOut: inspectStopped} + err := machineReady(context.Background(), stubMachineDeps(rec)) + if err == nil { + t.Fatal("machineReady on a stopped machine = nil, want an error") + } + for _, tok := range []string{"is not running", "machine start", "podman-machine-default"} { + if !strings.Contains(err.Error(), tok) { + t.Errorf("stopped copy %q missing %q", err.Error(), tok) + } + } + if strings.Contains(err.Error(), "no podman machine exists") { + t.Errorf("stopped copy %q conflates a stopped machine with an absent one", err.Error()) + } + if rec.dialCalls != 0 { + t.Errorf("dial calls = %d, want 0 (a stopped machine has no socket to dial)", rec.dialCalls) + } +} + +// TestMachineReadyUnreachableSocket: the third distinguishable state — running, +// but the forwarded API socket does not answer. This is the state a state-string +// check alone would call ready. +func TestMachineReadyUnreachableSocket(t *testing.T) { + rec := runningRecorder() + deps := stubMachineDeps(rec) + deps.dialSocket = func(context.Context, string) error { + return errors.New("connect: connection refused") + } + err := machineReady(context.Background(), deps) + if err == nil { + t.Fatal("machineReady with an unreachable socket = nil, want an error") + } + for _, tok := range []string{"is running", "unreachable", "/tmp/podman.sock", "machine stop", "machine start"} { + if !strings.Contains(err.Error(), tok) { + t.Errorf("unreachable-socket copy %q missing %q", err.Error(), tok) + } + } +} + +// TestMachineReadyRunningWithoutSocketPath: a running machine whose inspect +// reports NO socket path is the unparseable-response case, and must never read +// as ready — that is precisely how a green preflight would precede an +// undiagnosable downstream failure. +func TestMachineReadyRunningWithoutSocketPath(t *testing.T) { + rec := &machineRecorder{listOut: listRunning, inspectOut: inspectNoSocket} + err := machineReady(context.Background(), stubMachineDeps(rec)) + if err == nil { + t.Fatal("machineReady with no reported socket path = nil, want an error") + } + if !strings.Contains(err.Error(), "no API socket path") { + t.Errorf("copy %q does not name the missing socket path", err.Error()) + } + if rec.dialCalls != 0 { + t.Errorf("dial calls = %d, want 0 (there is no path to dial)", rec.dialCalls) + } +} + +// TestMachineReadyUnparseableOutput: every way the podman CLI can answer +// unintelligibly — a failing command, non-JSON output, an inspect that describes +// no machine — classifies as UNKNOWN and errors. None of them may read as ready. +func TestMachineReadyUnparseableOutput(t *testing.T) { + cliErr := errors.New("podman: command not found") + cases := map[string]struct { + mutate func(*machineDeps) + want string + }{ + "ls fails": { + mutate: func(d *machineDeps) { + d.list = func(context.Context) ([]byte, error) { return nil, cliErr } + }, + want: "machine ls --format json` failed", + }, + "ls is not json": { + mutate: func(d *machineDeps) { + d.list = func(context.Context) ([]byte, error) { return []byte("Error: unknown flag"), nil } + }, + want: "did not parse", + }, + "inspect fails": { + mutate: func(d *machineDeps) { + d.inspect = func(context.Context) ([]byte, error) { return nil, cliErr } + }, + want: "machine inspect` failed", + }, + "inspect is not json": { + mutate: func(d *machineDeps) { + d.inspect = func(context.Context) ([]byte, error) { return []byte("not json"), nil } + }, + want: "did not parse", + }, + "inspect describes no machine": { + mutate: func(d *machineDeps) { + d.inspect = func(context.Context) ([]byte, error) { return []byte(inspectEmptyList), nil } + }, + want: "described no machine", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + deps := stubMachineDeps(runningRecorder()) + tc.mutate(&deps) + err := machineReady(context.Background(), deps) + if err == nil { + t.Fatalf("%s: machineReady = nil, want an error (unparseable is never ready)", name) + } + if !errors.Is(err, errMachineUnclassified) { + t.Errorf("%s: err %v does not wrap errMachineUnclassified", name, err) + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: copy %q missing %q", name, err.Error(), tc.want) + } + }) + } +} + +// TestEnsureMachineReadyNoMachineProvisions: the fresh-Mac path — init, then +// start, then RE-PROBE to nil. The re-probe is what establishes readiness; the +// exit status of init/start is not trusted on its own. +func TestEnsureMachineReadyNoMachineProvisions(t *testing.T) { + rec := &machineRecorder{listOut: listEmpty} + if err := ensureMachineReady(context.Background(), stubMachineDeps(rec)); err != nil { + t.Fatalf("ensureMachineReady from no machine = %v, want nil after provisioning", err) + } + if rec.initCalls != 1 { + t.Errorf("init calls = %d, want 1", rec.initCalls) + } + if rec.startCalls != 1 { + t.Errorf("start calls = %d, want 1 (a freshly-created machine is not running)", rec.startCalls) + } + if rec.listCalls < 2 { + t.Errorf("list calls = %d, want >= 2 (the ensure step must re-probe)", rec.listCalls) + } + if rec.dialCalls != 1 { + t.Errorf("dial calls = %d, want 1 (the re-probe proves the socket answers)", rec.dialCalls) + } +} + +// TestEnsureMachineReadyStoppedStarts: a stopped machine is STARTED, never +// re-initialized (init on an existing machine would fail, and would re-download). +func TestEnsureMachineReadyStoppedStarts(t *testing.T) { + rec := &machineRecorder{listOut: listStopped, inspectOut: inspectStopped} + if err := ensureMachineReady(context.Background(), stubMachineDeps(rec)); err != nil { + t.Fatalf("ensureMachineReady from stopped = %v, want nil after start", err) + } + if rec.initCalls != 0 { + t.Errorf("init calls = %d, want 0 (the machine already exists)", rec.initCalls) + } + if rec.startCalls != 1 { + t.Errorf("start calls = %d, want 1", rec.startCalls) + } +} + +// TestEnsureMachineReadyRunningIsNoOp: an already-ready machine provisions +// nothing — the ensure step is idempotent, so a normal launch pays only the probe. +func TestEnsureMachineReadyRunningIsNoOp(t *testing.T) { + rec := runningRecorder() + if err := ensureMachineReady(context.Background(), stubMachineDeps(rec)); err != nil { + t.Fatalf("ensureMachineReady on a running machine = %v, want nil", err) + } + if rec.initCalls != 0 || rec.startCalls != 0 { + t.Errorf("provisioned an already-running machine: init=%d start=%d", rec.initCalls, rec.startCalls) + } +} + +// TestEnsureMachineReadyInitFails: the init-fails state. The error names the +// init command WITH the resource floor so the operator can run it by hand, and +// the step does not press on to start a machine that was never created. +func TestEnsureMachineReadyInitFails(t *testing.T) { + rec := &machineRecorder{listOut: listEmpty} + deps := stubMachineDeps(rec) + deps.initMachine = func(context.Context) error { + rec.initCalls++ + return errors.New("no space left on device") + } + err := ensureMachineReady(context.Background(), deps) + if err == nil { + t.Fatal("ensureMachineReady with a failing init = nil, want an error") + } + for _, tok := range []string{"machine init", "--memory", "--disk-size", "no space left on device"} { + if !strings.Contains(err.Error(), tok) { + t.Errorf("init-failure copy %q missing %q", err.Error(), tok) + } + } + if rec.startCalls != 0 { + t.Errorf("start calls = %d, want 0 (nothing was created to start)", rec.startCalls) + } +} + +// TestEnsureMachineReadyStartFails: a start that refuses surfaces podman's +// reason and names the command, rather than reporting a bare unready machine. +func TestEnsureMachineReadyStartFails(t *testing.T) { + rec := &machineRecorder{listOut: listStopped, inspectOut: inspectStopped} + deps := stubMachineDeps(rec) + deps.startMachine = func(context.Context) error { + rec.startCalls++ + return errors.New("vfkit: not permitted") + } + err := ensureMachineReady(context.Background(), deps) + if err == nil { + t.Fatal("ensureMachineReady with a failing start = nil, want an error") + } + for _, tok := range []string{"machine start", "podman-machine-default", "vfkit: not permitted"} { + if !strings.Contains(err.Error(), tok) { + t.Errorf("start-failure copy %q missing %q", err.Error(), tok) + } + } +} + +// TestEnsureMachineReadyUnclassifiedDoesNotProvision: an unintelligible CLI +// answer is NOT something init/start can fix, so the ensure step must surface it +// rather than blindly initializing over a machine whose state it cannot read. +func TestEnsureMachineReadyUnclassifiedDoesNotProvision(t *testing.T) { + rec := runningRecorder() + deps := stubMachineDeps(rec) + deps.list = func(context.Context) ([]byte, error) { + rec.listCalls++ + return []byte("Error: unknown flag: --format"), nil + } + err := ensureMachineReady(context.Background(), deps) + if !errors.Is(err, errMachineUnclassified) { + t.Fatalf("ensureMachineReady on an unparseable ls = %v, want errMachineUnclassified", err) + } + if rec.initCalls != 0 || rec.startCalls != 0 { + t.Errorf("provisioned against an unreadable state: init=%d start=%d", rec.initCalls, rec.startCalls) + } +} + +// TestMachineResourceFloorIsExplicit: the floor must stay ABOVE `podman machine +// init`'s own 2 GiB memory default — the whole reason the flags are passed is +// that the default starves the four containers the embedded stack runs. A future +// edit that drops the floor back to the default silently reintroduces that. +func TestMachineResourceFloorIsExplicit(t *testing.T) { + const podmanDefaultMemoryMiB = 2048 + if machineMemoryFloorMiB <= podmanDefaultMemoryMiB { + t.Errorf("memory floor %d MiB does not exceed podman's own default %d MiB", + machineMemoryFloorMiB, podmanDefaultMemoryMiB) + } + if machineDiskFloorGiB <= 0 { + t.Errorf("disk floor %d GiB is not a usable size", machineDiskFloorGiB) + } +} diff --git a/go/cmd/compass-app/main.go b/go/cmd/compass-app/main.go index 75ba3daf..8f0b8fa9 100644 --- a/go/cmd/compass-app/main.go +++ b/go/cmd/compass-app/main.go @@ -29,6 +29,7 @@ import ( "log/slog" "os" "path/filepath" + "runtime" "time" "github.com/RigelBuild/compass/go/internal/appconfig" @@ -42,9 +43,33 @@ import ( // context-bound. It is generous because a cold first run pulls THREE images — // the agent image from GHCR plus the stock postgres and collector images // (DL-260) — before the stack reaches Ready, so the window covers three -// sequential registry pulls, not one. (darwin machine-init time is A5/T-6's -// concern and not folded in here.) -const bringUpTimeout = 180 * time.Second +// sequential registry pulls, not one. +// +// On darwin the window is wider still. The machine ensure step runs inside it, +// and a cold `podman machine init` downloads a VM image before any of the +// above starts — minutes on its own, on a link whose speed we do not control. +// A budget that cannot fit the work it wraps is not a backstop; it is a +// deadline the first launch on a fresh Mac loses every time, and the error it +// produces names the timeout rather than the download. So darwin gets a window +// sized for cold provisioning plus the same three pulls. Both remain backstops +// against a wedge, not performance targets. +// +// The bring-up runs entirely BEFORE the window opens, so on darwin a genuinely +// wedged provision is now a silent wait of this length with no UI at all. The +// provisioning state that would make a long-but-healthy first run legible is +// not built yet; until it is, this number buys a working first launch at the +// cost of a worse failure mode for a hung one. +var bringUpTimeout = bringUpTimeoutFor(runtime.GOOS) + +// bringUpTimeoutFor returns the bring-up budget for the given host OS. It takes +// the OS as a parameter rather than reading runtime.GOOS so the per-OS choice +// is unit-testable from any host. +func bringUpTimeoutFor(goos string) time.Duration { + if goos == "darwin" { + return 15 * time.Minute + } + return 180 * time.Second +} func main() { if err := run(); err != nil { diff --git a/go/cmd/compass-app/main_test.go b/go/cmd/compass-app/main_test.go index cbad08cd..9f9db6e6 100644 --- a/go/cmd/compass-app/main_test.go +++ b/go/cmd/compass-app/main_test.go @@ -5,6 +5,7 @@ package main import ( "path/filepath" "testing" + "time" ) // TestDistDirForExecutable pins the packaging-layout dist resolution: a macOS @@ -50,3 +51,30 @@ func TestDistDirForExecutable(t *testing.T) { } }) } + +// TestBringUpTimeoutBudgetsDarwinColdProvisioning pins that darwin gets a +// materially wider bring-up window than linux. The machine ensure step runs +// inside this budget, and a cold `podman machine init` downloads a VM image +// before the stack pulls a single container image, so a linux-sized window +// would deadline every first launch on a fresh Mac and report the timeout +// instead of the download. The assertion is a floor and a relation, not the +// literal figures: re-tuning either budget is fine, collapsing the darwin one +// back onto the linux one is the regression. +func TestBringUpTimeoutBudgetsDarwinColdProvisioning(t *testing.T) { + linux := bringUpTimeoutFor("linux") + darwin := bringUpTimeoutFor("darwin") + + if darwin <= linux { + t.Errorf("bringUpTimeoutFor(darwin) = %v, not greater than linux %v; a cold "+ + "podman machine init cannot fit a linux-sized window", darwin, linux) + } + // A cold VM-image download plus three registry pulls does not fit in five + // minutes on an ordinary connection. + if darwin < 10*time.Minute { + t.Errorf("bringUpTimeoutFor(darwin) = %v, too tight for a cold machine init "+ + "plus three image pulls", darwin) + } + if linux <= 0 { + t.Errorf("bringUpTimeoutFor(linux) = %v, want a positive backstop", linux) + } +} diff --git a/go/internal/preflight/preflight.go b/go/internal/preflight/preflight.go index aec124e1..0b0105ef 100644 --- a/go/internal/preflight/preflight.go +++ b/go/internal/preflight/preflight.go @@ -27,10 +27,15 @@ type Deps struct { // front door instead (design §A3 delta 4). PodmanVersion func(ctx context.Context) error // MachineReady probes that the darwin podman machine (the Linux VM podman - // runs inside on macOS) is up. Consulted ONLY on darwin; nil on linux (there - // is no machine to check). A nil error means ready; a non-nil error explains - // why not. The darwin adapter that supplies it lands in T-6 (design §A5); a - // nil MachineReady on darwin leaves the check absent until then. + // runs inside on macOS) is up, provisioning it if needed. Consulted ONLY on + // darwin; nil on linux (there is no machine to check). A nil error means + // ready; a non-nil error explains why not. + // + // On darwin it is REQUIRED: a nil MachineReady there is a wiring defect, and + // Run reports it as a FAILED machine check rather than omitting the check. + // Omitting it is the worse outcome — a Mac with no machine would pass + // preflight all-green and then fail somewhere downstream with nothing + // pointing at the cause. MachineReady func(ctx context.Context) error // ImagePresent probes that the given agent image ref is present in the local // container store. A nil error means present; a non-nil error means it is not @@ -110,13 +115,21 @@ func (d Deps) Run(ctx context.Context, p Params) Results { } results = append(results, pvRes) - // (4) Darwin podman machine ready. macOS runs podman inside a Linux VM; the - // check is consulted ONLY on darwin, and only when an adapter is wired (the - // darwin adapter lands in T-6). On linux there is no machine, so the check - // is absent. - if d.GOOS == "darwin" && d.MachineReady != nil { + // (4) Darwin podman machine ready. macOS runs podman inside a Linux VM. On + // linux there is no machine, so the check is correctly absent. On darwin the + // check ALWAYS appears: a missing adapter is reported as a failure, never + // skipped, so a wiring regression cannot turn a broken host into a green + // preflight. It is reported rather than panicked because the caller's + // failure path already surfaces legible copy, and a panic in a GUI binary + // would replace that copy with a stack trace. + if d.GOOS == "darwin" { machineRes := Result{Name: checkMachine, OK: true} - if err := d.MachineReady(ctx); err != nil { + if d.MachineReady == nil { + machineRes.OK = false + machineRes.Detail = "no podman machine adapter is wired on darwin; " + + "embedded mode cannot verify the Linux VM podman runs inside " + + "(this is a build/wiring defect, not a host condition)" + } else if err := d.MachineReady(ctx); err != nil { machineRes.OK = false machineRes.Detail = fmt.Sprintf("the podman machine is not ready: %v", err) } diff --git a/go/internal/preflight/preflight_test.go b/go/internal/preflight/preflight_test.go index 4c15aa1b..d162d631 100644 --- a/go/internal/preflight/preflight_test.go +++ b/go/internal/preflight/preflight_test.go @@ -120,20 +120,44 @@ func TestRunMachineNotReadyOnDarwin(t *testing.T) { assertErrContains(t, rs.Err(), "machine stopped") } -// TestRunMachineAbsentOnDarwinWithoutAdapter: on darwin with no MachineReady -// adapter wired (the pre-T-6 state), the machine check is absent rather than a -// spurious failure — the seam is wired, the adapter lands in T-6. -func TestRunMachineAbsentOnDarwinWithoutAdapter(t *testing.T) { +// TestRunMachineCheckFailsOnDarwinWithoutAdapter: a nil MachineReady on darwin +// is a wiring defect, and the check FAILS rather than vanishing. Omitting it +// would hand a Mac with no podman machine an all-green preflight followed by an +// undiagnosable downstream failure — the silent skip this behavior removes. +func TestRunMachineCheckFailsOnDarwinWithoutAdapter(t *testing.T) { ctx := context.Background() rs := okDeps("darwin").Run(ctx, testParams) - for _, r := range rs { - if r.Name == checkMachine { - t.Fatalf("machine check present on darwin without an adapter: %v", rs) - } + got := resultByName(t, rs, checkMachine) + if got.OK { + t.Fatal("machine check passed on darwin with no adapter wired; it must fail, never be skipped") } - if err := rs.Err(); err != nil { - t.Errorf("want nil error on darwin with no machine adapter, got %v", err) + if !strings.Contains(got.Detail, "no podman machine adapter is wired") { + t.Errorf("machine detail %q does not name the missing adapter", got.Detail) + } + assertErrContains(t, rs.Err(), "no podman machine adapter is wired") +} + +// TestRunMachineCheckAlwaysPresentOnDarwin: the machine check is present in the +// results on darwin for EVERY adapter state — ready, failing, or unwired. The +// regression this pins is the check being absent from a darwin run, which reads +// as a pass to any caller that classifies by result. +func TestRunMachineCheckAlwaysPresentOnDarwin(t *testing.T) { + ctx := context.Background() + adapters := map[string]func(context.Context) error{ + "ready": func(context.Context) error { return nil }, + "failing": func(context.Context) error { return errors.New("machine down") }, + "unwired": nil, + } + for name, adapter := range adapters { + t.Run(name, func(t *testing.T) { + d := okDeps("darwin") + d.MachineReady = adapter + rs := d.Run(ctx, testParams) + // resultByName t.Fatalf's when the check is missing, which IS the + // assertion: an absent machine check fails this test. + resultByName(t, rs, checkMachine) + }) } }