diff --git a/go/cmd/compass-runner/main.go b/go/cmd/compass-runner/main.go index a0ad6831c..969093fe3 100644 --- a/go/cmd/compass-runner/main.go +++ b/go/cmd/compass-runner/main.go @@ -194,6 +194,13 @@ type podmanPreflighter interface { VerifyUsernsRemapSupport(ctx context.Context) error } +// appleContainerPreflighter is the apple-container backend's static +// host-capability probe: the `container` CLI is present and meets the version +// floor this backend's command contract relies on. +type appleContainerPreflighter interface { + VerifyAppleContainerSupport(ctx context.Context) error +} + // canaryBooter is the microVM backend's dynamic host-capability probe: it really // boots a throwaway VM through the backend's own verbs, proving the whole boot // chain. Kept a DISTINCT single-method interface from microVMPreflighter (not a @@ -204,17 +211,19 @@ type canaryBooter interface { // verifyBackendPreflight runs the selected engine's static host-capability // preflight. It dispatches on the engine's concrete type, first match wins, -// probing the microVM backend before podman; no engine satisfies both today, so -// dispatch is deterministic. An engine exposing neither probe is a fail-closed -// startup error naming the concrete type — never a silent skip, so a backend -// added without a preflight surfaces loudly at launch rather than running -// unchecked. +// probing the microVM backend before podman and apple-container; no engine +// satisfies two of them today, so dispatch is deterministic. An engine exposing +// no probe is a fail-closed startup error naming the concrete type — never a +// silent skip, so a backend added without a preflight surfaces loudly at launch +// rather than running unchecked. func verifyBackendPreflight(ctx context.Context, engine runtime.WorkloadRuntime) error { switch e := engine.(type) { case microVMPreflighter: return runMicroVMPreflight(ctx, e, engine) case podmanPreflighter: return e.VerifyUsernsRemapSupport(ctx) + case appleContainerPreflighter: + return e.VerifyAppleContainerSupport(ctx) default: return fmt.Errorf("backend %T exposes no startup preflight probe", engine) } @@ -293,8 +302,8 @@ type backendFlags struct { func registerBackendFlags() backendFlags { return backendFlags{ backend: flag.String("backend", "", - "Container runtime backend: 'podman' (default, transitional) or "+ - "'microvm'. Defaults to $COMPASS_RUNTIME_BACKEND."), + "Container runtime backend: 'podman' (default, transitional), "+ + "'microvm' or 'apple-container'. Defaults to $COMPASS_RUNTIME_BACKEND."), vmm: flag.String("microvm-vmm", "", "Path to the microVM monitor binary (microvm backend). Defaults to $COMPASS_MICROVM_VMM."), virtiofsd: flag.String("microvm-virtiofsd", "", @@ -371,6 +380,10 @@ func (f backendFlags) backendConfig() (runtime.BackendConfig, error) { DefaultMemoryMB: memoryMB, QuotaRequired: quotaRequired, }, + // Zero, explicitly: the runner exposes no program/timeout flags for + // this backend, and NewAppleContainerCLI reads a zero field as "use the + // default" (`container` on PATH, the shared command timeout). + AppleContainer: runtime.AppleContainerConfig{}, }, nil } diff --git a/go/cmd/compass-runner/main_test.go b/go/cmd/compass-runner/main_test.go index d2d8dd945..f7770f562 100644 --- a/go/cmd/compass-runner/main_test.go +++ b/go/cmd/compass-runner/main_test.go @@ -18,9 +18,10 @@ import ( // podman branch also depends on it NOT satisfying microVMPreflighter; that // precedence is exercised by TestVerifyBackendPreflight, not asserted here. var ( - _ microVMPreflighter = (*runtime.MicroVMRuntime)(nil) - _ podmanPreflighter = (*runtime.PodmanCLI)(nil) - _ canaryBooter = (*runtime.MicroVMRuntime)(nil) + _ microVMPreflighter = (*runtime.MicroVMRuntime)(nil) + _ podmanPreflighter = (*runtime.PodmanCLI)(nil) + _ canaryBooter = (*runtime.MicroVMRuntime)(nil) + _ appleContainerPreflighter = (*runtime.AppleContainerCLI)(nil) ) // parseMount is the operator surface for --mount: a malformed value must be @@ -155,6 +156,18 @@ func (e bothProbesEngine) BootCanary(context.Context) (runtime.CanaryReport, err return runtime.CanaryReport{}, nil } +// appleOnlyEngine exposes only the apple-container probe. +type appleOnlyEngine struct { + runtime.WorkloadRuntime + called *bool + err error +} + +func (e appleOnlyEngine) VerifyAppleContainerSupport(context.Context) error { + *e.called = true + return e.err +} + // verifyBackendPreflight dispatches on the selected engine's concrete type // (RIG-2496): microVM first, then podman, first match wins; the matched probe // runs and its error is returned verbatim; an engine exposing neither probe is a @@ -270,3 +283,32 @@ func TestVerifyBackendPreflight(t *testing.T) { } }) } + +// The apple-container arm of the same dispatch, kept a separate function rather +// than two more subtests on TestVerifyBackendPreflight: that one is already at +// the gocognit ceiling, and these two cases stand on their own. +func TestVerifyBackendPreflightAppleContainer(t *testing.T) { + t.Run("apple-container probe dispatched, not the fail-closed default", func(t *testing.T) { + sentinel := errors.New("preflight refused") + called := false + err := verifyBackendPreflight(context.Background(), appleOnlyEngine{called: &called, err: sentinel}) + if !errors.Is(err, sentinel) { + t.Errorf("verifyBackendPreflight = %v, want the apple probe's sentinel error", err) + } + if !called { + t.Error("apple-container probe was not called") + } + }) + + t.Run("selected apple-container backend reaches its probe", func(t *testing.T) { + engine, err := runtime.SelectBackend(runtime.BackendConfig{Backend: "apple-container"}) + if err != nil { + t.Fatalf("SelectBackend(apple-container) = %v, want the apple engine", err) + } + // Do NOT invoke the real probe (it shells out to `container + // --version`); only assert the selected engine routes to its branch. + if _, ok := engine.(appleContainerPreflighter); !ok { + t.Errorf("apple-container backend %T does not satisfy appleContainerPreflighter", engine) + } + }) +} diff --git a/go/internal/runtime/applecontainer.go b/go/internal/runtime/applecontainer.go new file mode 100644 index 000000000..80ee3cef2 --- /dev/null +++ b/go/internal/runtime/applecontainer.go @@ -0,0 +1,359 @@ +package runtime + +// applecontainer.go is the Apple `container` CLI WorkloadRuntime backend: the +// macOS arm of the substrate, driving Apple's container tool the same way +// podman.go drives podman — argv builders split from the shared cliEngine +// subprocess seam (clispawn.go), so every serialized command shape is +// unit-testable without a binary. +// +// Deliberately untagged (no //go:build darwin): the driver is plain Go over a +// subprocess, so its argv builders and version parser compile and test on any +// host. Only the CLI it invokes is macOS-only. +// +// Two shape differences from podman, both measured on real hardware: +// - No userns remap. virtiofs performs identity translation at the host↔guest +// boundary, so podman's --userns=keep-id:uid=,gid= has no analogue and needs +// none; guest writes already land host-side as the invoking macOS user. +// - No SELinux. Mounts carry no :Z relabel and MountLabel reports no label. + +import ( + "context" + "fmt" + "regexp" + "strconv" + "strings" + "time" +) + +// AppleContainerConfig is the operator-supplied wiring for the apple-container +// backend. Both fields are optional: a zero value selects the default below. +type AppleContainerConfig struct { + // Program is the engine binary. Empty invokes `container` on PATH. + Program string + // Timeout is the per-command wall-clock cap. Zero uses + // defaultCommandTimeout. + Timeout time.Duration +} + +// AppleContainerCLI is a WorkloadRuntime over Apple's `container` CLI, +// mirroring PodmanCLI's shape, over the same shared subprocess seam. +type AppleContainerCLI struct { + cliEngine +} + +var _ WorkloadRuntime = (*AppleContainerCLI)(nil) + +// appleProgram is the engine binary name NewAppleContainerCLI defaults to. +const appleProgram = "container" + +// NewAppleContainerCLI builds an AppleContainerCLI from cfg, defaulting to +// `container` on PATH under the shared per-command timeout. +func NewAppleContainerCLI(cfg AppleContainerConfig) *AppleContainerCLI { + program := cfg.Program + if program == "" { + program = appleProgram + } + timeout := cfg.Timeout + if timeout == 0 { + timeout = defaultCommandTimeout + } + return &AppleContainerCLI{cliEngine{program: program, timeout: timeout}} +} + +// Create assembles and runs `container create`, returning the new container id. +// The CLI renders its `[N/6] ` progress on stderr, so stdout carries the +// id alone. +func (a *AppleContainerCLI) Create(ctx context.Context, spec WorkloadSpec) (WorkloadID, error) { + stdout, err := a.run(ctx, "container create", appleCreateArgs(spec)) + if err != nil { + return "", err + } + return WorkloadID(strings.TrimSpace(string(stdout))), nil +} + +// appleCreateArgs assembles the argv for `container create`. Split out so the +// argv assembly is unit-testable without spawning the CLI, the createArgs +// discipline. +// +// No userns/uid-remap token is emitted, and that is load-bearing: virtiofs +// translates identity at the boundary, so the podman remap has no analogue here +// and a hand-rolled substitute would break the ownership round-trip that +// already works. +func appleCreateArgs(spec WorkloadSpec) []string { + // Preallocate: 3 fixed tokens (create, --name+value) + 2 per + // cap/mount/env pair + image + command tokens, so the appends below don't + // reallocate. + args := make([]string, 0, 3+2*(len(spec.CapAdd)+len(spec.Mounts)+len(spec.Env))+1+len(spec.Command)) + args = append(args, "create", "--name", spec.Name) + for _, capability := range spec.CapAdd { + args = append(args, "--cap-add", capability) + } + for _, mount := range spec.Mounts { + args = append(args, "--volume", appleMountArg(mount)) + } + for _, kv := range sortedEnv(spec.Env) { + args = append(args, "--env", kv.key+"="+kv.value) + } + args = append(args, spec.Image) + args = append(args, spec.Command...) + return args +} + +// appleMountArg assembles a `host:container[:ro]` volume argument. No :Z +// relabel: SELinux does not exist on macOS, so podman's mountArg suffix has +// nothing to label here. +func appleMountArg(mount Mount) string { + arg := mount.HostPath + ":" + mount.ContainerPath + if mount.ReadOnly { + arg += ":ro" + } + return arg +} + +// minAppleContainerMajor / minAppleContainerMinor are the `container` version +// floor: 1.0.0 removed the v0 XPC APIs and froze the config surface, so it is +// the first release whose CLI contract this backend can rely on. The floor is +// hard — there is no compatibility path below it. +const ( + minAppleContainerMajor = 1 + minAppleContainerMinor = 0 +) + +// VerifyAppleContainerSupport checks the engine meets the 1.0.0 floor, probing +// `container --version` and erroring below it. It names both the required floor +// and the found version so an operator on too-old a CLI learns the cause at +// startup rather than deep inside the first container create. +func (a *AppleContainerCLI) VerifyAppleContainerSupport(ctx context.Context) error { + stdout, err := a.run(ctx, "container --version", []string{"--version"}) + if err != nil { + return err + } + return appleVersionFloorVerdict(strings.TrimSpace(string(stdout))) +} + +// appleVersionFloorVerdict parses raw `container --version` output and returns +// nil at or above the floor, an error below it. Pure so the refusal — the copy +// an operator actually reads — is unit-testable without spawning the CLI. +func appleVersionFloorVerdict(raw string) error { + major, minor, err := parseAppleContainerVersion(raw) + if err != nil { + return err + } + if major < minAppleContainerMajor || (major == minAppleContainerMajor && minor < minAppleContainerMinor) { + return fmt.Errorf( + "apple container %d.%d or newer is required (%d.%d froze the CLI and XPC "+ + "contract this backend drives), but this host has %q", + minAppleContainerMajor, minAppleContainerMinor, + minAppleContainerMajor, minAppleContainerMinor, raw) + } + return nil +} + +// appleVersionPattern matches the first major.minor in a `container --version` +// line. The CLI prints prose, not a bare version — +// `container CLI version 1.1.0 (build: release, commit: 5973b9c)` — so the +// number is extracted rather than split off the front. +var appleVersionPattern = regexp.MustCompile(`(\d+)\.(\d+)`) + +// parseAppleContainerVersion parses the major.minor out of a `container +// --version` line. Split out so the floor comparison is unit-testable without +// spawning the CLI. An input carrying no major.minor is an error. +func parseAppleContainerVersion(raw string) (major, minor int, err error) { + match := appleVersionPattern.FindStringSubmatch(raw) + if match == nil { + return 0, 0, fmt.Errorf("unparseable apple container version %q: want a major.minor[.patch] in the output", raw) + } + major, err = strconv.Atoi(match[1]) + if err != nil { + return 0, 0, fmt.Errorf("unparseable apple container major version in %q: %w", raw, err) + } + minor, err = strconv.Atoi(match[2]) + if err != nil { + return 0, 0, fmt.Errorf("unparseable apple container minor version in %q: %w", raw, err) + } + return major, minor, nil +} + +// Start starts a created container. +func (a *AppleContainerCLI) Start(ctx context.Context, id WorkloadID) error { + _, err := a.run(ctx, "container start", []string{"start", id.String()}) + return err +} + +// Exec runs a command in a running container, capturing its output. A non-zero +// exit is captured in ExecOutput, not folded into an error (a denied firewall +// probe is an expected non-zero); a spawn failure or timeout is an error. +func (a *AppleContainerCLI) Exec(ctx context.Context, id WorkloadID, spec ExecSpec) (ExecOutput, error) { + stdout, stderr, exitCode, err := a.spawnCapture(ctx, "container exec", appleExecArgs(id, spec), spec.Stdin) + if err != nil { + return ExecOutput{}, err + } + return ExecOutput{ + Stdout: string(stdout), + Stderr: string(stderr), + ExitCode: exitCode, + }, nil +} + +// appleExecArgs assembles the argv for a one-shot `container exec`. Split out so +// the argv assembly is unit-testable without spawning the CLI. +func appleExecArgs(id WorkloadID, spec ExecSpec) []string { + args := []string{argExec} + // Forward stdin only when there's input to feed, so `sh -s` reads the script + // from the pipe rather than the argv. + if spec.Stdin != nil { + args = append(args, argInteractive) + } + if spec.User != nil { + args = append(args, "--user", *spec.User) + } + if spec.Workdir != nil { + args = append(args, "--workdir", *spec.Workdir) + } + for _, kv := range sortedEnv(spec.Env) { + args = append(args, "--env", kv.key+"="+kv.value) + } + args = append(args, id.String()) + args = append(args, spec.Command...) + return args +} + +// ExecStreaming starts a streaming `container exec -i` through the shared +// subprocess seam, returning the live pipes plus a kill/wait handle. +func (a *AppleContainerCLI) ExecStreaming(ctx context.Context, id WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) { + return a.spawnStreaming(ctx, appleExecStreamingArgs(id, spec)) +} + +// appleExecStreamingArgs assembles the argv for a streaming `container exec -i`. +// --interactive keeps stdin open for the process's life; there is deliberately +// no --tty (the agent is a headless process draining diagnostic pipes, not a +// terminal session). +func appleExecStreamingArgs(id WorkloadID, spec StreamingExecSpec) []string { + args := []string{argExec, argInteractive} + if spec.User != nil { + args = append(args, "--user", *spec.User) + } + if spec.Workdir != nil { + args = append(args, "--workdir", *spec.Workdir) + } + for _, kv := range sortedEnv(spec.Env) { + args = append(args, "--env", kv.key+"="+kv.value) + } + args = append(args, id.String()) + args = append(args, spec.Command...) + return args +} + +// Stop stops a running container, allowing timeout for graceful exit. A +// container that is already gone is success: teardown runs more than once (a +// retry, or after an operator removed the container by hand) and must be a +// no-op the second time, not a hard failure. An already-stopped but existing +// container already exits 0, so only absence needs tolerating. +func (a *AppleContainerCLI) Stop(ctx context.Context, id WorkloadID, timeout time.Duration) error { + return a.runTolerateMissing(ctx, "container stop", appleStopArgs(id, timeout)) +} + +// appleStopArgs assembles the `container stop` argv. --time is whole seconds; +// the interface takes a Duration for idiom and callsite clarity, converted at +// this CLI boundary by the shared stopGraceSeconds rounding. +func appleStopArgs(id WorkloadID, timeout time.Duration) []string { + return []string{"stop", "--time", strconv.FormatInt(stopGraceSeconds(timeout), 10), id.String()} +} + +// Remove removes a container (force-kills if still running). Removing an +// already-removed or never-created id is success, for the same idempotent +// teardown reason as Stop; unlike podman's `rm --force`, this CLI exits 1 on a +// missing id. +func (a *AppleContainerCLI) Remove(ctx context.Context, id WorkloadID) error { + return a.runTolerateMissing(ctx, "container rm", appleRemoveArgs(id)) +} + +// appleRemoveArgs assembles the `container rm` argv. No --volumes counterpart to +// podman's removeArgs: this CLI has no anonymous-volume lifecycle to leak. +func appleRemoveArgs(id WorkloadID) []string { + return []string{"rm", "--force", id.String()} +} + +// Exists reports whether a container with name exists in any state. This CLI has +// no `container exists` verb, so absence is read off `container inspect`: exit 1 +// with a "not found" stderr. A generic non-zero is a real engine failure, never +// absence. +func (a *AppleContainerCLI) Exists(ctx context.Context, name string) (bool, error) { + _, stderr, exitCode, err := a.spawnCapture(ctx, "container inspect", appleInspectArgs(name), nil) + if err != nil { + return false, err + } + return classifyInspectErr(exitCode, string(stderr)) +} + +// appleInspectArgs assembles the `container inspect` argv used as the existence +// probe. +func appleInspectArgs(name string) []string { + return []string{"inspect", name} +} + +// appleNotFoundStderr is the CLI's own "that container does not exist" refusal. +// The two wordings differ by verb — inspect says `container not found: `, +// rm/stop say `notFound: "container with ID not found"` — and both carry +// this substring. +const appleNotFoundStderr = "not found" + +// appleMissingContainer reports whether a non-zero result is the CLI saying the +// container does not exist. +// +// Exit code AND wording, never the code alone: an unreachable apiserver also +// exits 1 (an "XPC connection error" stderr), so a code-only guard would report +// every container as absent while the engine is down. +func appleMissingContainer(exitCode int, stderr string) bool { + return exitCode == 1 && strings.Contains(stderr, appleNotFoundStderr) +} + +// classifyInspectErr turns an inspect exit code plus stderr into an existence +// verdict. Pure so the classification is unit-testable against real CLI output +// without spawning anything. +// +// Only the CLI's own "not found" wording means absence; any other non-zero is a +// failed probe, and reporting it as absence would let a caller recreate a +// container that already exists. +func classifyInspectErr(exitCode int, stderr string) (bool, error) { + trimmed := strings.TrimSpace(stderr) + switch { + case exitCode == 0: + return true, nil + case appleMissingContainer(exitCode, trimmed): + return false, nil + default: + return false, &CommandError{Summary: "container inspect", ExitCode: exitCode, Stderr: trimmed} + } +} + +// MountLabel reports no SELinux mount label: SELinux does not exist on macOS, +// so there is no MCS category to relabel a config dir into. The microVM +// precedent, and the reason the config-update relabel is a no-op here. +func (a *AppleContainerCLI) MountLabel(_ context.Context, _ WorkloadID) (string, error) { + return "", nil +} + +// Resize is the S1-frozen resize-in-place verb, unimplemented here for the same +// reason as PodmanCLI.Resize: a silent no-op would report a limit change that +// never happened. This CLI exposes no live resource-update verb at the version +// floor. +func (a *AppleContainerCLI) Resize(_ context.Context, _ WorkloadID, _ ResourceLimits) error { + return ErrResizeNotImplemented +} + +// runTolerateMissing runs `container ` like run, but reports the CLI's +// own "container does not exist" refusal as success. The teardown verbs +// (stop/rm) use it so a repeated teardown is a no-op rather than a hard +// failure; every other non-zero exit still becomes a CommandError. +func (a *AppleContainerCLI) runTolerateMissing(ctx context.Context, summary string, args []string) error { + _, stderr, exitCode, err := a.spawnCapture(ctx, summary, args, nil) + if err != nil { + return err + } + trimmed := strings.TrimSpace(string(stderr)) + if exitCode == 0 || appleMissingContainer(exitCode, trimmed) { + return nil + } + return &CommandError{Summary: summary, ExitCode: exitCode, Stderr: trimmed} +} diff --git a/go/internal/runtime/applecontainer_test.go b/go/internal/runtime/applecontainer_test.go new file mode 100644 index 000000000..06e9acd9b --- /dev/null +++ b/go/internal/runtime/applecontainer_test.go @@ -0,0 +1,370 @@ +package runtime + +// The apple-container hermetic suite: the pure argv assembly, version parsing, +// and inspect classification that decide what the Runner shells out to Apple's +// `container` CLI with, and how it reads the answers back. No subprocess is +// spawned — these pin the serialized command shapes and the CLI-output +// contracts a real bug (a dropped flag, a leaked podman-ism, a misread +// "not found") would silently corrupt. + +import ( + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" +) + +// The full create the Runner assembles: caps, mounts, env, image and command. +// Env is emitted in sorted key order, so the expectation is exact rather than +// order-tolerant. The absence of a userns token is the load-bearing assertion — +// virtiofs translates identity at the boundary, and a ported +// --userns=keep-id:uid=,gid= would be rejected by a CLI that has no such flag. +func TestAppleCreateArgsAssemblesCreateWithoutUserns(t *testing.T) { + args := appleCreateArgs(WorkloadSpec{ + Name: "agent-1", + Image: "compass-agent:latest", + UID: 1000, + CapAdd: []string{"NET_ADMIN"}, + Mounts: []Mount{{HostPath: "/tmp/work", ContainerPath: "/work"}, {HostPath: "/tmp/cache", ContainerPath: "/src", ReadOnly: true}}, + Env: map[string]string{"HOME": "/home/agent", "COMPASS_WORKDIR": "/work"}, + Command: []string{"sleep", "infinity"}, + }) + + want := []string{ + "create", "--name", "agent-1", + "--cap-add", "NET_ADMIN", + "--volume", "/tmp/work:/work", + "--volume", "/tmp/cache:/src:ro", + "--env", "COMPASS_WORKDIR=/work", + "--env", "HOME=/home/agent", + "compass-agent:latest", "sleep", "infinity", + } + if !slices.Equal(args, want) { + t.Fatalf("appleCreateArgs = %q, want %q", args, want) + } + for _, arg := range args { + if strings.Contains(arg, "userns") { + t.Fatalf("appleCreateArgs = %q, want no userns token (virtiofs translates identity at the boundary)", args) + } + } +} + +// A mount must not carry podman's :Z SELinux relabel: macOS has no SELinux, and +// the CLI would read the suffix as part of the container path. +func TestAppleMountArgOmitsSELinuxRelabel(t *testing.T) { + tests := []struct { + name string + mount Mount + want string + }{ + {"read-write mount", Mount{HostPath: "/tmp/work", ContainerPath: "/work"}, "/tmp/work:/work"}, + {"read-only mount", Mount{HostPath: "/tmp/cache", ContainerPath: "/src", ReadOnly: true}, "/tmp/cache:/src:ro"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := appleMountArg(tc.mount); got != tc.want { + t.Fatalf("appleMountArg(%+v) = %q, want %q", tc.mount, got, tc.want) + } + }) + } +} + +// A one-shot exec with a stdin script: --interactive appears only when there is +// input to feed (so `sh -s` reads the script off the pipe, never the argv), and +// env rides --env rather than podman's -e. +func TestAppleExecArgsAssemblesOneShotExec(t *testing.T) { + script := "nft -f -" + spec := NewExecSpec("sh", "-s").AsUser("0").InDir("/work").WithStdin(script) + spec.Env["LANG"] = "C" + + args := appleExecArgs(WorkloadID("ctr123"), spec) + + want := []string{ + "exec", "--interactive", + "--user", "0", + "--workdir", "/work", + "--env", "LANG=C", + "ctr123", "sh", "-s", + } + if !slices.Equal(args, want) { + t.Fatalf("appleExecArgs = %q, want %q", args, want) + } +} + +// Without stdin there is no --interactive: a one-shot probe must not hold a +// pipe open waiting for input that never arrives. +func TestAppleExecArgsOmitsInteractiveWithoutStdin(t *testing.T) { + args := appleExecArgs(WorkloadID("ctr123"), NewExecSpec("true")) + + want := []string{"exec", "ctr123", "true"} + if !slices.Equal(args, want) { + t.Fatalf("appleExecArgs = %q, want %q", args, want) + } +} + +// The agent's streaming exec: always --interactive (stdin stays open for the +// process's life), never --tty. +func TestAppleExecStreamingArgsAssemblesInteractiveExec(t *testing.T) { + spec := NewStreamingExecSpec("compass-agent").AsUser("1000").InDir("/work") + spec.Env["HOME"] = "/home/agent" + spec.Env["COMPASS_MODEL"] = "test-model" + + args := appleExecStreamingArgs(WorkloadID("ctr123"), spec) + + want := []string{ + "exec", "--interactive", + "--user", "1000", + "--workdir", "/work", + "--env", "COMPASS_MODEL=test-model", + "--env", "HOME=/home/agent", + "ctr123", "compass-agent", + } + if !slices.Equal(args, want) { + t.Fatalf("appleExecStreamingArgs = %q, want %q", args, want) + } + if slices.Contains(args, "--tty") { + t.Fatalf("appleExecStreamingArgs = %q, want no --tty (the agent is headless)", args) + } +} + +// The graceful stop grace must survive the Duration→whole-seconds conversion: a +// sub-second grace rounding to 0 would be an immediate kill with no grace. +func TestAppleStopArgsCarriesGrace(t *testing.T) { + tests := []struct { + name string + timeout time.Duration + want []string + }{ + {"whole seconds pass through", 10 * time.Second, []string{"stop", "--time", "10", "ctr123"}}, + {"sub-second grace rounds up", 100 * time.Millisecond, []string{"stop", "--time", "1", "ctr123"}}, + {"negative grace clamps to zero", -5 * time.Second, []string{"stop", "--time", "0", "ctr123"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := appleStopArgs(WorkloadID("ctr123"), tc.timeout) + if !slices.Equal(got, tc.want) { + t.Fatalf("appleStopArgs(%v) = %q, want %q", tc.timeout, got, tc.want) + } + }) + } +} + +// Remove must force: a still-running container is torn down, not refused. +func TestAppleRemoveArgsForces(t *testing.T) { + want := []string{"rm", "--force", "ctr123"} + if got := appleRemoveArgs(WorkloadID("ctr123")); !slices.Equal(got, want) { + t.Fatalf("appleRemoveArgs = %q, want %q", got, want) + } +} + +// parseAppleContainerVersion + the floor comparison together decide the startup +// gate. The CLI prints prose ("container CLI version 1.1.0 (build: …)"), not a +// bare version, so a parser that assumed a leading number would reject every +// real host. +func TestParseAppleContainerVersion(t *testing.T) { + tests := []struct { + name string + in string + wantMajor int + wantMinor int + wantParseErr bool + wantRefused bool + }{ + {"real CLI prose is parsed", "container CLI version 1.1.0 (build: release, commit: 5973b9c)", 1, 1, false, false}, + {"bare version is parsed", "1.4.1", 1, 4, false, false}, + {"below floor 0.12 is refused", "container CLI version 0.12.3 (build: release)", 0, 12, false, true}, + {"at floor 1.0 is admitted", "container CLI version 1.0.0 (build: release)", 1, 0, false, false}, + {"garbage is a parse error", "not-a-version", 0, 0, true, false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + major, minor, err := parseAppleContainerVersion(tc.in) + if tc.wantParseErr { + if err == nil { + t.Fatalf("parseAppleContainerVersion(%q) = (%d, %d, nil), want a parse error", tc.in, major, minor) + } + return + } + if err != nil { + t.Fatalf("parseAppleContainerVersion(%q) = unexpected error %v", tc.in, err) + } + if major != tc.wantMajor || minor != tc.wantMinor { + t.Fatalf("parseAppleContainerVersion(%q) = (%d, %d), want (%d, %d)", tc.in, major, minor, tc.wantMajor, tc.wantMinor) + } + verdict := appleVersionFloorVerdict(tc.in) + if (verdict != nil) != tc.wantRefused { + t.Fatalf("appleVersionFloorVerdict(%q) = %v, want refused:%v", tc.in, verdict, tc.wantRefused) + } + if !tc.wantRefused { + return + } + // A refusal an operator cannot act on is a bad refusal: it must + // name the required floor and the version actually found. + for _, want := range []string{"1.0", tc.in} { + if !strings.Contains(verdict.Error(), want) { + t.Fatalf("appleVersionFloorVerdict(%q) = %q, want it to name %q", tc.in, verdict, want) + } + } + }) + } +} + +// Exists reads absence off `container inspect`, which has no exists-style exit +// code contract. Only the CLI's own "not found" wording means absent; folding a +// generic failure into absence would let a caller recreate a container that +// already exists. +func TestClassifyInspectErr(t *testing.T) { + tests := []struct { + name string + exitCode int + stderr string + want bool + wantErr bool + }{ + {"exit 0 with empty stderr is present", 0, "", true, false}, + {"exit 1 with not-found is absent", 1, "Error: container not found: zznotexist\n", false, false}, + {"apiserver down is a failure, not absence", 1, "Error: XPC connection error: Connection invalid. Ensure container system service has been started with `container system start`.\n", false, true}, + {"a generic non-zero exit is a failure", 125, "Error: container system is not running\n", false, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := classifyInspectErr(tc.exitCode, tc.stderr) + if (err != nil) != tc.wantErr { + t.Fatalf("classifyInspectErr(%d, %q) err = %v, wantErr %v", tc.exitCode, tc.stderr, err, tc.wantErr) + } + if got != tc.want { + t.Fatalf("classifyInspectErr(%d, %q) = %v, want %v", tc.exitCode, tc.stderr, got, tc.want) + } + }) + } +} + +// SelectBackend routes the new name, and its refusal copy must name every +// accepted value — an operator who typo'd the backend learns the valid set from +// the error, not from the source. +func TestSelectBackendAppleContainer(t *testing.T) { + rt, err := SelectBackend(BackendConfig{Backend: "apple-container"}) + if err != nil { + t.Fatalf("SelectBackend(apple-container) err = %v, want nil", err) + } + if _, ok := rt.(*AppleContainerCLI); !ok { + t.Fatalf("SelectBackend(apple-container) = %T, want *AppleContainerCLI", rt) + } + + _, err = SelectBackend(BackendConfig{Backend: "bogus"}) + if err == nil { + t.Fatal("SelectBackend(bogus) err = nil, want non-nil") + } + for _, accepted := range []string{"podman", "microvm", "apple-container"} { + if !strings.Contains(err.Error(), accepted) { + t.Fatalf("SelectBackend(bogus) err = %q, want it to name %q", err, accepted) + } + } +} + +// MountLabel reports no label rather than failing: macOS has no SELinux, so the +// config-update relabel has nothing to target and must not be handed a bogus +// category. +func TestAppleContainerMountLabelIsEmpty(t *testing.T) { + label, err := NewAppleContainerCLI(AppleContainerConfig{}).MountLabel(t.Context(), WorkloadID("ctr123")) + if err != nil { + t.Fatalf("MountLabel err = %v, want nil", err) + } + if label != "" { + t.Fatalf("MountLabel = %q, want an empty label", label) + } +} + +// Resize must refuse loudly: a silent success would report a limit change that +// never happened, and this CLI exposes no resource-update verb at the floor. +func TestAppleContainerResizeIsReserved(t *testing.T) { + err := NewAppleContainerCLI(AppleContainerConfig{}).Resize(t.Context(), WorkloadID("ctr123"), ResourceLimits{CPUShares: 512}) + if !errors.Is(err, ErrResizeNotImplemented) { + t.Fatalf("Resize err = %v, want ErrResizeNotImplemented", err) + } +} + +// appleMissingContainer is the single place absence is read off a failed +// command, and it must hold for BOTH measured wordings: `inspect` says +// "container not found: " while `rm`/`stop` say +// `notFound: "container with ID not found"`. The apiserver-down case is +// the hazard the exit-code half of the guard exists for — it is also exit 1, +// so a code-only guard would call every container absent while the engine is +// down, and teardown would silently "succeed" against a live container. +func TestAppleMissingContainer(t *testing.T) { + tests := []struct { + name string + exitCode int + stderr string + want bool + }{ + {"inspect not-found", 1, "Error: container not found: zznotexist", true}, + {"rm not-found", 1, `Error: internalError: "failed to delete container" (cause: "notFound: "container with ID zznotexist not found"")`, true}, + {"stop not-found", 1, `Error: internalError: "failed to stop container" (cause: "notFound: "container with ID zznotexist not found"")`, true}, + { + "apiserver down is not absence", 1, + "Error: XPC connection error: Connection invalid. Ensure container system service has been started with `container system start`.", + false, + }, + {"unrelated exit 1 is not absence", 1, "Error: invalid argument --nope", false}, + {"exit 0 is not absence", 0, "", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := appleMissingContainer(tc.exitCode, tc.stderr); got != tc.want { + t.Fatalf("appleMissingContainer(%d, %q) = %v, want %v", tc.exitCode, tc.stderr, got, tc.want) + } + }) + } +} + +// appleStubCLI builds a CLI whose engine binary is a shell stub printing stderr +// and exiting 1, so the teardown verbs are exercised end to end through the +// real spawn seam without a `container` binary. Exit 1 is fixed: it is the code +// this CLI returns for both a missing container and an apiserver-down refusal, +// which is exactly what the tolerate-missing guard has to tell apart. +func appleStubCLI(t *testing.T, stderr string) *AppleContainerCLI { + t.Helper() + prog := filepath.Join(t.TempDir(), "container-stub.sh") + script := "#!/bin/sh\ncat <<'EOF' >&2\n" + stderr + "\nEOF\nexit 1\n" + if err := os.WriteFile(prog, []byte(script), 0o755); err != nil { + t.Fatalf("writing stub: %v", err) + } + return NewAppleContainerCLI(AppleContainerConfig{Program: prog, Timeout: 10 * time.Second}) +} + +// Teardown must be idempotent: AgentRuntime.Teardown propagates these errors, +// so a second teardown — or one after an operator removed the container by +// hand — would be a hard failure instead of a no-op. Both verbs exit 1 on a +// missing id on real hardware, unlike podman's `rm --force`. +func TestAppleStopRemoveTolerateMissingContainer(t *testing.T) { + const rmMissing = `Error: internalError: "failed to delete container" (cause: "notFound: "container with ID gone not found"")` + const stopMissing = `Error: internalError: "failed to stop container" (cause: "notFound: "container with ID gone not found"")` + + t.Run("remove of a missing container is nil", func(t *testing.T) { + if err := appleStubCLI(t, rmMissing).Remove(t.Context(), WorkloadID("gone")); err != nil { + t.Fatalf("Remove err = %v, want nil", err) + } + }) + + t.Run("stop of a missing container is nil", func(t *testing.T) { + if err := appleStubCLI(t, stopMissing).Stop(t.Context(), WorkloadID("gone"), 5*time.Second); err != nil { + t.Fatalf("Stop err = %v, want nil", err) + } + }) + + // The guard must stay narrow: an apiserver-down teardown is also exit 1, + // and swallowing it would report a live container as torn down. + t.Run("apiserver down still fails the teardown", func(t *testing.T) { + const down = "Error: XPC connection error: Connection invalid. Ensure container system service has been started with `container system start`." + if err := appleStubCLI(t, down).Remove(t.Context(), WorkloadID("live")); err == nil { + t.Fatal("Remove err = nil, want the apiserver-down failure surfaced") + } + if err := appleStubCLI(t, down).Stop(t.Context(), WorkloadID("live"), 5*time.Second); err == nil { + t.Fatal("Stop err = nil, want the apiserver-down failure surfaced") + } + }) +} diff --git a/go/internal/runtime/clispawn.go b/go/internal/runtime/clispawn.go new file mode 100644 index 000000000..61a7b1b16 --- /dev/null +++ b/go/internal/runtime/clispawn.go @@ -0,0 +1,139 @@ +package runtime + +// clispawn.go is the one subprocess seam every CLI-driven WorkloadRuntime +// backend spawns through. PodmanCLI and AppleContainerCLI differ only in which +// binary they invoke and which argv they assemble; the process handling around +// that — timeout, stdin, exit-code mapping, streaming pipes, kill-on-abandon — +// is identical, so it lives here once and each backend embeds cliEngine. +// +// Embedded (not a named field) so the backends keep referring to program and +// timeout directly and the seam methods are promoted onto them unchanged. + +import ( + "bytes" + "context" + "errors" + "os/exec" //nolint:depguard // CLI-engine seam: *exec.Cmd/*exec.ExitError types for the container engine subprocess + "strings" + "time" +) + +// cliEngine is the shared subprocess seam: a container-engine binary plus the +// per-command wall-clock cap its one-shot invocations run under. +type cliEngine struct { + program string + timeout time.Duration +} + +// spawnCapture spawns ` `, optionally writing stdin, and +// captures output under the command timeout. A spawn failure, a timeout, and a +// captured non-zero exit are all mapped here. summary names the operation for +// error context without leaking the full argv (which may hold env values or a +// token on stdin). +// +// A non-zero exit is returned as (stdout, stderr, code, nil) — the caller +// decides whether that is an error (run) or an expected result (Exec, Exists). +// Only a spawn failure, this call's own timeout, or parent-context cancellation +// is a non-nil error. +func (e cliEngine) spawnCapture(ctx context.Context, summary string, args []string, stdin *string) (stdout, stderr []byte, exitCode int, err error) { + cctx, cancel := context.WithTimeout(ctx, e.timeout) + defer cancel() + + //nolint:gosec // G204: this is the container-engine seam — spawning the + // configured engine binary (e.program) with caller-supplied argv is the + // module's entire purpose. Host/allowlist inputs are validated upstream + // (isValidHost) before reaching an argv, and the program is operator-set, + // not attacker-controlled. + cmd := exec.CommandContext(cctx, e.program, args...) + // A killed process that leaked a child still holding the output pipe would + // keep Run blocked on that pipe indefinitely; WaitDelay bounds that wait so a + // leaked-pipe hang can't outlive this call's timeout by more than WaitDelay. + cmd.WaitDelay = 10 * time.Second + var out, errBuf bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &errBuf + if stdin != nil { + // A strings.Reader hits EOF when the script is exhausted, so the child + // never blocks waiting for more input. + cmd.Stdin = strings.NewReader(*stdin) + } + + if runErr := cmd.Run(); runErr != nil { + switch { + case cctx.Err() == context.DeadlineExceeded && ctx.Err() == nil: + // This call's own timeout fired (not the parent): the process was + // killed, so surface a timeout rather than a bogus exit code. + return nil, nil, 0, &TimeoutError{Summary: summary, Timeout: e.timeout} + case ctx.Err() != nil: + // The caller cancelled: propagate the context error. + return nil, nil, 0, ctx.Err() + default: + if exitErr, ok := errors.AsType[*exec.ExitError](runErr); ok { + // Ran to completion but exited non-zero: not an error here. + return out.Bytes(), errBuf.Bytes(), exitErr.ExitCode(), nil + } + return nil, nil, 0, &SpawnError{Program: e.program, Err: runErr} + } + } + return out.Bytes(), errBuf.Bytes(), 0, nil +} + +// run runs ` `, requiring a zero exit (a non-zero becomes a +// CommandError). For fire-and-check operations like create/start/stop/remove. +func (e cliEngine) run(ctx context.Context, summary string, args []string) ([]byte, error) { + stdout, stderr, exitCode, err := e.spawnCapture(ctx, summary, args, nil) + if err != nil { + return nil, err + } + if exitCode != 0 { + return nil, &CommandError{ + Summary: summary, + ExitCode: exitCode, + Stderr: strings.TrimSpace(string(stderr)), + } + } + return stdout, nil +} + +// spawnStreaming starts ` ` streaming, returning the live pipes +// plus a kill/wait handle. The process is bound to a cancellable child of ctx: +// its Cancel SIGKILLs the process and WaitDelay bounds the reap, so cancelling +// the parent context or calling ChildHandle.Kill terminates the in-container +// agent even without a Go Drop. +func (e cliEngine) spawnStreaming(ctx context.Context, args []string) (*StreamingExec, error) { + execCtx, cancel := context.WithCancel(ctx) + //nolint:gosec // G204: the container-engine seam — see spawnCapture. The + // engine binary is operator-set and the exec argv is Runner-assembled. + cmd := exec.CommandContext(execCtx, e.program, args...) + // A dropped session must kill the exec, or the in-container agent keeps + // running after the Runner lets go of the handle. No command timeout: a + // streaming session is long-lived by design. + cmd.Cancel = func() error { return cmd.Process.Kill() } + cmd.WaitDelay = 10 * time.Second + + spawnErr := func(err error) (*StreamingExec, error) { + cancel() + return nil, &SpawnError{Program: e.program, Err: err} + } + + stdin, err := cmd.StdinPipe() + if err != nil { + return spawnErr(err) + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return spawnErr(err) + } + stderr, err := cmd.StderrPipe() + if err != nil { + return spawnErr(err) + } + if err := cmd.Start(); err != nil { + return spawnErr(err) + } + + return &StreamingExec{ + IO: StreamingIO{Stdin: stdin, Stdout: stdout, Stderr: stderr}, + Process: &ChildHandle{cmd: cmd, cancel: cancel}, + }, nil +} diff --git a/go/internal/runtime/microvm.go b/go/internal/runtime/microvm.go index 8933ed28c..aa149f48a 100644 --- a/go/internal/runtime/microvm.go +++ b/go/internal/runtime/microvm.go @@ -77,14 +77,18 @@ type MicroVMConfig struct { } // BackendConfig selects and configures the workload runtime backend. Backend -// is the chosen backend name ("podman" or "microvm"); MicroVM carries the -// microVM-specific wiring, consulted only when Backend selects it. +// is the chosen backend name ("podman", "microvm" or "apple-container"); +// MicroVM and AppleContainer carry the backend-specific wiring, each consulted +// only when Backend selects it. type BackendConfig struct { // Backend names the runtime backend: "podman" (or empty, the transitional - // default) or "microvm". + // default), "microvm", or "apple-container". Backend string - // MicroVM configures the microVM backend; ignored for podman. + // MicroVM configures the microVM backend; ignored for the others. MicroVM MicroVMConfig + // AppleContainer configures the apple-container backend; ignored for the + // others. Appended, never reordered. + AppleContainer AppleContainerConfig } // MicroVMRuntime is a WorkloadRuntime that isolates each agent in its own @@ -123,7 +127,8 @@ func NewMicroVMRuntime(cfg MicroVMConfig) *MicroVMRuntime { // SelectBackend chooses the workload runtime backend from cfg. An empty or // "podman" backend returns the podman CLI runtime; "microvm" returns the -// microVM runtime; any other value is an error naming the unknown backend and +// microVM runtime; "apple-container" returns the Apple `container` CLI runtime +// (the macOS arm); any other value is an error naming the unknown backend and // the accepted values. // // During the transitional period both backends ship and the default is podman: @@ -139,7 +144,9 @@ func SelectBackend(cfg BackendConfig) (WorkloadRuntime, error) { return NewPodmanCLI(), nil case "microvm": return NewMicroVMRuntime(cfg.MicroVM), nil + case "apple-container": + return NewAppleContainerCLI(cfg.AppleContainer), nil default: - return nil, fmt.Errorf("runtime: unknown backend %q: accepted values are \"podman\" (default) and \"microvm\"", cfg.Backend) + return nil, fmt.Errorf("runtime: unknown backend %q: accepted values are \"podman\" (default), \"microvm\" and \"apple-container\"", cfg.Backend) } } diff --git a/go/internal/runtime/podman.go b/go/internal/runtime/podman.go index 64af58235..f083bd3e4 100644 --- a/go/internal/runtime/podman.go +++ b/go/internal/runtime/podman.go @@ -8,9 +8,11 @@ // container, so neither the image build nor the clone is the Runner's job. // // The layering, bottom to top: -// - podman.go — a thin WorkloadRuntime over the podman CLI: the only place a -// subprocess is spawned. Everything above depends on the interface, so a -// libpod-REST backend can replace it without touching a caller. +// - clispawn.go — the shared subprocess seam (cliEngine): the only place a +// CLI-backend process is spawned. +// - podman.go — a thin WorkloadRuntime over the podman CLI, driving that +// seam. Everything above depends on the interface, so a libpod-REST +// backend can replace it without touching a caller. // - egress.go — the default-deny + allowlist firewall applied inside the // container before the agent runs. // - workspace.go — clone-per-container plus the scoped $HOME and its git @@ -37,7 +39,6 @@ package runtime import ( - "bytes" "context" "errors" "fmt" @@ -423,16 +424,17 @@ const ( argFormat = "--format" ) -// PodmanCLI is a WorkloadRuntime over the podman CLI. +// PodmanCLI is a WorkloadRuntime over the podman CLI. The subprocess seam +// (spawn/capture/streaming) is the shared cliEngine, embedded so podman's +// verb methods keep calling run/spawnCapture directly. type PodmanCLI struct { - program string - timeout time.Duration + cliEngine } // NewPodmanCLI builds a PodmanCLI invoking `podman` on PATH with the default // per-command timeout. func NewPodmanCLI() *PodmanCLI { - return &PodmanCLI{program: "podman", timeout: defaultCommandTimeout} + return &PodmanCLI{cliEngine{program: "podman", timeout: defaultCommandTimeout}} } // WithProgram uses an explicit engine binary (e.g. an absolute path, or @@ -584,47 +586,10 @@ func (p *PodmanCLI) Exec(ctx context.Context, id WorkloadID, spec ExecSpec) (Exe }, nil } -// ExecStreaming starts a streaming `podman exec -i`, returning the live pipes -// plus a kill/wait handle. The exec is bound to a cancellable child of ctx: its -// Cancel SIGKILLs the process and WaitDelay bounds the reap, so cancelling the -// parent context or calling ChildHandle.Kill terminates the in-container agent -// even without a Go Drop. +// ExecStreaming starts a streaming `podman exec -i` through the shared +// subprocess seam, returning the live pipes plus a kill/wait handle. func (p *PodmanCLI) ExecStreaming(ctx context.Context, id WorkloadID, spec StreamingExecSpec) (*StreamingExec, error) { - execCtx, cancel := context.WithCancel(ctx) - //nolint:gosec // G204: the container-engine seam — see spawnCapture. The - // engine binary is operator-set and the exec argv is Runner-assembled. - cmd := exec.CommandContext(execCtx, p.program, execStreamingArgs(id, spec)...) - // A dropped session must kill the exec, or the in-container agent keeps - // running after the Runner lets go of the handle. No command timeout: a - // streaming session is long-lived by design. - cmd.Cancel = func() error { return cmd.Process.Kill() } - cmd.WaitDelay = 10 * time.Second - - spawnErr := func(err error) (*StreamingExec, error) { - cancel() - return nil, &SpawnError{Program: p.program, Err: err} - } - - stdin, err := cmd.StdinPipe() - if err != nil { - return spawnErr(err) - } - stdout, err := cmd.StdoutPipe() - if err != nil { - return spawnErr(err) - } - stderr, err := cmd.StderrPipe() - if err != nil { - return spawnErr(err) - } - if err := cmd.Start(); err != nil { - return spawnErr(err) - } - - return &StreamingExec{ - IO: StreamingIO{Stdin: stdin, Stdout: stdout, Stderr: stderr}, - Process: &ChildHandle{cmd: cmd, cancel: cancel}, - }, nil + return p.spawnStreaming(ctx, execStreamingArgs(id, spec)) } // stopGraceSeconds converts a graceful-stop timeout to podman's whole-second @@ -745,76 +710,6 @@ func (p *PodmanCLI) MountLabel(ctx context.Context, id WorkloadID) (string, erro return strings.TrimSpace(string(out)), nil } -// spawnCapture spawns `podman `, optionally writing stdin, and captures -// output under the command timeout. The single subprocess seam: a spawn -// failure, a timeout, and a captured non-zero exit are all mapped here. summary -// names the operation for error context without leaking the full argv (which may -// hold env values or a token on stdin). -// -// A non-zero exit is returned as (stdout, stderr, code, nil) — the caller -// decides whether that is an error (run) or an expected result (Exec, Exists). -// Only a spawn failure, this call's own timeout, or parent-context cancellation -// is a non-nil error. -func (p *PodmanCLI) spawnCapture(ctx context.Context, summary string, args []string, stdin *string) (stdout, stderr []byte, exitCode int, err error) { - cctx, cancel := context.WithTimeout(ctx, p.timeout) - defer cancel() - - //nolint:gosec // G204: this is the container-engine seam — spawning the - // configured engine binary (p.program) with caller-supplied argv is the - // module's entire purpose. Host/allowlist inputs are validated upstream - // (isValidHost) before reaching an argv, and the program is operator-set, - // not attacker-controlled. - cmd := exec.CommandContext(cctx, p.program, args...) - // A killed process that leaked a child still holding the output pipe would - // keep Run blocked on that pipe indefinitely; WaitDelay bounds that wait so a - // leaked-pipe hang can't outlive this call's timeout by more than WaitDelay. - cmd.WaitDelay = 10 * time.Second - var out, errBuf bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &errBuf - if stdin != nil { - // A strings.Reader hits EOF when the script is exhausted, so the child - // never blocks waiting for more input. - cmd.Stdin = strings.NewReader(*stdin) - } - - if runErr := cmd.Run(); runErr != nil { - switch { - case cctx.Err() == context.DeadlineExceeded && ctx.Err() == nil: - // This call's own timeout fired (not the parent): the process was - // killed, so surface a timeout rather than a bogus exit code. - return nil, nil, 0, &TimeoutError{Summary: summary, Timeout: p.timeout} - case ctx.Err() != nil: - // The caller cancelled: propagate the context error. - return nil, nil, 0, ctx.Err() - default: - if exitErr, ok := errors.AsType[*exec.ExitError](runErr); ok { - // Ran to completion but exited non-zero: not an error here. - return out.Bytes(), errBuf.Bytes(), exitErr.ExitCode(), nil - } - return nil, nil, 0, &SpawnError{Program: p.program, Err: runErr} - } - } - return out.Bytes(), errBuf.Bytes(), 0, nil -} - -// run runs `podman `, requiring a zero exit (a non-zero becomes a -// CommandError). For fire-and-check operations like create/start/stop/remove. -func (p *PodmanCLI) run(ctx context.Context, summary string, args []string) ([]byte, error) { - stdout, stderr, exitCode, err := p.spawnCapture(ctx, summary, args, nil) - if err != nil { - return nil, err - } - if exitCode != 0 { - return nil, &CommandError{ - Summary: summary, - ExitCode: exitCode, - Stderr: strings.TrimSpace(string(stderr)), - } - } - return stdout, nil -} - // execStreamingArgs assembles the argv for a streaming `podman exec -i`. Split // out so the argv assembly is unit-testable without spawning podman. // --interactive keeps stdin open for the process's life; there is deliberately