From 3972671731878f27dfe5bc134d922cefa890b45a Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Thu, 6 Aug 2026 14:07:20 +0800 Subject: [PATCH] Protect host system trees from runtime binds Reject runtime host sources that resolve to the filesystem root or protected kernel system trees. Use platform-specific filesystem and mount topology identity to catch direct, aliased, and nested kernel-control filesystems while preserving explicit ordinary directory binds with nested application objects. Reject unsupported remote Docker endpoints before runtime and recovered-container operations, including admission cleanup paths. Document the narrowed direct-bind security contract, close the corresponding backlog item, and add a security changelog fragment. --- .../+protect-host-system-trees.yaml | 2 + docs/BACKLOG.md | 39 +- docs/CONTROLLED_SESSION_DESIGN.md | 116 +++- internal/dockerdeploy/apt_resolver_session.go | 32 +- .../dockerdeploy/apt_resolver_session_test.go | 151 +++-- internal/dockerdeploy/buildkit_probe.go | 49 +- internal/dockerdeploy/buildkit_probe_test.go | 72 ++ internal/dockerdeploy/compose.go | 85 ++- internal/dockerdeploy/compose_test.go | 198 +++++- internal/dockerdeploy/control_admission.go | 2 +- .../dockerdeploy/control_admission_test.go | 44 ++ internal/dockerdeploy/docker_endpoint.go | 188 ++++++ internal/dockerdeploy/docker_endpoint_test.go | 152 +++++ internal/dockerdeploy/execution_plan.go | 16 +- internal/dockerdeploy/execution_plan_test.go | 6 +- .../full_validation_python_profile.go | 2 +- .../installed_service_container.go | 12 +- internal/dockerdeploy/live_run_admission.go | 2 +- .../dockerdeploy/live_run_admission_test.go | 44 ++ internal/dockerdeploy/live_run_container.go | 13 +- .../dockerdeploy/live_run_container_test.go | 45 ++ internal/dockerdeploy/platform.go | 11 +- internal/dockerdeploy/platform_test.go | 37 +- ...e_workload_environment_integration_test.go | 2 +- .../private_workload_environment_test.go | 2 +- internal/dockerdeploy/probe_run.go | 44 +- internal/dockerdeploy/probe_run_test.go | 144 ++-- .../dockerdeploy/provider_build_run_test.go | 2 +- .../provider_install_host_execute.go | 13 +- .../provider_install_host_execute_test.go | 102 +++ .../provider_install_path_updates.go | 28 +- ...r_install_path_updates_integration_test.go | 2 +- .../provider_install_path_updates_test.go | 42 ++ internal/dockerdeploy/python_build_tools.go | 2 +- .../python_interpreter_selection_test.go | 79 ++- .../dockerdeploy/python_resolver_session.go | 42 +- .../python_resolver_session_test.go | 100 ++- .../runtime_host_filesystem_darwin.go | 32 + .../runtime_host_filesystem_linux.go | 626 ++++++++++++++++++ .../runtime_host_filesystem_linux_test.go | 486 ++++++++++++++ .../runtime_host_filesystem_other.go | 11 + .../dockerdeploy/runtime_host_preflight.go | 73 ++ .../runtime_host_preflight_test.go | 70 ++ .../runtime_host_preflight_unix_test.go | 27 + internal/dockerdeploy/test.go | 8 +- 45 files changed, 2862 insertions(+), 393 deletions(-) create mode 100644 .changes/unreleased/+protect-host-system-trees.yaml create mode 100644 internal/dockerdeploy/docker_endpoint.go create mode 100644 internal/dockerdeploy/docker_endpoint_test.go create mode 100644 internal/dockerdeploy/runtime_host_filesystem_darwin.go create mode 100644 internal/dockerdeploy/runtime_host_filesystem_linux.go create mode 100644 internal/dockerdeploy/runtime_host_filesystem_linux_test.go create mode 100644 internal/dockerdeploy/runtime_host_filesystem_other.go create mode 100644 internal/dockerdeploy/runtime_host_preflight_unix_test.go diff --git a/.changes/unreleased/+protect-host-system-trees.yaml b/.changes/unreleased/+protect-host-system-trees.yaml new file mode 100644 index 00000000..41cb769f --- /dev/null +++ b/.changes/unreleased/+protect-host-system-trees.yaml @@ -0,0 +1,2 @@ +kind: Security +body: Reject ordinary application-runtime binds to the host filesystem root, `/proc`, `/dev`, and `/sys`, including symlink and protected-filesystem aliases; reject unsupported remote Docker endpoints; and replace generic root-runtime warnings with precise diagnostics for prohibited authority. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 61c5cbf5..2d1f6bae 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -31,12 +31,14 @@ This file is the day-to-day queue for design and implementation gaps. ## Now -- [ ] `P1` Define confinement for special files nested inside host directory - binds. A launch-time recursive scan for sockets and device nodes adds - unbounded source-tree latency and provides only a point-in-time result. - Choose a durable mechanism or explicitly narrow the security contract, - then add focused cross-platform tests without weakening ordinary - read-only project mounts. +- [ ] `P1` Implement the initial coarse application-network policy. Preserve + independent public and local policy intent with both denied by default, + apply it consistently to workloads, commands, shells, and lifecycle + commands, and use only proven isolation and endpoint primitives from the + active runtime backend. Permit exact declared inbound endpoints without + granting general local access. Fail closed when a backend cannot realize + a requested combination, and do not represent this slice as destination-, + domain-, or packet-level filtering. ## Pre-release @@ -183,6 +185,17 @@ This file is the day-to-day queue for design and implementation gaps. ## Post-v1 +- [ ] `P2` Design explicit remote Docker support. + Replace today's rejected ambient `DOCKER_HOST` and remote-context behavior + with an intentional distributed-runtime contract. Define input snapshot + upload, output-file and output-dir extraction with safe local publication, + image export or remote placement, port forwarding, remote identity and + permission semantics, authenticated transport, lifecycle ownership, + interruption recovery, deterministic cleanup, and a defensible way to + establish or replace host-path namespace equivalence. Keep local Docker + Engine and Docker Desktop behavior distinct from generic remote daemons, + containerized Reploy, and Unix-socket proxies. + - [ ] `P2` Consider a Reploy host configuration surface. Keep a standing inventory of concrete operator- or host-owned settings that do not belong in blueprints, staging overrides, or installation @@ -192,6 +205,20 @@ This file is the day-to-day queue for design and implementation gaps. fixed host-owned limits for controlled-session endpoint streams and connection-open rates. +- [ ] `P2` Design and implement a Reploy userland L3 policy gateway. Keep this + separate from the initial public/local kill switches and controlled + sessions. Define a capability-free application network namespace, a + one-shot route initializer, an isolated data path whose only peer is the + gateway, private gateway control, root-resistant route invariants, + IPv4/IPv6 and DNS policy, destination and port grants, auditing, resource + limits, failure behavior, reconciliation, and Docker/Podman plus Desktop + integration. Treat native engine primitives as fast paths rather than + exposing backend network modes as product policy. Replace or mediate the + initial controlled-session host-loopback endpoint publication so only the + lease-owned Host Reploy operation can reach the recorded application; + include multi-user-host tests proving unrelated local processes cannot + bypass the session endpoint grant. + - [ ] `P2` Evaluate and prioritize the Dingo development-environment gaps. Use `docs/DINGO_GAPS.md` as the needs and evidence record for portable checkout binding, development execution, shell initialization, pinned diff --git a/docs/CONTROLLED_SESSION_DESIGN.md b/docs/CONTROLLED_SESSION_DESIGN.md index 0bc3c555..dd8bcfca 100644 --- a/docs/CONTROLLED_SESSION_DESIGN.md +++ b/docs/CONTROLLED_SESSION_DESIGN.md @@ -76,12 +76,13 @@ containers use the host account selected by materializes the local account through Linux account databases; other target-OS backends may realize the same contract differently. -If the effective runtime user is root, Reploy emits a precise warning that the -application can interfere with more of its container. Root does not implicitly -grant capabilities, host input or shared-state mounts, network access, -privileged mode, or daemon access. Root-safe `--output-file` and `--output-dir` -are separate global runtime contracts and remain rejected until their focused -pre-release review and implementation are complete. +Root remains an explicit runtime identity, but selecting it does not produce a +generic runtime warning. Reploy instead rejects prohibited combinations with +precise diagnostics. Root does not implicitly grant capabilities, host input +or shared-state mounts, network access, privileged mode, or daemon access. +Root-safe `--output-file` and `--output-dir` are separate global runtime +contracts and remain rejected until their focused pre-release review and +implementation are complete. ## Context @@ -635,13 +636,13 @@ user-scope Reploy was invoked as root, or because a system-scope installation explicitly selected root. It is never inherited merely from the base image's configured `USER`. -A root runtime identity must emit a warning equivalent to: - -> The application will run as root inside its container. Root can bypass -> application-level file permissions. Host input and shared-state mounts are -> prohibited. Explicit root output contracts require their separately reviewed -> safeguards. Network access and Linux capabilities remain restricted unless -> separately granted. +A root runtime identity does not emit a generic warning. With the global +sandbox enforced, its additional authority is limited to container-scoped +root-owned image content, declared persistent storage, and processes using the +same identity. Prohibited combinations fail with diagnostics that identify the +specific rejected authority. If a future capability grants root broader +authority, that capability's explicit opt-in surface must disclose the added +risk rather than making ordinary root execution noisy. A root runtime identity does not imply: @@ -670,6 +671,60 @@ approved project directory. A client cannot turn it into an arbitrary host-path selector through the session protocol. Original project source is never exposed through a writable bind. +An explicit host directory bind grants access to every unmasked entry below +that directory. Read-only mode prevents ordinary file mutation, but does not +neutralize Unix sockets, device nodes, FIFOs, or nested mount points. Reploy +does not recursively scan a live source tree: such a scan has unbounded launch +cost and provides only a race-prone point-in-time observation. A caller that +requires stronger isolation must use no host bind or a future filtered-copy +workspace. This is a deliberate narrowing of the direct-bind security +contract, not a claim that active host objects have been confined. + +Direct host binds also trust the selected host pathname namespace to remain +stable until Docker establishes the mount. Reploy does not defend against a +separate host-side actor retargeting the source path during launch or using +Docker daemon access to alter the container. Such actors already hold authority +outside the controller/workload isolation boundary. The launched workload +cannot create this race itself because Docker establishes its mounts before +starting the workload process. + +Generic remote Docker daemons are unsupported. Reploy requires a local Unix +socket or Windows named-pipe endpoint, including the local endpoint presented +by Docker Desktop. This prevents local validation and output contracts from +silently applying to paths, ports, images, identities, and lifecycle state on +another machine. A future remote-Docker design requires explicit input upload, +output extraction and local publication, image placement, port forwarding, +authentication, cleanup, and recovery semantics rather than inherited Docker +context behavior. + +On native Linux, the operator must run Reploy directly in the host namespace +served by the local Docker Engine. Running Reploy inside a container with a +host socket mounted, or placing a local Unix-socket proxy in front of another +daemon, is unsupported because the Docker API cannot prove that Reploy and the +daemon resolve host paths in the same mount namespace. Unix-socket and +named-pipe classification rejects ordinary remote Docker configuration; it is +not a security attestation for an operator-controlled socket. Docker Desktop +is the intentional exception because its native client integration supplies the +supported host-path sharing and port-forwarding contract. + +Ordinary host binds reject the host filesystem root and canonical sources at +or below `/proc`, `/dev`, or `/sys`, including symlink aliases. On Linux, +Reploy also rejects bind mounts that expose the same filesystem root and checks +filesystem identity so procfs, sysfs, cgroup hierarchies, device filesystems, +and kernel control or observation filesystems remain prohibited when exposed +through another path. Linux proc magic links are rejected before canonical +resolution, including when procfs is reached through a symlink alias, so they +cannot resolve differently for Reploy and Docker. Where Linux reports mount +identity, Reploy rejects aliases of every mount rooted below `/proc`, `/dev`, +or `/sys` while preserving unrelated mounts of the same filesystem type. On +Linux kernels without no-magic-link path resolution, direct paths remain +available but symlinked host sources fail closed. On macOS, native devfs and +procfs sources are likewise rejected. +Containers keep Docker's container-scoped `/proc` and restricted `/dev`; those +are not host binds. Hardware or host-observation access, if later justified by +a compelling use case, requires a separately designed explicit capability +rather than an ordinary mount. + Root inside any Reploy application container may not receive host input or shared-state binds, including read-only binds. Read-only prevents modification but does not make exposed content confidential from container root. Reploy @@ -786,6 +841,13 @@ denial includes host gateways, Docker peers outside the granted operation, loopback redirection, private and link-local address ranges, IPv6 local ranges, and infrastructure metadata endpoints. +The initial implementation preserves this coarse public/local policy intent +and exact declared endpoint grants, using only backend isolation and endpoint +primitives whose behavior Reploy can verify. A backend that cannot realize a +requested combination fails closed. This slice does not introduce a custom +packet gateway and must not claim destination-, port-, domain-, DNS-, or +packet-level enforcement beyond what the selected primitive actually proves. + A controller may receive an explicit session-local grant to a declared workload endpoint. That grant is not treated as general local-network access. @@ -1077,9 +1139,13 @@ remain separate prerequisite slices. Root host authority is now enforced at runtime: host sources are classified as input, shared state, or explicit output; UID 0 is rejected for all three before container creation; and root output options are rejected before host-path preparation. Docker-managed -volumes and tmpfs remain available to root. Durable confinement of special -files nested inside a non-root directory bind remains unresolved and must not -be represented as solved by an expensive launch-time snapshot alone. +volumes and tmpfs remain available to root. Ordinary binds also reject +canonical host root, `/proc`, +`/dev`, and `/sys` sources plus equivalent protected filesystem mounts detected +through native filesystem identity. Explicit non-root directory binds +intentionally grant access to their remaining unmasked contents, including +nested active objects; this narrowed contract avoids representing a recursive +launch-time scan as durable confinement. ### Slice 2: Controlled-Session Lifecycle Core @@ -1151,11 +1217,16 @@ lease protocol. ### Network Isolation and Audit -Define general public and local kill switches, direct-egress enforcement, proxy -behavior, DNS control, IPv6, metadata protection, and auditability as a -separate Reploy/agent-sandbox design. The one-way, exact endpoint forwarding -used by the initial controlled session is intentionally narrower than that -future surface. +After the coarse public/local kill switches, define a separate Reploy userland +L3 policy gateway for finer network control. Its design should cover a +capability-free application network namespace, one-shot route initialization, +an isolated data path whose only peer is the gateway, private gateway control, +root-resistant route invariants, direct-egress prevention, destination and +port grants, DNS and IPv6 policy, metadata protection, auditing, resource +limits, failure behavior, reconciliation, and portable Docker/Podman +integration. The one-way, exact endpoint forwarding used by the initial +controlled session remains intentionally narrower and does not depend on this +later gateway. ### Disposable Writable Workspaces @@ -1192,7 +1263,8 @@ analysis. Privileged application containers remain outside this design. - Installed system-scope application containers use the configured host service account's numeric identity under the blueprint's container-local account name. -- Root application containers are possible but visibly weaker. +- Root application containers are possible but receive no implicit additional + authority; prohibited combinations fail with precise diagnostics. - Root application containers never receive host input or shared-state binds; local source requires the separately designed disposable-copy capability. Explicit root output-only binds remain unavailable until their separate diff --git a/internal/dockerdeploy/apt_resolver_session.go b/internal/dockerdeploy/apt_resolver_session.go index 83dba1c1..04d46764 100644 --- a/internal/dockerdeploy/apt_resolver_session.go +++ b/internal/dockerdeploy/apt_resolver_session.go @@ -39,6 +39,7 @@ type APTResolverSession struct { probe PreparedProbeWorkspace resolver PreparedAPTResolverWorkspace containerName string + runDocker commandRunner stdout io.Writer stderr io.Writer observations map[string]probe.ExecutableObservationV1 @@ -624,8 +625,7 @@ func (session *APTResolverSession) RefreshIndexes(ctx context.Context) error { return nil } -var runAPTResolverOpenCommand = runCommand -var runAPTResolverFollowupCommand = runCommandWithoutDockerPreflight +var bindAPTResolverCommandRunner = bindDockerCommandRunnerV1 // OpenAPTResolverSession starts the one held container that will validate the // exact prefix and, in later typed operations, resolve its APT transaction. @@ -674,25 +674,27 @@ func OpenAPTResolverSession( }} var stdout bytes.Buffer var stderr bytes.Buffer - if err := runAPTResolverOpenCommand(spec, RunOptions{ - Context: context.WithoutCancel(ctx), Stdout: &stdout, Stderr: &stderr, - DockerPreflightTimeout: options.DockerPreflightTimeout, - }); err != nil { + runDocker, err := bindAPTResolverCommandRunner(context.WithoutCancel(ctx), spec, options.DockerPreflightTimeout) + if err != nil { + return nil, aptResolverCommandError("create", descriptor.Platform.Canonical, descriptor.ConfigDigest, stderr.String(), err) + } + if err := runDocker(spec, RunOptions{Context: context.WithoutCancel(ctx), Stdout: &stdout, Stderr: &stderr}); err != nil { return nil, aptResolverCommandError("create", descriptor.Platform.Canonical, descriptor.ConfigDigest, stderr.String(), err) } if err := ctx.Err(); err != nil { - cleanupErr := removeAPTResolverContainer(context.WithoutCancel(ctx), containerName) + cleanupErr := removeAPTResolverContainer(context.WithoutCancel(ctx), containerName, runDocker) return nil, errors.Join(fmt.Errorf("open APT resolver session: %w", err), cleanupErr) } stderr.Reset() - if err := runAPTResolverFollowupCommand(CommandSpec{Name: "docker", Args: []string{"start", containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := runDocker(CommandSpec{Name: "docker", Args: []string{"start", containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { startErr := aptResolverCommandError("start", descriptor.Platform.Canonical, descriptor.ConfigDigest, stderr.String(), err) - cleanupErr := removeAPTResolverContainer(context.WithoutCancel(ctx), containerName) + cleanupErr := removeAPTResolverContainer(context.WithoutCancel(ctx), containerName, runDocker) return nil, errors.Join(startErr, cleanupErr) } return &APTResolverSession{ descriptor: descriptor, probe: probeWorkspace, resolver: resolverWorkspace, - containerName: containerName, stdout: aptResolverOutputWriter(options.Stdout), stderr: aptResolverOutputWriter(options.Stderr), + containerName: containerName, runDocker: runDocker, + stdout: aptResolverOutputWriter(options.Stdout), stderr: aptResolverOutputWriter(options.Stderr), observations: map[string]probe.ExecutableObservationV1{}, }, nil } @@ -747,7 +749,7 @@ func (session *APTResolverSession) runProbe(ctx context.Context, request probe.R "exec", "--interactive", "--user", "0:0", "--workdir", "/", session.containerName, session.probe.ContainerExecutable, }} - if err := runAPTResolverFollowupCommand(spec, RunOptions{Context: ctx, Stdin: bytes.NewReader(encoded), Stdout: &stdout, Stderr: io.MultiWriter(&stderr, session.stderr)}); err != nil { + if err := session.runDocker(spec, RunOptions{Context: ctx, Stdin: bytes.NewReader(encoded), Stdout: &stdout, Stderr: io.MultiWriter(&stderr, session.stderr)}); err != nil { return probe.ResponseV1{}, aptResolverCommandError("probe", session.descriptor.Platform.Canonical, session.descriptor.ConfigDigest, stderr.String(), err) } response, err := probe.DecodeResponseV1(request, stdout.Bytes()) @@ -803,7 +805,7 @@ func (session *APTResolverSession) runProfileArgvTo(ctx context.Context, phase s args = append(args, argv[1:]...) diagnosticTail := &aptDiagnosticTail{limit: commandOutputErrorLimit} stderr = io.MultiWriter(aptResolverOutputWriter(stderr), diagnosticTail) - if err := runAPTResolverFollowupCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{Context: ctx, Stdout: aptResolverOutputWriter(stdout), Stderr: stderr}); err != nil { + if err := session.runDocker(CommandSpec{Name: "docker", Args: args}, RunOptions{Context: ctx, Stdout: aptResolverOutputWriter(stdout), Stderr: stderr}); err != nil { return aptResolverCommandError(phase, session.descriptor.Platform.Canonical, session.descriptor.ConfigDigest, diagnosticTail.String(), err) } return nil @@ -852,7 +854,7 @@ func (session *APTResolverSession) Close(ctx context.Context) error { if ctx == nil { return fmt.Errorf("close APT resolver session context is required") } - if err := removeAPTResolverContainer(ctx, session.containerName); err != nil { + if err := removeAPTResolverContainer(ctx, session.containerName, session.runDocker); err != nil { return err } session.closed = true @@ -973,10 +975,10 @@ func aptResolverContainerName(workspace string) string { return fmt.Sprintf("reploy-apt-resolve-%x", digest[:12]) } -func removeAPTResolverContainer(ctx context.Context, containerName string) error { +func removeAPTResolverContainer(ctx context.Context, containerName string, runDocker commandRunner) error { var stdout bytes.Buffer var stderr bytes.Buffer - if err := runAPTResolverFollowupCommand(CommandSpec{Name: "docker", Args: []string{"rm", "--force", containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := runDocker(CommandSpec{Name: "docker", Args: []string{"rm", "--force", containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { return markProviderHelperCleanupError(aptResolverCommandError("remove", "container", "", stderr.String(), err)) } return nil diff --git a/internal/dockerdeploy/apt_resolver_session_test.go b/internal/dockerdeploy/apt_resolver_session_test.go index b71cac94..b875e14a 100644 --- a/internal/dockerdeploy/apt_resolver_session_test.go +++ b/internal/dockerdeploy/apt_resolver_session_test.go @@ -285,14 +285,14 @@ func TestAPTResolverBaseStateCommandFailureDoesNotRetryOrAcceptPartialOutput(t * if _, err := session.PlanPackages(context.Background(), request); err != nil { t.Fatal(err) } - prior := runAPTResolverFollowupCommand - runAPTResolverFollowupCommand = func(spec CommandSpec, options RunOptions) error { + prior := session.runDocker + session.runDocker = func(spec CommandSpec, options RunOptions) error { *commands = append(*commands, spec) _, _ = options.Stdout.Write([]byte("libc6:amd64\t2.39\tamd64\tinstall ok installed\n")) _, _ = options.Stderr.Write([]byte("secret partial dpkg output")) return syscall.E2BIG } - t.Cleanup(func() { runAPTResolverFollowupCommand = prior }) + t.Cleanup(func() { session.runDocker = prior }) _, err = session.ReadBasePackageState(context.Background()) if err == nil || !strings.Contains(err.Error(), "apt.resolve.base-state") || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "libc6") { t.Fatalf("base state err = %v", err) @@ -302,7 +302,7 @@ func TestAPTResolverBaseStateCommandFailureDoesNotRetryOrAcceptPartialOutput(t * if err == nil || !strings.Contains(err.Error(), "already failed") || len(*commands) != commandCount { t.Fatalf("retry err = %v, commands = %d", err, len(*commands)) } - runAPTResolverFollowupCommand = prior + session.runDocker = prior if err := session.Close(context.Background()); err != nil { t.Fatal(err) } @@ -467,8 +467,8 @@ func TestAPTResolverRefreshForwardsDiagnosticsAndReturnsStructuredError(t *testi if _, err := session.ProbeBaseProfile(context.Background()); err != nil { t.Fatal(err) } - prior := runAPTResolverFollowupCommand - runAPTResolverFollowupCommand = func(spec CommandSpec, options RunOptions) error { + prior := session.runDocker + session.runDocker = func(spec CommandSpec, options RunOptions) error { commandsValue := *commands commandsValue = append(commandsValue, spec) *commands = commandsValue @@ -476,7 +476,7 @@ func TestAPTResolverRefreshForwardsDiagnosticsAndReturnsStructuredError(t *testi _, _ = options.Stderr.Write([]byte("E: https://user:secret@example.invalid/private failed\n")) return errors.New("exit status 100: user:secret") } - t.Cleanup(func() { runAPTResolverFollowupCommand = prior }) + t.Cleanup(func() { session.runDocker = prior }) err = session.RefreshIndexes(context.Background()) if err == nil || !strings.Contains(err.Error(), "apt.resolve.update") || !strings.Contains(err.Error(), "apt.update_failed") || !strings.Contains(err.Error(), "select or rebuild a base image") || strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "example.invalid") { t.Fatalf("refresh err = %v", err) @@ -493,7 +493,7 @@ func TestAPTResolverRefreshForwardsDiagnosticsAndReturnsStructuredError(t *testi if err == nil || !strings.Contains(err.Error(), "already failed") || len(*commands) != commandCount { t.Fatalf("retry err = %v, commands = %d", err, len(*commands)) } - runAPTResolverFollowupCommand = prior + session.runDocker = prior if err := session.Close(context.Background()); err != nil { t.Fatal(err) } @@ -578,28 +578,29 @@ func TestOpenAPTResolverSessionFinishesCreateBeforeHonoringCancellation(t *testi descriptor := testProbeImageDescriptor(t, "linux/amd64") probeWorkspace := testPreparedProbeWorkspace(t, descriptor.Platform, t.TempDir()) resolverWorkspace := testPreparedAPTResolverWorkspace(t) - previousOpen := runAPTResolverOpenCommand - previousFollowup := runAPTResolverFollowupCommand - t.Cleanup(func() { - runAPTResolverOpenCommand = previousOpen - runAPTResolverFollowupCommand = previousFollowup - }) + previousBind := bindAPTResolverCommandRunner + t.Cleanup(func() { bindAPTResolverCommandRunner = previousBind }) ctx, cancel := context.WithCancel(context.Background()) removed := false - runAPTResolverOpenCommand = func(_ CommandSpec, options RunOptions) error { - cancel() - if err := options.Context.Err(); err != nil { - t.Fatalf("Docker create inherited cancellation: %v", err) + bindAPTResolverCommandRunner = func(bindCtx context.Context, _ CommandSpec, _ time.Duration) (commandRunner, error) { + if err := bindCtx.Err(); err != nil { + t.Fatalf("Docker endpoint binding inherited cancellation: %v", err) } - return nil - } - runAPTResolverFollowupCommand = func(spec CommandSpec, _ RunOptions) error { - if len(spec.Args) == 3 && spec.Args[0] == "rm" && spec.Args[1] == "--force" { - removed = true + return func(spec CommandSpec, options RunOptions) error { + if len(spec.Args) != 0 && spec.Args[0] == "create" { + cancel() + if err := options.Context.Err(); err != nil { + t.Fatalf("Docker create inherited cancellation: %v", err) + } + return nil + } + if len(spec.Args) == 3 && spec.Args[0] == "rm" && spec.Args[1] == "--force" { + removed = true + return nil + } + t.Fatalf("unexpected follow-up command: %#v", spec) return nil - } - t.Fatalf("unexpected follow-up command: %#v", spec) - return nil + }, nil } if _, err := OpenAPTResolverSession(ctx, descriptor, probeWorkspace, resolverWorkspace, RunOptions{}); !errors.Is(err, context.Canceled) { t.Fatalf("error = %v", err) @@ -613,18 +614,17 @@ func TestOpenAPTResolverSessionPropagatesDockerPreflightTimeout(t *testing.T) { descriptor := testProbeImageDescriptor(t, "linux/amd64") probeWorkspace := testPreparedProbeWorkspace(t, descriptor.Platform, t.TempDir()) resolverWorkspace := testPreparedAPTResolverWorkspace(t) - previousOpen := runAPTResolverOpenCommand - previousFollowup := runAPTResolverFollowupCommand - t.Cleanup(func() { - runAPTResolverOpenCommand = previousOpen - runAPTResolverFollowupCommand = previousFollowup - }) + previousBind := bindAPTResolverCommandRunner + t.Cleanup(func() { bindAPTResolverCommandRunner = previousBind }) var received time.Duration - runAPTResolverOpenCommand = func(_ CommandSpec, options RunOptions) error { - received = options.DockerPreflightTimeout - return nil + commands := []CommandSpec{} + bindAPTResolverCommandRunner = func(_ context.Context, _ CommandSpec, timeout time.Duration) (commandRunner, error) { + received = timeout + return func(spec CommandSpec, _ RunOptions) error { + commands = append(commands, spec) + return nil + }, nil } - runAPTResolverFollowupCommand = func(_ CommandSpec, _ RunOptions) error { return nil } const timeout = 17 * time.Second session, err := OpenAPTResolverSession(context.Background(), descriptor, probeWorkspace, resolverWorkspace, RunOptions{ @@ -639,6 +639,9 @@ func TestOpenAPTResolverSessionPropagatesDockerPreflightTimeout(t *testing.T) { if err := session.Close(context.Background()); err != nil { t.Fatal(err) } + if len(commands) != 3 || commands[0].Args[0] != "create" || commands[1].Args[0] != "start" || commands[2].Args[0] != "rm" { + t.Fatalf("bound Docker commands = %#v", commands) + } } func TestOpenAPTResolverSessionRejectsNonemptyScratchBeforeDocker(t *testing.T) { @@ -728,55 +731,49 @@ func stubAPTResolverCommands( startErr error, ) (*[]CommandSpec, *[]byte) { t.Helper() - previousOpen := runAPTResolverOpenCommand - previousFollowup := runAPTResolverFollowupCommand + previousBind := bindAPTResolverCommandRunner commands := []CommandSpec{} probeInput := []byte(nil) profileIndex := 0 - t.Cleanup(func() { - runAPTResolverOpenCommand = previousOpen - runAPTResolverFollowupCommand = previousFollowup - }) - runAPTResolverOpenCommand = func(spec CommandSpec, _ RunOptions) error { - commands = append(commands, spec) - return nil - } - runAPTResolverFollowupCommand = func(spec CommandSpec, options RunOptions) error { - commands = append(commands, spec) - if len(spec.Args) != 0 && spec.Args[0] == "start" && startErr != nil { - if options.Stderr != nil { - _, _ = options.Stderr.Write([]byte(startErr.Error())) - } - return startErr - } - if len(spec.Args) != 0 && spec.Args[len(spec.Args)-1] == ProbeContainerExecutable { - input, err := io.ReadAll(options.Stdin) - if err != nil { - return err + t.Cleanup(func() { bindAPTResolverCommandRunner = previousBind }) + bindAPTResolverCommandRunner = func(context.Context, CommandSpec, time.Duration) (commandRunner, error) { + return func(spec CommandSpec, options RunOptions) error { + commands = append(commands, spec) + if len(spec.Args) != 0 && spec.Args[0] == "start" && startErr != nil { + if options.Stderr != nil { + _, _ = options.Stderr.Write([]byte(startErr.Error())) + } + return startErr } - probeInput = input - _, _ = options.Stdout.Write(probeResponse) - return nil - } - if len(spec.Args) != 0 && spec.Args[0] == "exec" { - if profileIndex >= len(profileOutputs) { - return errors.New("unexpected APT profile command") + if len(spec.Args) != 0 && spec.Args[len(spec.Args)-1] == ProbeContainerExecutable { + input, err := io.ReadAll(options.Stdin) + if err != nil { + return err + } + probeInput = input + _, _ = options.Stdout.Write(probeResponse) + return nil } - joined := strings.Join(spec.Args, "\x00") - if strings.Contains(joined, "Debug::pkgDepCache::Marker=1") { - stdout, stderr, separated := bytes.Cut(profileOutputs[profileIndex], []byte("\x00APT-PLAN-STDERR\x00")) - if separated { - _, _ = options.Stdout.Write(stdout) - _, _ = options.Stderr.Write(stderr) + if len(spec.Args) != 0 && spec.Args[0] == "exec" { + if profileIndex >= len(profileOutputs) { + return errors.New("unexpected APT profile command") + } + joined := strings.Join(spec.Args, "\x00") + if strings.Contains(joined, "Debug::pkgDepCache::Marker=1") { + stdout, stderr, separated := bytes.Cut(profileOutputs[profileIndex], []byte("\x00APT-PLAN-STDERR\x00")) + if separated { + _, _ = options.Stdout.Write(stdout) + _, _ = options.Stderr.Write(stderr) + } else { + _, _ = options.Stderr.Write(profileOutputs[profileIndex]) + } } else { - _, _ = options.Stderr.Write(profileOutputs[profileIndex]) + _, _ = options.Stdout.Write(profileOutputs[profileIndex]) } - } else { - _, _ = options.Stdout.Write(profileOutputs[profileIndex]) + profileIndex++ } - profileIndex++ - } - return nil + return nil + }, nil } return &commands, &probeInput } diff --git a/internal/dockerdeploy/buildkit_probe.go b/internal/dockerdeploy/buildkit_probe.go index 59f664ea..440f893e 100644 --- a/internal/dockerdeploy/buildkit_probe.go +++ b/internal/dockerdeploy/buildkit_probe.go @@ -3,6 +3,7 @@ package dockerdeploy import ( "context" "fmt" + "os" "os/exec" "strconv" "strings" @@ -26,17 +27,27 @@ type BuildKitCapabilities struct { } type dockerOutputRunner func(context.Context, ...string) (string, error) +type dockerOutputBinder func(context.Context) (dockerOutputRunner, string, error) var runDockerOutput dockerOutputRunner = executeDockerOutput +var bindBuildKitDockerOutput dockerOutputBinder = bindDockerOutputV1 // ProbeBuildKitCapabilities verifies the common Linux daemon contract used on // native Linux and by Docker Desktop. The generated-build smoke test remains // the final proof that the daemon's BuildKit frontend supports RUN mounts. func ProbeBuildKitCapabilities(ctx context.Context) (BuildKitCapabilities, error) { - return probeBuildKitCapabilities(ctx, runDockerOutput) + run, contextName, err := bindBuildKitDockerOutput(ctx) + if err != nil { + return BuildKitCapabilities{}, fmt.Errorf("probe Docker daemon for generated images: %w", err) + } + return probeBuildKitCapabilitiesForContext(ctx, run, contextName) } func probeBuildKitCapabilities(ctx context.Context, run dockerOutputRunner) (BuildKitCapabilities, error) { + return probeBuildKitCapabilitiesForContext(ctx, run, "") +} + +func probeBuildKitCapabilitiesForContext(ctx context.Context, run dockerOutputRunner, contextName string) (BuildKitCapabilities, error) { output, err := run(ctx, "info", "--format", "{{.ServerVersion}}\t{{.OSType}}\t{{.OperatingSystem}}") if err != nil { return BuildKitCapabilities{}, fmt.Errorf("probe Docker daemon for generated images: %w", err) @@ -45,12 +56,15 @@ func probeBuildKitCapabilities(ctx context.Context, run dockerOutputRunner) (Bui if len(parts) != 3 || parts[0] == "" || parts[1] == "" { return BuildKitCapabilities{}, fmt.Errorf("probe Docker daemon returned unexpected output %q", strings.TrimSpace(output)) } - contextName, err := run(ctx, "context", "show") - if err != nil { - return BuildKitCapabilities{}, fmt.Errorf("probe Docker context: %w", err) + if contextName == "" { + contextOutput, err := run(ctx, "context", "show") + if err != nil { + return BuildKitCapabilities{}, fmt.Errorf("probe Docker context: %w", err) + } + contextName = strings.TrimSpace(contextOutput) } capabilities := BuildKitCapabilities{ - ServerVersion: parts[0], ServerOS: parts[1], OperatingSystem: parts[2], Context: strings.TrimSpace(contextName), + ServerVersion: parts[0], ServerOS: parts[1], OperatingSystem: parts[2], Context: contextName, Engine: DockerEngineLinux, } if strings.Contains(strings.ToLower(capabilities.OperatingSystem), "docker desktop") { @@ -65,6 +79,17 @@ func probeBuildKitCapabilities(ctx context.Context, run dockerOutputRunner) (Bui return capabilities, nil } +func bindDockerOutputV1(ctx context.Context) (dockerOutputRunner, string, error) { + spec := CommandSpec{Name: "docker"} + target, err := verifiedLocalDockerTargetV1(ctx, spec, defaultDockerPreflightTimeout) + if err != nil { + return nil, "", err + } + return func(runCtx context.Context, args ...string) (string, error) { + return executeDockerOutputAtEndpoint(runCtx, target.Endpoint, args...) + }, target.Context, nil +} + func minimumDockerVersion(value string, minimumMajor int, minimumMinor int) bool { value = strings.TrimPrefix(strings.TrimSpace(value), "v") parts := strings.Split(value, ".") @@ -83,8 +108,20 @@ func minimumDockerVersion(value string, minimumMajor int, minimumMinor int) bool } func executeDockerOutput(ctx context.Context, args ...string) (string, error) { + spec := CommandSpec{Name: "docker", Args: args} + endpoint, err := verifiedLocalDockerEndpointV1(ctx, spec, defaultDockerPreflightTimeout) + if err != nil { + return "", err + } + return executeDockerOutputAtEndpoint(ctx, endpoint, args...) +} + +func executeDockerOutputAtEndpoint(ctx context.Context, endpoint string, args ...string) (string, error) { + spec := CommandSpec{Name: "docker", Args: args} + spec = pinDockerEndpointV1(spec, endpoint) ctx, end := buildprofile.Start(ctx, dockerProfileOperation(args)) - command := exec.CommandContext(ctx, "docker", args...) + command := exec.CommandContext(ctx, spec.Name, spec.Args...) + command.Env = append(os.Environ(), spec.Env...) output, err := command.CombinedOutput() // Docker output probes often use a non-zero exit to represent ordinary // absence. Their semantic caller records a failure only when it propagates. diff --git a/internal/dockerdeploy/buildkit_probe_test.go b/internal/dockerdeploy/buildkit_probe_test.go index 1012c63f..2e158ea0 100644 --- a/internal/dockerdeploy/buildkit_probe_test.go +++ b/internal/dockerdeploy/buildkit_probe_test.go @@ -3,6 +3,10 @@ package dockerdeploy import ( "context" "fmt" + "os" + "path/filepath" + "runtime" + "strings" "testing" ) @@ -53,3 +57,71 @@ func TestProbeBuildKitCapabilitiesRejectsUnsupportedDaemon(t *testing.T) { } } } + +func TestExecuteDockerOutputPinsVerifiedEndpoint(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture requires a POSIX host") + } + dir := t.TempDir() + logPath := filepath.Join(dir, "command.env") + writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\nif [ \"$1\" = context ]; then printf 'unix:///verified/docker.sock\\n'; exit 0; fi\nprintf '%s|%s|%s\\n' \"$*\" \"$DOCKER_HOST\" \"$DOCKER_CONTEXT\" > \"$DOCKER_COMMAND_ENV\"\nprintf 'result\\n'\n", + "@exit /b 1\r\n", + ) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("DOCKER_HOST", "") + t.Setenv("DOCKER_CONTEXT", "") + t.Setenv("DOCKER_COMMAND_ENV", logPath) + + output, err := executeDockerOutput(context.Background(), "image", "inspect", "demo") + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(output) != "result" { + t.Fatalf("output = %q", output) + } + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(content)); got != "image inspect demo|unix:///verified/docker.sock|" { + t.Fatalf("Docker environment = %q", got) + } +} + +func TestProbeBuildKitCapabilitiesRetainsVerifiedNamedContext(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture requires a POSIX host") + } + dir := t.TempDir() + logPath := filepath.Join(dir, "command.env") + writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\nif [ \"$1\" = context ] && [ \"$2\" = inspect ]; then printf 'desktop-linux\\tunix:///verified/docker.sock\\n'; exit 0; fi\nif [ \"$1\" = info ]; then printf '%s|%s\\n' \"$DOCKER_HOST\" \"$DOCKER_CONTEXT\" > \"$DOCKER_COMMAND_ENV\"; printf '29.6.1\\tlinux\\tDocker Desktop\\n'; exit 0; fi\nprintf 'unexpected command: %s\\n' \"$*\" >&2\nexit 1\n", + "@exit /b 1\r\n", + ) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("DOCKER_HOST", "") + t.Setenv("DOCKER_CONTEXT", "") + t.Setenv("DOCKER_COMMAND_ENV", logPath) + + capabilities, err := ProbeBuildKitCapabilities(context.Background()) + if err != nil { + t.Fatal(err) + } + if capabilities.Context != "desktop-linux" || capabilities.Engine != DockerEngineDesktop { + t.Fatalf("capabilities = %#v", capabilities) + } + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(content)); got != "unix:///verified/docker.sock|" { + t.Fatalf("Docker environment = %q", got) + } +} diff --git a/internal/dockerdeploy/compose.go b/internal/dockerdeploy/compose.go index 04ab546d..42416c3b 100644 --- a/internal/dockerdeploy/compose.go +++ b/internal/dockerdeploy/compose.go @@ -41,25 +41,50 @@ const defaultDockerPreflightTimeout = 5 * time.Second var dockerPreflight = checkDockerResponsive func runCommand(spec CommandSpec, options RunOptions) error { + if spec.Name == "docker" { + return runDockerCommand(spec, options) + } + return runCommandWithoutDockerPreflight(spec, options) +} + +func runDockerCommand(spec CommandSpec, options RunOptions) error { ctx := options.Context if ctx == nil { ctx = context.Background() } - if spec.Name == "docker" { - _, end := buildprofile.Start(ctx, "Docker preflight") - err := dockerPreflight(ctx, spec, effectiveDockerPreflightTimeout(options.DockerPreflightTimeout)) - end(err) - if err != nil { - return err - } + run, err := bindDockerCommandRunnerV1(ctx, spec, options.DockerPreflightTimeout) + if err != nil { + return err } - return runCommandWithoutDockerPreflight(spec, options) + return run(spec, options) +} + +func bindDockerCommandRunnerV1(ctx context.Context, spec CommandSpec, timeout time.Duration) (commandRunner, error) { + if ctx == nil { + return nil, fmt.Errorf("bind Docker command runner requires a context") + } + _, end := buildprofile.Start(ctx, "Docker preflight") + endpoint, err := dockerPreflight(ctx, spec, effectiveDockerPreflightTimeout(timeout)) + end(err) + if err != nil { + return nil, err + } + executable := spec.Name + return func(command CommandSpec, options RunOptions) error { + if command.Name != executable { + return fmt.Errorf("Docker operation changed executable from %q to %q", executable, command.Name) + } + return runCommandWithoutDockerPreflight(pinDockerEndpointV1(command, endpoint), options) + }, nil } -// runCommandWithoutDockerPreflight is for follow-up commands in one -// higher-level Docker operation whose first command already passed preflight. -// Callers must not use it as the entry point to an independent operation. +// runCommandWithoutDockerPreflight executes non-Docker commands and Docker +// commands whose exact local endpoint was already pinned by runDockerCommand. +// Recognizable unpinned Docker commands fail closed. func runCommandWithoutDockerPreflight(spec CommandSpec, options RunOptions) (resultErr error) { + if dockerCommandExecutableV1(spec.Name) && !pinnedDockerEndpointV1(spec) { + return fmt.Errorf("Docker command %q requires a verified pinned local endpoint", spec.Name) + } ctx := options.Context if ctx == nil { ctx = context.Background() @@ -92,6 +117,27 @@ func runCommandWithoutDockerPreflight(spec CommandSpec, options RunOptions) (res return nil } +func dockerCommandExecutableV1(name string) bool { + base := strings.TrimSuffix(strings.ToLower(filepath.Base(strings.TrimSpace(name))), ".exe") + return base == "docker" +} + +func pinnedDockerEndpointV1(spec CommandSpec) bool { + host, hostSet := commandSpecEnvironmentValueV1(spec, "DOCKER_HOST") + contextName, contextSet := commandSpecEnvironmentValueV1(spec, "DOCKER_CONTEXT") + return hostSet && contextSet && contextName == "" && localDockerEndpointV1(host) +} + +func commandSpecEnvironmentValueV1(spec CommandSpec, name string) (string, bool) { + prefix := name + "=" + for index := len(spec.Env) - 1; index >= 0; index-- { + if strings.HasPrefix(spec.Env[index], prefix) { + return strings.TrimSpace(strings.TrimPrefix(spec.Env[index], prefix)), true + } + } + return "", false +} + func dockerProfileOperation(args []string) string { if len(args) == 0 { return "Docker command" @@ -119,10 +165,17 @@ func effectiveDockerPreflightTimeout(timeout time.Duration) time.Duration { return defaultDockerPreflightTimeout } -func checkDockerResponsive(ctx context.Context, spec CommandSpec, timeout time.Duration) error { +func checkDockerResponsive(ctx context.Context, spec CommandSpec, timeout time.Duration) (string, error) { + timeout = effectiveDockerPreflightTimeout(timeout) preflightCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + endpoint, err := verifiedLocalDockerEndpointV1(preflightCtx, spec, timeout) + if err != nil { + return "", err + } + spec = pinDockerEndpointV1(spec, endpoint) + command := exec.CommandContext(preflightCtx, spec.Name, "version", "--format", "{{.Server.Version}}") command.Dir = spec.Dir if len(spec.Env) > 0 { @@ -133,14 +186,14 @@ func checkDockerResponsive(ctx context.Context, spec CommandSpec, timeout time.D command.Stderr = &output if err := command.Run(); err != nil { if errors.Is(preflightCtx.Err(), context.DeadlineExceeded) { - return fmt.Errorf("docker daemon did not respond within %s", timeout) + return "", fmt.Errorf("docker daemon did not respond within %s", timeout) } if output := trimmedCommandOutput(output.String()); output != "" { - return fmt.Errorf("docker daemon check failed: %w\ncommand output:\n%s", err, output) + return "", fmt.Errorf("docker daemon check failed: %w\ncommand output:\n%s", err, output) } - return fmt.Errorf("docker daemon check failed: %w", err) + return "", fmt.Errorf("docker daemon check failed: %w", err) } - return nil + return endpoint, nil } func trimmedCommandOutput(output string) string { diff --git a/internal/dockerdeploy/compose_test.go b/internal/dockerdeploy/compose_test.go index 1dbbb100..6c95af6a 100644 --- a/internal/dockerdeploy/compose_test.go +++ b/internal/dockerdeploy/compose_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "reflect" "runtime" "strings" "testing" @@ -54,9 +55,9 @@ func TestRunCommandSkipsDockerPreflightForNonDockerCommand(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell fixture requires a POSIX host") } - restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) error { + restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) (string, error) { t.Fatal("docker preflight should not run for non-docker commands") - return nil + return "", nil }) defer restore() @@ -76,9 +77,9 @@ func TestRunCommandSkipsDockerPreflightForNonDockerCommand(t *testing.T) { func TestRunCommandChecksDockerBeforeDockerCommand(t *testing.T) { preflightErr := errors.New("daemon stuck") called := false - restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) error { + restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) (string, error) { called = true - return preflightErr + return "", preflightErr }) defer restore() @@ -94,9 +95,9 @@ func TestRunCommandChecksDockerBeforeDockerCommand(t *testing.T) { func TestRunCommandPassesDockerPreflightTimeout(t *testing.T) { preflightErr := errors.New("stop after preflight") var gotTimeout time.Duration - restore := stubDockerPreflight(t, func(_ context.Context, _ CommandSpec, timeout time.Duration) error { + restore := stubDockerPreflight(t, func(_ context.Context, _ CommandSpec, timeout time.Duration) (string, error) { gotTimeout = timeout - return preflightErr + return "", preflightErr }) defer restore() @@ -119,14 +120,17 @@ func TestRunCommandWithoutDockerPreflightRunsKnownFollowup(t *testing.T) { "@echo off\r\necho followup:%*\r\n", ) t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) - restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) error { + restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) (string, error) { t.Fatal("known Docker follow-up repeated preflight") - return nil + return "", nil }) defer restore() var stdout strings.Builder if err := runCommandWithoutDockerPreflight( - CommandSpec{Name: "docker", Args: []string{"exec", "validation"}}, + CommandSpec{Name: "docker", Args: []string{"exec", "validation"}, Env: []string{ + "DOCKER_HOST=unix:///var/run/docker.sock", + "DOCKER_CONTEXT=", + }}, RunOptions{Stdout: &stdout}, ); err != nil { t.Fatal(err) @@ -136,6 +140,145 @@ func TestRunCommandWithoutDockerPreflightRunsKnownFollowup(t *testing.T) { } } +func TestRunCommandWithoutDockerPreflightRejectsUnpinnedDocker(t *testing.T) { + err := runCommandWithoutDockerPreflight( + CommandSpec{Name: "docker", Args: []string{"exec", "validation"}}, + RunOptions{}, + ) + if err == nil || !strings.Contains(err.Error(), "requires a verified pinned local endpoint") { + t.Fatalf("error = %v", err) + } +} + +func TestRunDockerCommandPreflightsAbsoluteExecutable(t *testing.T) { + dir := t.TempDir() + dockerPath := writeFakeCommand( + t, + dir, + "configured-docker", + "#!/bin/sh\nexit 0\n", + "@echo off\r\nexit /b 0\r\n", + ) + preflightCalled := false + restore := stubDockerPreflight(t, func(_ context.Context, spec CommandSpec, _ time.Duration) (string, error) { + preflightCalled = true + if spec.Name != dockerPath { + t.Fatalf("preflight executable = %q, want %q", spec.Name, dockerPath) + } + return "unix:///var/run/docker.sock", nil + }) + defer restore() + + if err := runDockerCommand(CommandSpec{Name: dockerPath, Args: []string{"version"}}, RunOptions{}); err != nil { + t.Fatal(err) + } + if !preflightCalled { + t.Fatal("absolute Docker executable bypassed preflight") + } +} + +func TestRunDockerCommandPinsVerifiedEndpoint(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture requires a POSIX host") + } + dir := t.TempDir() + versionEnvironmentPath := filepath.Join(dir, "version.env") + commandEnvironmentPath := filepath.Join(dir, "command.env") + dockerPath := writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\ncase \"$1\" in\n context) printf 'unix:///verified/docker.sock\\n' ;;\n version) printf '%s|%s\\n' \"$DOCKER_HOST\" \"$DOCKER_CONTEXT\" > \"$DOCKER_VERSION_ENV\"; printf '29.5.3\\n' ;;\n create) printf '%s|%s\\n' \"$DOCKER_HOST\" \"$DOCKER_CONTEXT\" > \"$DOCKER_COMMAND_ENV\" ;;\nesac\n", + "@exit /b 1\r\n", + ) + spec := CommandSpec{ + Name: dockerPath, + Args: []string{"create"}, + Env: []string{ + "DOCKER_HOST=", + "DOCKER_CONTEXT=", + "DOCKER_VERSION_ENV=" + versionEnvironmentPath, + "DOCKER_COMMAND_ENV=" + commandEnvironmentPath, + }, + } + if err := runDockerCommand(spec, RunOptions{}); err != nil { + t.Fatal(err) + } + for _, path := range []string{versionEnvironmentPath, commandEnvironmentPath} { + content, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if got := strings.TrimSpace(string(content)); got != "unix:///verified/docker.sock|" { + t.Fatalf("%s environment = %q", filepath.Base(path), got) + } + } + if spec.Env[0] != "DOCKER_HOST=" || spec.Env[1] != "DOCKER_CONTEXT=" { + t.Fatalf("caller command environment was mutated: %q", spec.Env) + } +} + +func TestBindDockerCommandRunnerPinsOneEndpointForEveryCommand(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell fixture requires a POSIX host") + } + dir := t.TempDir() + logPath := filepath.Join(dir, "commands.log") + dockerPath := writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\nprintf '%s|%s|%s\\n' \"$*\" \"$DOCKER_HOST\" \"$DOCKER_CONTEXT\" >> \"$DOCKER_COMMAND_LOG\"\n", + "@exit /b 1\r\n", + ) + preflights := 0 + restore := stubDockerPreflight(t, func(_ context.Context, spec CommandSpec, timeout time.Duration) (string, error) { + preflights++ + if spec.Name != dockerPath || timeout != 3*time.Second { + t.Fatalf("preflight = %#v / %s", spec, timeout) + } + return "unix:///first/docker.sock", nil + }) + defer restore() + + run, err := bindDockerCommandRunnerV1( + context.Background(), + CommandSpec{Name: dockerPath}, + 3*time.Second, + ) + if err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"create", "demo"}, {"start", "demo"}, {"rm", "--force", "demo"}} { + if err := run( + CommandSpec{Name: dockerPath, Args: args, Env: []string{"DOCKER_COMMAND_LOG=" + logPath}}, + RunOptions{}, + ); err != nil { + t.Fatal(err) + } + } + if preflights != 1 { + t.Fatalf("Docker preflights = %d, want 1", preflights) + } + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + want := strings.Join([]string{ + "create demo|unix:///first/docker.sock|", + "start demo|unix:///first/docker.sock|", + "rm --force demo|unix:///first/docker.sock|", + "", + }, "\n") + if string(content) != want { + t.Fatalf("commands:\n%s\nwant:\n%s", content, want) + } + if err := run(CommandSpec{Name: filepath.Join(dir, "other-docker"), Args: []string{"info"}}, RunOptions{}); err == nil || + !strings.Contains(err.Error(), "changed executable") { + t.Fatalf("changed executable error = %v", err) + } +} + func TestCheckDockerResponsiveUsesServerVersion(t *testing.T) { dir := t.TempDir() logPath := filepath.Join(dir, "argv.log") @@ -147,9 +290,9 @@ func TestCheckDockerResponsiveUsesServerVersion(t *testing.T) { "@echo off\r\necho %* > \"%DOCKER_ARGV_LOG%\"\r\necho 29.5.3\r\n", ) - err := checkDockerResponsive( + _, err := checkDockerResponsive( context.Background(), - CommandSpec{Name: dockerPath, Env: []string{"DOCKER_ARGV_LOG=" + logPath}}, + CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=unix:///var/run/docker.sock", "DOCKER_CONTEXT=", "DOCKER_ARGV_LOG=" + logPath}}, defaultDockerPreflightTimeout, ) if err != nil { @@ -164,7 +307,38 @@ func TestCheckDockerResponsiveUsesServerVersion(t *testing.T) { } } -func stubDockerPreflight(t *testing.T, preflight func(context.Context, CommandSpec, time.Duration) error) func() { +func TestCheckDockerResponsiveSharesOneDeadlineAcrossProbes(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell timing fixture requires a POSIX host") + } + dir := t.TempDir() + logPath := filepath.Join(dir, "argv.log") + dockerPath := writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\nprintf '%s\\n' \"$1\" >> \"$DOCKER_ARGV_LOG\"\nsleep 0.6\nif [ \"$1\" = context ]; then printf 'unix:///var/run/docker.sock\\n'; else printf '29.5.3\\n'; fi\n", + "@exit /b 1\r\n", + ) + + _, err := checkDockerResponsive( + context.Background(), + CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=", "DOCKER_CONTEXT=", "DOCKER_ARGV_LOG=" + logPath}}, + time.Second, + ) + if err == nil || !strings.Contains(err.Error(), "docker daemon did not respond within 1s") { + t.Fatalf("error = %v", err) + } + content, readErr := os.ReadFile(logPath) + if readErr != nil { + t.Fatal(readErr) + } + if got := strings.Fields(string(content)); !reflect.DeepEqual(got, []string{"context", "version"}) { + t.Fatalf("Docker probes = %q, want context then version", got) + } +} + +func stubDockerPreflight(t *testing.T, preflight func(context.Context, CommandSpec, time.Duration) (string, error)) func() { t.Helper() previous := dockerPreflight dockerPreflight = preflight diff --git a/internal/dockerdeploy/control_admission.go b/internal/dockerdeploy/control_admission.go index cfddd13a..24647eca 100644 --- a/internal/dockerdeploy/control_admission.go +++ b/internal/dockerdeploy/control_admission.go @@ -50,7 +50,7 @@ func AwaitControlAdmissionWithNoticeV1( return nil } }, - removeContainer: runCommandWithoutDockerPreflight, + removeContainer: runCommand, }) } diff --git a/internal/dockerdeploy/control_admission_test.go b/internal/dockerdeploy/control_admission_test.go index 135e2e17..94d8d18e 100644 --- a/internal/dockerdeploy/control_admission_test.go +++ b/internal/dockerdeploy/control_admission_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/omry/reploy/internal/deploy" ) @@ -110,6 +111,49 @@ func TestAwaitControlAdmissionV1ReturnsHeldAfterEarlierRunAndKeepsLaterRunBehind } } +func TestAwaitControlAdmissionV1PreflightsRecoveredContainerCleanup(t *testing.T) { + dir := t.TempDir() + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + abandoned := liveRunAdmissionFixtureV1("run-0000000000000001", false) + if _, err := operation.AdmitLiveRunV1(abandoned, false); err != nil { + t.Fatal(err) + } + container := "demo-" + abandoned.ID + if err := operation.RecordLiveRunContainerV1(abandoned.ID, container); err != nil { + t.Fatal(err) + } + marker := controlAdmissionFixtureV1("control-0000000000000001", deploy.ControlOperationInstallV1) + lease, err := operation.AcquireControlLeaseV1(marker.ID) + if err != nil { + t.Fatal(err) + } + preflightCalls := 0 + restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) (string, error) { + preflightCalls++ + return "", errors.New("remote Docker endpoint rejected") + }) + defer restore() + + admitted, err := AwaitControlAdmissionV1(t.Context(), dir, operation, marker, false) + if err != nil { + t.Fatal(err) + } + if preflightCalls != 1 { + t.Fatalf("Docker preflight calls = %d, want 1", preflightCalls) + } + queue, _, err := admitted.ReadLiveRunQueueV1() + if err != nil || len(queue.Runs) != 1 || queue.Runs[0].ID != marker.ID || + len(queue.Cleanup) != 1 || queue.Cleanup[0].Container != container { + t.Fatalf("queue after rejected remote cleanup = %#v, %v", queue, err) + } + if err := CompleteControlAdmissionV1(admitted, marker.ID, lease); err != nil { + t.Fatal(err) + } +} + func TestAwaitControlAdmissionV1ExplainsLifecycleWait(t *testing.T) { dir := t.TempDir() operation, err := deploy.AcquireOperationLock(t.Context(), dir) diff --git a/internal/dockerdeploy/docker_endpoint.go b/internal/dockerdeploy/docker_endpoint.go new file mode 100644 index 00000000..6940a898 --- /dev/null +++ b/internal/dockerdeploy/docker_endpoint.go @@ -0,0 +1,188 @@ +package dockerdeploy + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +const dockerContextHostFormatV1 = "{{.Endpoints.docker.Host}}" +const dockerContextTargetFormatV1 = "{{.Name}}\t{{.Endpoints.docker.Host}}" + +type verifiedDockerTargetV1 struct { + Endpoint string + Context string +} + +func requireDefaultLocalDockerEndpointV1(ctx context.Context) error { + return requireLocalDockerEndpointV1(ctx, CommandSpec{Name: "docker"}, defaultDockerPreflightTimeout) +} + +func requireLocalDockerEndpointV1(ctx context.Context, spec CommandSpec, timeout time.Duration) error { + _, err := verifiedLocalDockerEndpointV1(ctx, spec, timeout) + return err +} + +func verifiedLocalDockerEndpointV1(ctx context.Context, spec CommandSpec, timeout time.Duration) (string, error) { + endpoint, source, err := effectiveDockerEndpointV1(ctx, spec, timeout) + if err != nil { + return "", err + } + if localDockerEndpointV1(endpoint) { + return endpoint, nil + } + return "", fmt.Errorf( + "remote Docker endpoint %q selected by %s is not supported; switch to a local Docker Engine or Docker Desktop context", + endpoint, + source, + ) +} + +func verifiedLocalDockerTargetV1(ctx context.Context, spec CommandSpec, timeout time.Duration) (verifiedDockerTargetV1, error) { + if spec.Name == "" { + spec.Name = "docker" + } + var target verifiedDockerTargetV1 + var source string + var err error + if contextName := commandEnvironmentValueV1(spec, "DOCKER_CONTEXT"); contextName != "" { + target, err = inspectDockerContextTargetV1(ctx, spec, timeout, contextName) + source = fmt.Sprintf("Docker context %q", contextName) + } else if endpoint := commandEnvironmentValueV1(spec, "DOCKER_HOST"); endpoint != "" { + target.Endpoint = endpoint + source = "DOCKER_HOST" + } else { + target, err = inspectDockerContextTargetV1(ctx, spec, timeout, "") + source = "the active Docker context" + } + if err != nil { + return verifiedDockerTargetV1{}, err + } + if localDockerEndpointV1(target.Endpoint) { + return target, nil + } + return verifiedDockerTargetV1{}, fmt.Errorf( + "remote Docker endpoint %q selected by %s is not supported; switch to a local Docker Engine or Docker Desktop context", + target.Endpoint, + source, + ) +} + +func pinDockerEndpointV1(spec CommandSpec, endpoint string) CommandSpec { + environment := make([]string, 0, len(spec.Env)+2) + for _, assignment := range spec.Env { + if strings.HasPrefix(assignment, "DOCKER_HOST=") || strings.HasPrefix(assignment, "DOCKER_CONTEXT=") { + continue + } + environment = append(environment, assignment) + } + spec.Env = append(environment, "DOCKER_HOST="+endpoint, "DOCKER_CONTEXT=") + return spec +} + +func effectiveDockerEndpointV1(ctx context.Context, spec CommandSpec, timeout time.Duration) (string, string, error) { + if spec.Name == "" { + spec.Name = "docker" + } + if contextName := commandEnvironmentValueV1(spec, "DOCKER_CONTEXT"); contextName != "" { + endpoint, err := inspectDockerContextEndpointV1(ctx, spec, timeout, contextName) + return endpoint, fmt.Sprintf("Docker context %q", contextName), err + } + if endpoint := commandEnvironmentValueV1(spec, "DOCKER_HOST"); endpoint != "" { + return endpoint, "DOCKER_HOST", nil + } + endpoint, err := inspectDockerContextEndpointV1(ctx, spec, timeout, "") + return endpoint, "the active Docker context", err +} + +func inspectDockerContextEndpointV1(ctx context.Context, spec CommandSpec, timeout time.Duration, contextName string) (string, error) { + probeCtx, cancel := context.WithTimeout(ctx, effectiveDockerPreflightTimeout(timeout)) + defer cancel() + args := []string{"context", "inspect", "--format", dockerContextHostFormatV1} + if contextName != "" { + args = append(args, contextName) + } + command := exec.CommandContext(probeCtx, spec.Name, args...) + command.Dir = spec.Dir + if len(spec.Env) > 0 { + command.Env = append(os.Environ(), spec.Env...) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + if probeCtx.Err() == context.DeadlineExceeded { + return "", fmt.Errorf("Docker context inspection did not respond within %s", effectiveDockerPreflightTimeout(timeout)) + } + if text := trimmedCommandOutput(strings.Join([]string{stdout.String(), stderr.String()}, "\n")); text != "" { + return "", fmt.Errorf("inspect Docker endpoint: %w\ncommand output:\n%s", err, text) + } + return "", fmt.Errorf("inspect Docker endpoint: %w", err) + } + endpoint := strings.TrimSpace(stdout.String()) + if endpoint == "" { + return "", fmt.Errorf("Docker context did not report an endpoint") + } + return endpoint, nil +} + +func inspectDockerContextTargetV1(ctx context.Context, spec CommandSpec, timeout time.Duration, contextName string) (verifiedDockerTargetV1, error) { + probeCtx, cancel := context.WithTimeout(ctx, effectiveDockerPreflightTimeout(timeout)) + defer cancel() + args := []string{"context", "inspect", "--format", dockerContextTargetFormatV1} + if contextName != "" { + args = append(args, contextName) + } + command := exec.CommandContext(probeCtx, spec.Name, args...) + command.Dir = spec.Dir + if len(spec.Env) > 0 { + command.Env = append(os.Environ(), spec.Env...) + } + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + if err := command.Run(); err != nil { + if probeCtx.Err() == context.DeadlineExceeded { + return verifiedDockerTargetV1{}, fmt.Errorf("Docker context inspection did not respond within %s", effectiveDockerPreflightTimeout(timeout)) + } + if text := trimmedCommandOutput(strings.Join([]string{stdout.String(), stderr.String()}, "\n")); text != "" { + return verifiedDockerTargetV1{}, fmt.Errorf("inspect Docker endpoint: %w\ncommand output:\n%s", err, text) + } + return verifiedDockerTargetV1{}, fmt.Errorf("inspect Docker endpoint: %w", err) + } + parts := strings.Split(strings.TrimSpace(stdout.String()), "\t") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return verifiedDockerTargetV1{}, fmt.Errorf("Docker context did not report its name and endpoint") + } + return verifiedDockerTargetV1{Context: parts[0], Endpoint: parts[1]}, nil +} + +func commandEnvironmentValueV1(spec CommandSpec, name string) string { + value := os.Getenv(name) + prefix := name + "=" + for _, assignment := range spec.Env { + if strings.HasPrefix(assignment, prefix) { + value = strings.TrimPrefix(assignment, prefix) + } + } + return strings.TrimSpace(value) +} + +func localDockerEndpointV1(endpoint string) bool { + scheme, _, found := strings.Cut(strings.TrimSpace(endpoint), ":") + if !found { + return false + } + switch strings.ToLower(scheme) { + case "unix", "npipe": + return true + default: + return false + } +} diff --git a/internal/dockerdeploy/docker_endpoint_test.go b/internal/dockerdeploy/docker_endpoint_test.go new file mode 100644 index 00000000..58fb42c8 --- /dev/null +++ b/internal/dockerdeploy/docker_endpoint_test.go @@ -0,0 +1,152 @@ +package dockerdeploy + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLocalDockerEndpointV1(t *testing.T) { + for _, test := range []struct { + endpoint string + want bool + }{ + {endpoint: "unix:///var/run/docker.sock", want: true}, + {endpoint: "unix:///home/user/.docker/desktop/docker.sock", want: true}, + {endpoint: "npipe:////./pipe/docker_engine", want: true}, + {endpoint: "npipe:////./pipe/dockerDesktopLinuxEngine", want: true}, + {endpoint: "ssh://builder.example", want: false}, + {endpoint: "tcp://builder.example:2376", want: false}, + {endpoint: "tcp://127.0.0.1:2375", want: false}, + {endpoint: "https://builder.example", want: false}, + {endpoint: "", want: false}, + } { + if got := localDockerEndpointV1(test.endpoint); got != test.want { + t.Fatalf("local Docker endpoint %q = %t, want %t", test.endpoint, got, test.want) + } + } +} + +func TestRequireLocalDockerEndpointV1RejectsDockerHost(t *testing.T) { + err := requireLocalDockerEndpointV1( + context.Background(), + CommandSpec{Name: "command-must-not-run", Env: []string{"DOCKER_HOST=ssh://builder.example", "DOCKER_CONTEXT="}}, + time.Second, + ) + if err == nil { + t.Fatal("remote DOCKER_HOST was accepted") + } + for _, want := range []string{"ssh://builder.example", "DOCKER_HOST", "not supported"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("error missing %q: %v", want, err) + } + } +} + +func TestRequireLocalDockerEndpointV1RejectsRemoteContext(t *testing.T) { + dir := t.TempDir() + dockerPath := writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\nprintf 'ssh://builder.example\\n'\n", + "@echo off\r\necho ssh://builder.example\r\n", + ) + err := requireLocalDockerEndpointV1( + context.Background(), + CommandSpec{Name: dockerPath, Env: []string{"DOCKER_CONTEXT=remote", "DOCKER_HOST=tcp://ignored.example:2376"}}, + time.Second, + ) + if err == nil { + t.Fatal("remote Docker context was accepted") + } + if !strings.Contains(err.Error(), `Docker context "remote"`) { + t.Fatalf("error does not identify context: %v", err) + } +} + +func TestEffectiveDockerEndpointV1InspectsActiveContext(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "argv.log") + dockerPath := writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\nprintf '%s\\n' \"$*\" > \"$DOCKER_ARGV_LOG\"\nprintf 'unix:///var/run/docker.sock\\n'\n", + "@echo off\r\necho %* > \"%DOCKER_ARGV_LOG%\"\r\necho npipe:////./pipe/docker_engine\r\n", + ) + endpoint, source, err := effectiveDockerEndpointV1( + context.Background(), + CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=", "DOCKER_CONTEXT=", "DOCKER_ARGV_LOG=" + logPath}}, + time.Second, + ) + if err != nil { + t.Fatal(err) + } + if !localDockerEndpointV1(endpoint) || source != "the active Docker context" { + t.Fatalf("endpoint = %q from %q", endpoint, source) + } + content, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + want := "context inspect --format " + dockerContextHostFormatV1 + if strings.TrimSpace(string(content)) != want { + t.Fatalf("Docker context argv = %q, want %q", strings.TrimSpace(string(content)), want) + } +} + +func TestEffectiveDockerEndpointV1IgnoresSuccessfulContextWarnings(t *testing.T) { + dir := t.TempDir() + dockerPath := writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\nprintf 'configuration warning\\n' >&2\nprintf 'unix:///var/run/docker.sock\\n'\n", + "@echo off\r\necho configuration warning 1>&2\r\necho npipe:////./pipe/docker_engine\r\n", + ) + endpoint, source, err := effectiveDockerEndpointV1( + context.Background(), + CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=", "DOCKER_CONTEXT="}}, + time.Second, + ) + if err != nil { + t.Fatal(err) + } + if !localDockerEndpointV1(endpoint) || source != "the active Docker context" { + t.Fatalf("endpoint = %q from %q", endpoint, source) + } +} + +func TestRequireDefaultLocalDockerEndpointV1RevalidatesActiveContext(t *testing.T) { + dir := t.TempDir() + endpointPath := filepath.Join(dir, "endpoint") + writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\ncat \"$DOCKER_ENDPOINT_FILE\"\n", + "@echo off\r\ntype \"%DOCKER_ENDPOINT_FILE%\"\r\n", + ) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + t.Setenv("DOCKER_CONTEXT", "") + t.Setenv("DOCKER_HOST", "") + t.Setenv("DOCKER_ENDPOINT_FILE", endpointPath) + + if err := os.WriteFile(endpointPath, []byte("unix:///var/run/docker.sock\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := requireDefaultLocalDockerEndpointV1(context.Background()); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(endpointPath, []byte("ssh://builder.example\n"), 0o600); err != nil { + t.Fatal(err) + } + err := requireDefaultLocalDockerEndpointV1(context.Background()) + if err == nil || !strings.Contains(err.Error(), "remote Docker endpoint") { + t.Fatalf("changed active context was not rejected: %v", err) + } +} diff --git a/internal/dockerdeploy/execution_plan.go b/internal/dockerdeploy/execution_plan.go index 78d0c696..12eb8b23 100644 --- a/internal/dockerdeploy/execution_plan.go +++ b/internal/dockerdeploy/execution_plan.go @@ -332,16 +332,12 @@ func planRuntimeUser(document blueprint.Document, context DockerPlanContext) (Ru plan.Warnings = append(plan.Warnings, fmt.Sprintf("current-user install overrides the image user with local account %q (UID/GID %d:%d)", plan.LocalUser, context.UID, context.GID), ) - if context.UID == 0 { - plan.Warnings = append(plan.Warnings, rootRuntimeIdentityWarningV1) - } else { + if context.UID != 0 { plan.Warnings = append(plan.Warnings, "the image must tolerate an arbitrary non-root identity and may write persistently only to declared writable paths") } if document.Environment.Install.System.Account.User != "" || document.Environment.Install.System.Account.Group != "" { plan.Warnings = append(plan.Warnings, "environment.install.system.account does not apply to current-user scope") } - } else if context.UID == 0 { - plan.Warnings = append(plan.Warnings, rootRuntimeIdentityWarningV1) } return plan, nil } @@ -352,22 +348,16 @@ func planRuntimeUser(document blueprint.Document, context DockerPlanContext) (Ru if context.SystemUser == "" || context.SystemGroup == "" || context.UID < 0 || context.GID < 0 { return RuntimeUserPlan{}, fmt.Errorf("system Docker plan requires resolved service account and numeric UID/GID") } - plan := RuntimeUserPlan{ + return RuntimeUserPlan{ User: context.SystemUser, Group: context.SystemGroup, UID: context.UID, GID: context.GID, SupplementaryGIDs: supplementaryGIDs, DockerUser: strconv.Itoa(context.UID) + ":" + strconv.Itoa(context.GID), LocalUser: runtimeLocalUserNameV1(document.Environment.Runtime.User, context.UID), - } - if context.UID == 0 { - plan.Warnings = append(plan.Warnings, rootRuntimeIdentityWarningV1) - } - return plan, nil + }, nil } return RuntimeUserPlan{}, fmt.Errorf("cannot resolve Docker runtime user") } -const rootRuntimeIdentityWarningV1 = "the application will run as root inside its container; root can bypass application-level file permissions, while Docker access, Linux capabilities, host filesystem access, and network access remain limited by the effective Reploy sandbox policy" - func runtimeLocalUserNameV1(configured string, uid int) string { if uid == 0 { return "root" diff --git a/internal/dockerdeploy/execution_plan_test.go b/internal/dockerdeploy/execution_plan_test.go index 43d942e9..dd12fc3f 100644 --- a/internal/dockerdeploy/execution_plan_test.go +++ b/internal/dockerdeploy/execution_plan_test.go @@ -168,7 +168,7 @@ func TestPlanRuntimeUserScopePolicy(t *testing.T) { if root.LocalUser != "root" || root.DockerUser != "0:0" { t.Fatalf("root plan = %#v", root) } - if len(root.Warnings) != 1 || !strings.Contains(root.Warnings[0], "run as root inside its container") { + if len(root.Warnings) != 0 { t.Fatalf("root warnings = %#v", root.Warnings) } scope = blueprint.InstallScopeUser @@ -178,8 +178,8 @@ func TestPlanRuntimeUserScopePolicy(t *testing.T) { if err != nil { t.Fatal(err) } - if len(root.Warnings) != 3 || strings.Contains(strings.Join(root.Warnings, "\n"), "non-root identity") || - !strings.Contains(strings.Join(root.Warnings, "\n"), "run as root inside its container") { + if len(root.Warnings) != 2 || strings.Contains(strings.Join(root.Warnings, "\n"), "non-root identity") || + strings.Contains(strings.Join(root.Warnings, "\n"), "run as root inside its container") { t.Fatalf("root current-user warnings = %#v", root.Warnings) } } diff --git a/internal/dockerdeploy/full_validation_python_profile.go b/internal/dockerdeploy/full_validation_python_profile.go index 2d609394..73df0b84 100644 --- a/internal/dockerdeploy/full_validation_python_profile.go +++ b/internal/dockerdeploy/full_validation_python_profile.go @@ -113,7 +113,7 @@ func (session *ImageValidationSession) runPythonInterpreterInspection(ctx contex args = append(args, inspection...) var stdout bytes.Buffer var stderr bytes.Buffer - if err := runImageValidationFollowupCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{ + if err := session.runDockerCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{ Context: ctx, Stdout: &stdout, Stderr: &stderr, }); err != nil { return "", imageValidationCommandError("Python interpreter inspection", session.descriptor.Platform.Canonical, stderr.String(), err) diff --git a/internal/dockerdeploy/installed_service_container.go b/internal/dockerdeploy/installed_service_container.go index a987d640..98d38b51 100644 --- a/internal/dockerdeploy/installed_service_container.go +++ b/internal/dockerdeploy/installed_service_container.go @@ -14,6 +14,8 @@ import ( "github.com/omry/reploy/internal/providerstore" ) +var bindInstalledServiceCommandRunner = bindDockerCommandRunnerV1 + // RunInstalledServiceContainerV1 is the system-service container boundary. It // deliberately bypasses the public control surface because systemd itself is // already the admitted host-service operation. @@ -98,6 +100,10 @@ func RunInstalledServiceContainerV1(ctx context.Context, deploymentDir string, a start.Name = dockerPath cleanup := composeCommandWithProject(deploymentDir, installation.ComposeProject, "down", "--remove-orphans") cleanup.Name = dockerPath + runDocker, err := bindInstalledServiceCommandRunner(ctx, start, options.DockerPreflightTimeout) + if err != nil { + return fmt.Errorf("bind installed service Docker endpoint: %w", err) + } if err := startAndInjectPrivateWorkloadEnvironmentV1( ctx, start, @@ -105,12 +111,12 @@ func RunInstalledServiceContainerV1(ctx context.Context, deploymentDir string, a plan.Docker.ContainerName, environment, options, - runCommandWithoutDockerPreflight, + runDocker, ); err != nil { return err } if err := notifyInstalledServiceReadyV1(); err != nil { - return errors.Join(err, cleanupPrivateWorkloadContainerV1(cleanup, RunOptions{Context: context.WithoutCancel(ctx)}, runCommandWithoutDockerPreflight)) + return errors.Join(err, cleanupPrivateWorkloadContainerV1(cleanup, RunOptions{Context: context.WithoutCancel(ctx)}, runDocker)) } if err := operation.Unlock(); err != nil { return err @@ -123,7 +129,7 @@ func RunInstalledServiceContainerV1(ctx context.Context, deploymentDir string, a waitOptions.Stdin = nil waitOptions.Stdout = &status waitOptions.Stderr = options.Stderr - if err := runCommandWithoutDockerPreflight(CommandSpec{ + if err := runDocker(CommandSpec{ Name: dockerPath, Args: []string{"wait", plan.Docker.ContainerName}, }, waitOptions); err != nil { diff --git a/internal/dockerdeploy/live_run_admission.go b/internal/dockerdeploy/live_run_admission.go index 9ac120d3..17621934 100644 --- a/internal/dockerdeploy/live_run_admission.go +++ b/internal/dockerdeploy/live_run_admission.go @@ -52,7 +52,7 @@ func AwaitLiveRunAdmissionWithNoticeV1( return nil } }, - removeContainer: runCommandWithoutDockerPreflight, + removeContainer: runCommand, }) } diff --git a/internal/dockerdeploy/live_run_admission_test.go b/internal/dockerdeploy/live_run_admission_test.go index 9dc200e4..f983eaf7 100644 --- a/internal/dockerdeploy/live_run_admission_test.go +++ b/internal/dockerdeploy/live_run_admission_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/omry/reploy/internal/deploy" ) @@ -288,3 +289,46 @@ func TestAwaitLiveRunAdmissionV1RecoversAbandonedControlMarker(t *testing.T) { t.Fatal(err) } } + +func TestAwaitLiveRunAdmissionV1PreflightsRecoveredContainerCleanup(t *testing.T) { + dir := t.TempDir() + operation, err := deploy.AcquireOperationLock(t.Context(), dir) + if err != nil { + t.Fatal(err) + } + abandoned := liveRunAdmissionFixtureV1("run-0000000000000001", false) + if _, err := operation.AdmitLiveRunV1(abandoned, false); err != nil { + t.Fatal(err) + } + container := "demo-" + abandoned.ID + if err := operation.RecordLiveRunContainerV1(abandoned.ID, container); err != nil { + t.Fatal(err) + } + candidate := liveRunAdmissionFixtureV1("run-0000000000000002", false) + holdLiveRunLeaseV1(t, operation, candidate.ID) + preflightCalls := 0 + restore := stubDockerPreflight(t, func(context.Context, CommandSpec, time.Duration) (string, error) { + preflightCalls++ + return "", errors.New("remote Docker endpoint rejected") + }) + defer restore() + + admitted, err := AwaitLiveRunAdmissionV1(t.Context(), dir, operation, candidate, false) + if err != nil { + t.Fatal(err) + } + if preflightCalls != 1 { + t.Fatalf("Docker preflight calls = %d, want 1", preflightCalls) + } + queue, _, err := admitted.ReadLiveRunQueueV1() + if err != nil || len(queue.Runs) != 1 || queue.Runs[0].ID != candidate.ID || + len(queue.Cleanup) != 1 || queue.Cleanup[0].Container != container { + t.Fatalf("queue after rejected remote cleanup = %#v, %v", queue, err) + } + if _, removed, err := admitted.RemoveLiveRunV1(candidate.ID); err != nil || !removed { + t.Fatalf("remove admitted run = %t, %v", removed, err) + } + if err := admitted.Unlock(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/dockerdeploy/live_run_container.go b/internal/dockerdeploy/live_run_container.go index 685dc97e..fc235d22 100644 --- a/internal/dockerdeploy/live_run_container.go +++ b/internal/dockerdeploy/live_run_container.go @@ -5,12 +5,14 @@ import ( "errors" "fmt" "path/filepath" + "time" "github.com/omry/reploy/internal/deploy" ) type admittedTransientContainerBackendV1 struct { acquire func(context.Context, string) (*deploy.OperationLock, error) + bind func(context.Context, CommandSpec, time.Duration) (commandRunner, error) create commandRunner followup temporaryCommandRunner runTemporary func(temporaryCommandRunner, CommandSpec, CommandSpec, RunOptions) error @@ -31,8 +33,9 @@ func RunAdmittedTransientContainerV1( ) error { return runAdmittedTransientContainerV1(ctx, deploymentDir, operation, runID, execution, options, admittedTransientContainerBackendV1{ acquire: deploy.AcquireOperationLock, + bind: bindDockerCommandRunnerV1, create: runCommand, - followup: runCommandWithoutDockerPreflight, + followup: runDockerCommand, runTemporary: runTemporaryContainerCommand, }) } @@ -77,6 +80,14 @@ func runAdmittedTransientContainerV1( createOptions.Stdin = nil createOptions.Stdout = nil createOptions.Stderr = nil + if backend.bind != nil { + run, err := backend.bind(ctx, execution.Create, options.DockerPreflightTimeout) + if err != nil { + return removeAdmittedTransientBeforeCreateV1(operation, runID, fmt.Errorf("bind admitted transient Docker endpoint: %w", err)) + } + backend.create = run + backend.followup = temporaryCommandRunner(run) + } if err := backend.create(execution.Create, createOptions); err != nil { return abortAdmittedTransientBeforeStartV1(context.WithoutCancel(ctx), operation, runID, execution, options, backend, fmt.Errorf("create admitted transient container: %w", err)) diff --git a/internal/dockerdeploy/live_run_container_test.go b/internal/dockerdeploy/live_run_container_test.go index 873f5da6..8941485c 100644 --- a/internal/dockerdeploy/live_run_container_test.go +++ b/internal/dockerdeploy/live_run_container_test.go @@ -7,6 +7,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/omry/reploy/internal/deploy" ) @@ -95,6 +96,50 @@ func TestRunAdmittedTransientContainerV1ReleasesLockForExecutionAndCompletesQueu } } +func TestRunAdmittedTransientContainerV1UsesOneBoundDockerRunner(t *testing.T) { + dir := t.TempDir() + operation, run, execution := admittedTransientFixtureV1(t, dir) + boundCalls := []CommandSpec{} + bindCalls := 0 + backend := admittedTransientContainerBackendV1{ + acquire: deploy.AcquireOperationLock, + bind: func(ctx context.Context, spec CommandSpec, timeout time.Duration) (commandRunner, error) { + bindCalls++ + if ctx != t.Context() || !reflect.DeepEqual(spec, execution.Create) || timeout != 7*time.Second { + t.Fatalf("bind input = %#v / %s", spec, timeout) + } + return func(spec CommandSpec, _ RunOptions) error { + boundCalls = append(boundCalls, spec) + return nil + }, nil + }, + create: func(CommandSpec, RunOptions) error { + t.Fatal("unbound create runner used") + return nil + }, + followup: func(CommandSpec, RunOptions) error { + t.Fatal("unbound follow-up runner used") + return nil + }, + runTemporary: func(run temporaryCommandRunner, start CommandSpec, cleanup CommandSpec, options RunOptions) error { + if err := run(start, options); err != nil { + return err + } + return run(cleanup, options) + }, + } + if err := runAdmittedTransientContainerV1( + t.Context(), dir, operation, run.ID, execution, + RunOptions{DockerPreflightTimeout: 7 * time.Second}, + backend, + ); err != nil { + t.Fatal(err) + } + if bindCalls != 1 || !reflect.DeepEqual(boundCalls, []CommandSpec{execution.Create, execution.Start, execution.Cleanup}) { + t.Fatalf("bind calls = %d, commands = %#v", bindCalls, boundCalls) + } +} + func TestRunAdmittedTransientContainerV1CreateFailureCleansQueueAndLock(t *testing.T) { dir := t.TempDir() operation, run, execution := admittedTransientFixtureV1(t, dir) diff --git a/internal/dockerdeploy/platform.go b/internal/dockerdeploy/platform.go index 0547210a..17116a89 100644 --- a/internal/dockerdeploy/platform.go +++ b/internal/dockerdeploy/platform.go @@ -268,9 +268,16 @@ func detectDockerRuntime(ctx context.Context, spec CommandSpec, timeout time.Dur if spec.Name == "" { spec.Name = "docker" } - probeCtx, cancel := context.WithTimeout(ctx, effectiveDockerPreflightTimeout(timeout)) + timeout = effectiveDockerPreflightTimeout(timeout) + probeCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + endpoint, err := verifiedLocalDockerEndpointV1(probeCtx, spec, timeout) + if err != nil { + return dockerRuntimeInfo{}, err + } + spec = pinDockerEndpointV1(spec, endpoint) + command := exec.CommandContext(probeCtx, spec.Name, "info", "--format", "{{json .}}") command.Dir = spec.Dir if len(spec.Env) > 0 { @@ -281,7 +288,7 @@ func detectDockerRuntime(ctx context.Context, spec CommandSpec, timeout time.Dur command.Stderr = &output if err := command.Run(); err != nil { if probeCtx.Err() == context.DeadlineExceeded { - return dockerRuntimeInfo{}, fmt.Errorf("docker info did not respond within %s", effectiveDockerPreflightTimeout(timeout)) + return dockerRuntimeInfo{}, fmt.Errorf("docker info did not respond within %s", timeout) } if text := trimmedCommandOutput(output.String()); text != "" { return dockerRuntimeInfo{}, fmt.Errorf("docker runtime check failed: %w\ncommand output:\n%s", err, text) diff --git a/internal/dockerdeploy/platform_test.go b/internal/dockerdeploy/platform_test.go index 130ddd17..00134253 100644 --- a/internal/dockerdeploy/platform_test.go +++ b/internal/dockerdeploy/platform_test.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + "reflect" + "runtime" "strings" "testing" "time" @@ -406,7 +408,7 @@ func TestDetectDockerRuntimeDetectsDockerDesktop(t *testing.T) { "#!/bin/sh\nprintf '{\"OperatingSystem\":\"Docker Desktop\",\"ServerVersion\":\"29.5.3\"}\\n'\n", "@echo off\r\necho {\"OperatingSystem\":\"Docker Desktop\",\"ServerVersion\":\"29.5.3\"}\r\n", ) - info, err := detectDockerRuntime(context.Background(), CommandSpec{Name: dockerPath}, time.Second) + info, err := detectDockerRuntime(context.Background(), CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=unix:///var/run/docker.sock", "DOCKER_CONTEXT="}}, time.Second) if err != nil { t.Fatal(err) } @@ -427,7 +429,7 @@ func TestDetectDockerRuntimeDetectsLinuxEngine(t *testing.T) { "#!/bin/sh\nprintf '{\"OperatingSystem\":\"Ubuntu 24.04\",\"ServerVersion\":\"29.5.3\"}\\n'\n", "@echo off\r\necho {\"OperatingSystem\":\"Ubuntu 24.04\",\"ServerVersion\":\"29.5.3\"}\r\n", ) - info, err := detectDockerRuntime(context.Background(), CommandSpec{Name: dockerPath}, time.Second) + info, err := detectDockerRuntime(context.Background(), CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=unix:///var/run/docker.sock", "DOCKER_CONTEXT="}}, time.Second) if err != nil { t.Fatal(err) } @@ -436,6 +438,37 @@ func TestDetectDockerRuntimeDetectsLinuxEngine(t *testing.T) { } } +func TestDetectDockerRuntimeSharesOneDeadlineAcrossProbes(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell timing fixture requires a POSIX host") + } + dir := t.TempDir() + logPath := filepath.Join(dir, "argv.log") + dockerPath := writeFakeCommand( + t, + dir, + "docker", + "#!/bin/sh\nprintf '%s\\n' \"$1\" >> \"$DOCKER_ARGV_LOG\"\nsleep 0.6\nif [ \"$1\" = context ]; then printf 'unix:///var/run/docker.sock\\n'; else printf '{\"OperatingSystem\":\"Ubuntu 24.04\",\"ServerVersion\":\"29.5.3\"}\\n'; fi\n", + "@exit /b 1\r\n", + ) + + _, err := detectDockerRuntime( + context.Background(), + CommandSpec{Name: dockerPath, Env: []string{"DOCKER_HOST=", "DOCKER_CONTEXT=", "DOCKER_ARGV_LOG=" + logPath}}, + time.Second, + ) + if err == nil || !strings.Contains(err.Error(), "docker info did not respond within 1s") { + t.Fatalf("error = %v", err) + } + content, readErr := os.ReadFile(logPath) + if readErr != nil { + t.Fatal(readErr) + } + if got := strings.Fields(string(content)); !reflect.DeepEqual(got, []string{"context", "info"}) { + t.Fatalf("Docker probes = %q, want context then info", got) + } +} + func stubHostPlatform(t *testing.T, platform hostPlatform) func() { t.Helper() previous := detectHostPlatform diff --git a/internal/dockerdeploy/private_workload_environment_integration_test.go b/internal/dockerdeploy/private_workload_environment_integration_test.go index d567808f..92aee16a 100644 --- a/internal/dockerdeploy/private_workload_environment_integration_test.go +++ b/internal/dockerdeploy/private_workload_environment_integration_test.go @@ -117,7 +117,7 @@ printf 'private-mask-pass\n'`, expectedTokenDigest) container, environment, RunOptions{}, - runCommandWithoutDockerPreflight, + runDockerCommand, ); err != nil { t.Fatal(err) } diff --git a/internal/dockerdeploy/private_workload_environment_test.go b/internal/dockerdeploy/private_workload_environment_test.go index 4678e304..50a49845 100644 --- a/internal/dockerdeploy/private_workload_environment_test.go +++ b/internal/dockerdeploy/private_workload_environment_test.go @@ -440,7 +440,7 @@ func TestPrivateWorkloadEnvironmentRealDockerIsolation(t *testing.T) { container := "reploy-private-env-test-" + strconv.Itoa(os.Getpid()) run := func(spec CommandSpec, options RunOptions) error { options.Context = t.Context() - return runCommandWithoutDockerPreflight(spec, options) + return runDockerCommand(spec, options) } remove := func() { _ = exec.CommandContext(context.Background(), "docker", "rm", "--force", container).Run() diff --git a/internal/dockerdeploy/probe_run.go b/internal/dockerdeploy/probe_run.go index f7577c30..63670d35 100644 --- a/internal/dockerdeploy/probe_run.go +++ b/internal/dockerdeploy/probe_run.go @@ -22,12 +22,13 @@ type ImageValidationSession struct { workspace PreparedProbeWorkspace aptWorkspace *PreparedAPTResolverWorkspace containerName string + runDocker commandRunner aptBase *APTBaseValidation closed bool } -var runImageValidationOpenCommand = runCommand -var runImageValidationFollowupCommand = runCommandWithoutDockerPreflight +var bindImageValidationCommandRunner = bindDockerCommandRunnerV1 +var runImageValidationFollowupCommand = runDockerCommand // OpenImageValidationSession starts one held, networkless container for full // final-image or additive layer validation. Its public operations remain @@ -73,27 +74,38 @@ func openImageValidationSession( } var stdout bytes.Buffer var stderr bytes.Buffer - if err := runImageValidationOpenCommand(spec, RunOptions{Context: context.WithoutCancel(ctx), Stdout: &stdout, Stderr: &stderr}); err != nil { + runDocker, err := bindImageValidationCommandRunner(context.WithoutCancel(ctx), spec, 0) + if err != nil { + return nil, imageValidationCommandError("create", descriptor.Platform.Canonical, stderr.String(), err) + } + if err := runDocker(spec, RunOptions{Context: context.WithoutCancel(ctx), Stdout: &stdout, Stderr: &stderr}); err != nil { return nil, imageValidationCommandError("create", descriptor.Platform.Canonical, stderr.String(), err) } if err := ctx.Err(); err != nil { - cleanupErr := removeImageValidationContainer(context.WithoutCancel(ctx), containerName) + cleanupErr := removeImageValidationContainer(context.WithoutCancel(ctx), containerName, runDocker) return nil, errors.Join(fmt.Errorf("open image validation session: %w", err), cleanupErr) } stderr.Reset() - if err := runImageValidationFollowupCommand( + if err := runDocker( CommandSpec{Name: "docker", Args: []string{"start", containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}, ); err != nil { startErr := imageValidationCommandError("start", descriptor.Platform.Canonical, stderr.String(), err) - cleanupErr := removeImageValidationContainer(context.WithoutCancel(ctx), containerName) + cleanupErr := removeImageValidationContainer(context.WithoutCancel(ctx), containerName, runDocker) return nil, errors.Join(startErr, cleanupErr) } return &ImageValidationSession{ - descriptor: descriptor, workspace: workspace, aptWorkspace: aptWorkspace, containerName: containerName, + descriptor: descriptor, workspace: workspace, aptWorkspace: aptWorkspace, containerName: containerName, runDocker: runDocker, }, nil } +func (session *ImageValidationSession) runDockerCommand(spec CommandSpec, options RunOptions) error { + if session.runDocker != nil { + return session.runDocker(spec, options) + } + return runImageValidationFollowupCommand(spec, options) +} + // ProbeAPTBaseProfile reproduces the same canonical APT base facts used by // resolution inside this already-held networkless validation container. func (session *ImageValidationSession) ProbeAPTBaseProfile(ctx context.Context) (APTBaseValidation, error) { @@ -139,7 +151,7 @@ func (session *ImageValidationSession) runAPTProfileCommand(ctx context.Context, args = append(args, arguments...) var stdout bytes.Buffer var stderr bytes.Buffer - if err := runImageValidationFollowupCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{ + if err := session.runDockerCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{ Context: ctx, Stdout: &stdout, Stderr: &stderr, }); err != nil { return nil, imageValidationCommandError("APT profile", session.descriptor.Platform.Canonical, stderr.String(), err) @@ -172,7 +184,7 @@ func (session *ImageValidationSession) Probe(ctx context.Context, request probe. "exec", "--interactive", "--user", "0:0", "--workdir", "/", session.containerName, session.workspace.ContainerExecutable, }} - if err := runImageValidationFollowupCommand(spec, RunOptions{ + if err := session.runDockerCommand(spec, RunOptions{ Context: ctx, Stdin: bytes.NewReader(encoded), Stdout: &stdout, Stderr: &stderr, }); err != nil { return probe.ResponseV1{}, imageValidationCommandError("probe", session.descriptor.Platform.Canonical, stderr.String(), err) @@ -217,7 +229,7 @@ func (session *ImageValidationSession) QueryDPKGOwners(ctx context.Context, path args = append(args, paths...) var stdout bytes.Buffer var stderr bytes.Buffer - if err := runImageValidationFollowupCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{ + if err := session.runDockerCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{ Context: ctx, Stdout: &stdout, Stderr: &stderr, }); err != nil { output := trimmedCommandOutput(stderr.String()) @@ -262,7 +274,7 @@ func (session *ImageValidationSession) QueryDPKGPackageState(ctx context.Context args = append(args, names...) var stdout bytes.Buffer var stderr bytes.Buffer - if err := runImageValidationFollowupCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{ + if err := session.runDockerCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{ Context: ctx, Stdout: &stdout, Stderr: &stderr, }); err != nil { output := trimmedCommandOutput(stderr.String()) @@ -295,7 +307,7 @@ func (session *ImageValidationSession) QueryAlternative(ctx context.Context, gro "exec", "--user", "0:0", "--workdir", "/", session.containerName, "/usr/bin/update-alternatives", "--query", group, }} - if err := runImageValidationFollowupCommand(spec, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := session.runDockerCommand(spec, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { output := trimmedCommandOutput(stderr.String()) if output != "" { return nil, fmt.Errorf("query image alternative %q: %w\ncommand output:\n%s", group, err, output) @@ -324,7 +336,7 @@ func (session *ImageValidationSession) ValidateBuildScratchAbsent(ctx context.Co "exec", "--user", "0:0", "--workdir", "/", session.containerName, "/bin/sh", "-c", `test ! -e "$1"`, "reploy-validation", "/.reploy-build", }} - if err := runImageValidationFollowupCommand(spec, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := session.runDockerCommand(spec, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { return fmt.Errorf("image validation requires /.reploy-build to be absent: %w", imageValidationCommandError("build-scratch absence", session.descriptor.Platform.Canonical, stderr.String(), err)) } return nil @@ -365,7 +377,7 @@ func (session *ImageValidationSession) Close(ctx context.Context) error { if ctx == nil { return fmt.Errorf("close image validation session context is required") } - if err := removeImageValidationContainer(ctx, session.containerName); err != nil { + if err := removeImageValidationContainer(ctx, session.containerName, session.runDockerCommand); err != nil { return err } session.closed = true @@ -463,10 +475,10 @@ func validatePreparedProbeWorkspaceShape(workspace PreparedProbeWorkspace) error return nil } -func removeImageValidationContainer(ctx context.Context, containerName string) error { +func removeImageValidationContainer(ctx context.Context, containerName string, runDocker commandRunner) error { var stdout bytes.Buffer var stderr bytes.Buffer - if err := runImageValidationFollowupCommand( + if err := runDocker( CommandSpec{Name: "docker", Args: []string{"rm", "--force", containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}, ); err != nil { diff --git a/internal/dockerdeploy/probe_run_test.go b/internal/dockerdeploy/probe_run_test.go index c6bd4f18..68027b16 100644 --- a/internal/dockerdeploy/probe_run_test.go +++ b/internal/dockerdeploy/probe_run_test.go @@ -9,6 +9,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/canonical" @@ -131,36 +132,30 @@ func TestAPTImageValidationSessionReusesHeldContainerAndCachesProfile(t *testing []byte("amd64\n"), {}, } - previousOpen := runImageValidationOpenCommand - previousFollowup := runImageValidationFollowupCommand - t.Cleanup(func() { - runImageValidationOpenCommand = previousOpen - runImageValidationFollowupCommand = previousFollowup - }) + previousBind := bindImageValidationCommandRunner + t.Cleanup(func() { bindImageValidationCommandRunner = previousBind }) commands := []CommandSpec{} profileIndex := 0 - runImageValidationOpenCommand = func(spec CommandSpec, _ RunOptions) error { - commands = append(commands, spec) - return nil - } - runImageValidationFollowupCommand = func(spec CommandSpec, options RunOptions) error { - commands = append(commands, spec) - if len(spec.Args) == 0 || spec.Args[0] != "exec" { - return nil - } - if spec.Args[len(spec.Args)-1] == ProbeContainerExecutable { - if _, err := io.ReadAll(options.Stdin); err != nil { - return err + bindImageValidationCommandRunner = func(context.Context, CommandSpec, time.Duration) (commandRunner, error) { + return func(spec CommandSpec, options RunOptions) error { + commands = append(commands, spec) + if len(spec.Args) == 0 || spec.Args[0] != "exec" { + return nil + } + if spec.Args[len(spec.Args)-1] == ProbeContainerExecutable { + if _, err := io.ReadAll(options.Stdin); err != nil { + return err + } + _, _ = options.Stdout.Write(mustCanonicalProbeResponse(t, aptBaseProbeResponse())) + return nil + } + if profileIndex >= len(profileOutputs) { + t.Fatalf("unexpected profile command: %#v", spec.Args) } - _, _ = options.Stdout.Write(mustCanonicalProbeResponse(t, aptBaseProbeResponse())) + _, _ = options.Stdout.Write(profileOutputs[profileIndex]) + profileIndex++ return nil - } - if profileIndex >= len(profileOutputs) { - t.Fatalf("unexpected profile command: %#v", spec.Args) - } - _, _ = options.Stdout.Write(profileOutputs[profileIndex]) - profileIndex++ - return nil + }, nil } session, err := OpenAPTImageValidationSession(context.Background(), descriptor, probeWorkspace, aptWorkspace) if err != nil { @@ -318,28 +313,29 @@ func TestOpenImageValidationSessionCleansFailedStartAndExplainsEmulation(t *test func TestOpenImageValidationSessionFinishesCreateBeforeHonoringCancellation(t *testing.T) { descriptor := testProbeImageDescriptor(t, "linux/amd64") workspace := testPreparedProbeWorkspace(t, descriptor.Platform, t.TempDir()) - previousOpen := runImageValidationOpenCommand - previousFollowup := runImageValidationFollowupCommand - t.Cleanup(func() { - runImageValidationOpenCommand = previousOpen - runImageValidationFollowupCommand = previousFollowup - }) + previousBind := bindImageValidationCommandRunner + t.Cleanup(func() { bindImageValidationCommandRunner = previousBind }) ctx, cancel := context.WithCancel(context.Background()) removed := false - runImageValidationOpenCommand = func(_ CommandSpec, options RunOptions) error { - cancel() - if err := options.Context.Err(); err != nil { - t.Fatalf("Docker create inherited cancellation: %v", err) + bindImageValidationCommandRunner = func(bindCtx context.Context, _ CommandSpec, _ time.Duration) (commandRunner, error) { + if err := bindCtx.Err(); err != nil { + t.Fatalf("Docker endpoint binding inherited cancellation: %v", err) } - return nil - } - runImageValidationFollowupCommand = func(spec CommandSpec, _ RunOptions) error { - if len(spec.Args) == 3 && spec.Args[0] == "rm" && spec.Args[1] == "--force" { - removed = true + return func(spec CommandSpec, options RunOptions) error { + if len(spec.Args) != 0 && spec.Args[0] == "create" { + cancel() + if err := options.Context.Err(); err != nil { + t.Fatalf("Docker create inherited cancellation: %v", err) + } + return nil + } + if len(spec.Args) == 3 && spec.Args[0] == "rm" && spec.Args[1] == "--force" { + removed = true + return nil + } + t.Fatalf("unexpected follow-up command: %#v", spec) return nil - } - t.Fatalf("unexpected follow-up command: %#v", spec) - return nil + }, nil } if _, err := OpenImageValidationSession(ctx, descriptor, workspace); !errors.Is(err, context.Canceled) { t.Fatalf("error = %v", err) @@ -356,11 +352,11 @@ func TestRunImageProbeRejectsMismatchedPlatformBeforeDocker(t *testing.T) { t.Fatal(err) } workspace := testPreparedProbeWorkspace(t, other, t.TempDir()) - previous := runImageValidationOpenCommand - t.Cleanup(func() { runImageValidationOpenCommand = previous }) - runImageValidationOpenCommand = func(CommandSpec, RunOptions) error { + previous := bindImageValidationCommandRunner + t.Cleanup(func() { bindImageValidationCommandRunner = previous }) + bindImageValidationCommandRunner = func(context.Context, CommandSpec, time.Duration) (commandRunner, error) { t.Fatal("mismatched platform reached Docker") - return nil + return nil, nil } request := probe.RequestV1{Schema: probe.RequestSchemaV1, Inspections: []probe.ExecutableInspectionV1{}} if _, err := RunImageProbe(context.Background(), descriptor, workspace, request); err == nil || !strings.Contains(err.Error(), "does not match") { @@ -373,42 +369,38 @@ var recordedImageValidationStdin []byte func stubImageValidationCommands(t *testing.T, response []byte, startErr error) func() { t.Helper() - previousOpen := runImageValidationOpenCommand - previousFollowup := runImageValidationFollowupCommand + previousBind := bindImageValidationCommandRunner recordedImageValidationCommands = nil recordedImageValidationStdin = nil - runImageValidationOpenCommand = func(spec CommandSpec, _ RunOptions) error { - recordedImageValidationCommands = append(recordedImageValidationCommands, spec) - return nil - } - runImageValidationFollowupCommand = func(spec CommandSpec, options RunOptions) error { - recordedImageValidationCommands = append(recordedImageValidationCommands, spec) - if len(spec.Args) == 0 { - return errors.New("empty Docker command") - } - switch spec.Args[0] { - case "start": - if startErr != nil { - _, _ = options.Stderr.Write([]byte("exec /.reploy-validation/reploy-probe: exec format error\n")) - return startErr + bindImageValidationCommandRunner = func(context.Context, CommandSpec, time.Duration) (commandRunner, error) { + return func(spec CommandSpec, options RunOptions) error { + recordedImageValidationCommands = append(recordedImageValidationCommands, spec) + if len(spec.Args) == 0 { + return errors.New("empty Docker command") } - case "exec": - if options.Stdin != nil { - var input bytes.Buffer - if _, err := input.ReadFrom(options.Stdin); err != nil { + switch spec.Args[0] { + case "start": + if startErr != nil { + _, _ = options.Stderr.Write([]byte("exec /.reploy-validation/reploy-probe: exec format error\n")) + return startErr + } + case "exec": + if options.Stdin != nil { + var input bytes.Buffer + if _, err := input.ReadFrom(options.Stdin); err != nil { + return err + } + recordedImageValidationStdin = append(recordedImageValidationStdin, input.Bytes()...) + } + if _, err := options.Stdout.Write(response); err != nil { return err } - recordedImageValidationStdin = append(recordedImageValidationStdin, input.Bytes()...) - } - if _, err := options.Stdout.Write(response); err != nil { - return err } - } - return nil + return nil + }, nil } return func() { - runImageValidationOpenCommand = previousOpen - runImageValidationFollowupCommand = previousFollowup + bindImageValidationCommandRunner = previousBind recordedImageValidationCommands = nil recordedImageValidationStdin = nil } diff --git a/internal/dockerdeploy/provider_build_run_test.go b/internal/dockerdeploy/provider_build_run_test.go index 021e577e..e8cee206 100644 --- a/internal/dockerdeploy/provider_build_run_test.go +++ b/internal/dockerdeploy/provider_build_run_test.go @@ -113,7 +113,7 @@ func TestRunLockedProviderBuildV1UsesAndRetainsCallerLock(t *testing.T) { if err != nil { t.Fatal(err) } - want := LockedProviderBuildExecutionResultV1{Reused: true, Warnings: []string{rootRuntimeIdentityWarningV1}} + want := LockedProviderBuildExecutionResultV1{Reused: true} order := []string{} result, err := runLockedProviderBuildV1(t.Context(), LockedProviderBuildRunInputV1{ diff --git a/internal/dockerdeploy/provider_install_host_execute.go b/internal/dockerdeploy/provider_install_host_execute.go index 380f4ca2..2fa26885 100644 --- a/internal/dockerdeploy/provider_install_host_execute.go +++ b/internal/dockerdeploy/provider_install_host_execute.go @@ -5,6 +5,8 @@ import ( "fmt" ) +var bindProviderInstallHostCommandRunner = bindDockerCommandRunnerV1 + func configureProviderInstallHostV1(ctx context.Context, plan providerInstallationPlanV1, tools providerInstallHostToolsV1, options RunOptions) error { commands, err := planProviderInstallHostCommandsV1(plan, tools.DockerPath, tools.SystemctlPath) if err != nil { @@ -29,6 +31,13 @@ func startProviderInstallHostV1(ctx context.Context, plan providerInstallationPl return fmt.Errorf("validate installed private runtime isolation: %w", err) } } + run := commandRunner(runCommandWithoutDockerPreflight) + if plan.Backend == installBackendDockerDesktop || plan.Backend == installBackendDockerManaged { + run, err = bindProviderInstallHostCommandRunner(ctx, commands.Start, options.DockerPreflightTimeout) + if err != nil { + return fmt.Errorf("bind install host Docker endpoint: %w", err) + } + } if plan.Docker.PrivateEnvironment && plan.Backend != installBackendLinuxSystemd { if !environment.Present { return fmt.Errorf("installed private workload environment disappeared before startup") @@ -45,13 +54,13 @@ func startProviderInstallHostV1(ctx context.Context, plan providerInstallationPl plan.Docker.ContainerName, environment, options, - runCommandWithoutDockerPreflight, + run, ); err != nil { return fmt.Errorf("install host startup: %w", err) } return nil } - return startProviderInstallHostWithV1(ctx, commands, options, runCommandWithoutDockerPreflight) + return startProviderInstallHostWithV1(ctx, commands, options, run) } func configureProviderInstallHostWithV1(ctx context.Context, commands providerInstallHostCommandsV1, options RunOptions, run commandRunner) error { diff --git a/internal/dockerdeploy/provider_install_host_execute_test.go b/internal/dockerdeploy/provider_install_host_execute_test.go index 38f5d3fe..383968af 100644 --- a/internal/dockerdeploy/provider_install_host_execute_test.go +++ b/internal/dockerdeploy/provider_install_host_execute_test.go @@ -1,6 +1,7 @@ package dockerdeploy import ( + "context" "errors" "os" "path/filepath" @@ -8,6 +9,7 @@ import ( "runtime" "strings" "testing" + "time" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" @@ -55,6 +57,106 @@ func TestStartProviderInstallHostV1RunsOneCommandWithoutPreflight(t *testing.T) } } +func TestStartProviderInstallHostV1PreflightsDockerBackend(t *testing.T) { + destinationDir := t.TempDir() + dockerPath := writeFakeCommand( + t, + destinationDir, + "docker", + "#!/bin/sh\nexit 0\n", + "@exit /b 0\r\n", + ) + references := fixedPublicationReferences(t, destinationDir, 0xd4) + plan := providerInstallRunPlanFixture(destinationDir, references) + plan.Backend = installBackendDockerManaged + plan.Installation.Scope = "user" + plan.Installation.UnitPath = "" + plan.Docker.DeploymentDir = destinationDir + + previousPreflight := dockerPreflight + t.Cleanup(func() { dockerPreflight = previousPreflight }) + preflights := 0 + dockerPreflight = func(_ context.Context, spec CommandSpec, _ time.Duration) (string, error) { + preflights++ + if spec.Name != dockerPath { + t.Fatalf("preflight command = %#v", spec) + } + return "unix:///var/run/docker.sock", nil + } + + if err := startProviderInstallHostV1( + t.Context(), + plan, + providerInstallHostToolsV1{DockerPath: dockerPath}, + RunOptions{}, + ); err != nil { + t.Fatal(err) + } + if preflights != 1 { + t.Fatalf("Docker preflights = %d, want 1", preflights) + } +} + +func TestStartProviderInstallHostV1BindsPrivateEnvironmentStartupOnce(t *testing.T) { + destinationDir := t.TempDir() + dockerPath := filepath.Join(destinationDir, "docker") + environmentPath := filepath.Join(destinationDir, PrivateWorkloadEnvironmentFileName) + if created, err := publishPrivateWorkloadEnvironmentFileV1( + environmentPath, + []byte("PRIVATE_NAME=private-value\n"), + false, + ); err != nil || !created { + t.Fatal(err) + } + references := fixedPublicationReferences(t, destinationDir, 0xd5) + plan := providerInstallRunPlanFixture(destinationDir, references) + plan.Backend = installBackendDockerManaged + plan.Installation.Scope = "user" + plan.Installation.UnitPath = "" + plan.Docker.DeploymentDir = destinationDir + plan.Docker.PrivateEnvironment = true + plan.Docker.Workload = &WorkloadExecutionPlan{Argv: []string{"/bin/true"}} + rendered, err := RenderDockerInputs(plan.Docker, plan.ControlScript) + if err != nil { + t.Fatal(err) + } + plan.Rendered = rendered + + previousBind := bindProviderInstallHostCommandRunner + t.Cleanup(func() { bindProviderInstallHostCommandRunner = previousBind }) + binds := 0 + var operations []string + bindProviderInstallHostCommandRunner = func(bindCtx context.Context, spec CommandSpec, timeout time.Duration) (commandRunner, error) { + binds++ + if bindCtx != t.Context() || spec.Name != dockerPath || timeout != 3*time.Second { + t.Fatalf("bind context=%v spec=%#v timeout=%v", bindCtx, spec, timeout) + } + return func(spec CommandSpec, _ RunOptions) error { + operations = append(operations, strings.Join(spec.Args, " ")) + if len(spec.Args) > 0 && spec.Args[0] == "exec" { + return errors.New("relay failed") + } + return nil + }, nil + } + + err = startProviderInstallHostV1( + t.Context(), + plan, + providerInstallHostToolsV1{DockerPath: dockerPath}, + RunOptions{DockerPreflightTimeout: 3 * time.Second}, + ) + if err == nil || !strings.Contains(err.Error(), "relay failed") { + t.Fatalf("error = %v", err) + } + if binds != 1 { + t.Fatalf("Docker runner binds = %d, want 1", binds) + } + if len(operations) != 3 || !strings.HasPrefix(operations[0], "compose ") || !strings.HasPrefix(operations[1], "exec -i ") || !strings.HasPrefix(operations[2], "compose ") { + t.Fatalf("operations = %#v", operations) + } +} + func TestStartProviderInstallHostV1RejectsChangedPrivateRuntimeMasks(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("ordinary Windows users cannot create the test symlink") diff --git a/internal/dockerdeploy/provider_install_path_updates.go b/internal/dockerdeploy/provider_install_path_updates.go index 33542d02..2535ff56 100644 --- a/internal/dockerdeploy/provider_install_path_updates.go +++ b/internal/dockerdeploy/provider_install_path_updates.go @@ -21,13 +21,35 @@ type providerInstallPathUpdateBackendV1 struct { run commandRunner } +var bindProviderInstallPathUpdateCommandRunner = bindDockerCommandRunnerV1 + func applyProviderInstallPathUpdatesV1(ctx context.Context, locked lockedProviderInstallV1) error { + runDocker := commandRunner(runDockerCommand) + for _, action := range locked.Plan.PathUpdates { + if action.Kind != PathPreserveVolume && action.Kind != PathReplaceVolume { + continue + } + dockerPath := locked.HostTools.DockerPath + if strings.TrimSpace(dockerPath) == "" { + return fmt.Errorf("materialize installed volume %q requires the Docker client", action.Name) + } + var err error + runDocker, err = bindProviderInstallPathUpdateCommandRunner( + ctx, + CommandSpec{Name: dockerPath, Args: []string{"volume", "inspect", action.Target}}, + locked.Input.RunOptions.DockerPreflightTimeout, + ) + if err != nil { + return fmt.Errorf("bind provider install volume Docker endpoint: %w", err) + } + break + } return applyProviderInstallPathUpdatesWithV1(ctx, locked, providerInstallPathUpdateBackendV1{ volumeExists: func(ctx context.Context, name string) (bool, error) { - return providerInstallVolumeExistsV1(ctx, locked.HostTools.DockerPath, name, locked.Input.RunOptions) + return providerInstallVolumeExistsWithV1(ctx, locked.HostTools.DockerPath, name, locked.Input.RunOptions, runDocker) }, prepareProbeWorkspace: PrepareProbeWorkspace, - run: runCommandWithoutDockerPreflight, + run: runDocker, }) } @@ -304,7 +326,7 @@ func applyProviderInstallVolumeV1( } func providerInstallVolumeExistsV1(ctx context.Context, dockerPath string, name string, options RunOptions) (bool, error) { - return providerInstallVolumeExistsWithV1(ctx, dockerPath, name, options, runCommandWithoutDockerPreflight) + return providerInstallVolumeExistsWithV1(ctx, dockerPath, name, options, runDockerCommand) } func providerInstallVolumeExistsWithV1(ctx context.Context, dockerPath string, name string, options RunOptions, run commandRunner) (bool, error) { diff --git a/internal/dockerdeploy/provider_install_path_updates_integration_test.go b/internal/dockerdeploy/provider_install_path_updates_integration_test.go index 15dbbf10..5048d0b7 100644 --- a/internal/dockerdeploy/provider_install_path_updates_integration_test.go +++ b/internal/dockerdeploy/provider_install_path_updates_integration_test.go @@ -74,7 +74,7 @@ func TestProviderInstallVolumeCopyHelperDockerIntegration(t *testing.T) { prepareProbeWorkspace: func(context.Context, providerstore.Store, blueprint.Platform) (PreparedProbeWorkspace, func() error, error) { return workspace, func() error { cleaned = true; return nil }, nil }, - run: runCommandWithoutDockerPreflight, + run: runDockerCommand, }); err != nil { t.Fatal(err) } diff --git a/internal/dockerdeploy/provider_install_path_updates_test.go b/internal/dockerdeploy/provider_install_path_updates_test.go index ba46eca7..2ee8303e 100644 --- a/internal/dockerdeploy/provider_install_path_updates_test.go +++ b/internal/dockerdeploy/provider_install_path_updates_test.go @@ -8,6 +8,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/providerstore" @@ -186,6 +187,47 @@ func TestApplyProviderInstallVolumeV1CreatesEmptyTargetWhenStagingVolumeIsAbsent } } +func TestApplyProviderInstallPathUpdatesV1BindsOneRunnerForVolumeTransaction(t *testing.T) { + action := PathUpdateAction{Name: "data", Kind: PathPreserveVolume, Source: "staging-data", Target: "installed-data"} + locked := providerInstallPathUpdateFixture(t.TempDir(), action) + locked.HostTools.DockerPath = "/usr/bin/docker" + locked.Input.RunOptions.DockerPreflightTimeout = 17 * time.Second + previous := bindProviderInstallPathUpdateCommandRunner + t.Cleanup(func() { bindProviderInstallPathUpdateCommandRunner = previous }) + bindCalls := 0 + commands := []CommandSpec{} + bindProviderInstallPathUpdateCommandRunner = func(ctx context.Context, spec CommandSpec, timeout time.Duration) (commandRunner, error) { + bindCalls++ + if ctx != t.Context() || spec.Name != locked.HostTools.DockerPath || timeout != locked.Input.RunOptions.DockerPreflightTimeout { + t.Fatalf("bind input = %#v, timeout = %s", spec, timeout) + } + return func(command CommandSpec, _ RunOptions) error { + commands = append(commands, command) + if len(command.Args) >= 2 && command.Args[0] == "container" && command.Args[1] == "rm" { + return errors.New("No such container") + } + if len(command.Args) >= 2 && command.Args[0] == "volume" && command.Args[1] == "inspect" { + return errors.New("No such volume") + } + return nil + }, nil + } + if err := applyProviderInstallPathUpdatesV1(t.Context(), locked); err != nil { + t.Fatal(err) + } + if bindCalls != 1 { + t.Fatalf("bind calls = %d", bindCalls) + } + wantOperations := []string{"container rm", "volume inspect", "volume create", "volume inspect"} + gotOperations := make([]string, len(commands)) + for index, command := range commands { + gotOperations[index] = strings.Join(command.Args[:2], " ") + } + if !reflect.DeepEqual(gotOperations, wantOperations) { + t.Fatalf("bound volume operations = %#v, want %#v", gotOperations, wantOperations) + } +} + func TestApplyProviderInstallVolumeV1CleansContainerAndPartialTargetAfterCopyFailure(t *testing.T) { destinationRoot := t.TempDir() action := PathUpdateAction{Name: "data", Kind: PathReplaceVolume, Source: "staging-data", Target: "installed-data"} diff --git a/internal/dockerdeploy/python_build_tools.go b/internal/dockerdeploy/python_build_tools.go index 77c9abd4..0fb75d98 100644 --- a/internal/dockerdeploy/python_build_tools.go +++ b/internal/dockerdeploy/python_build_tools.go @@ -278,7 +278,7 @@ func (session *PythonResolverSession) ValidatePortableBuildToolsV1( executable, } commandArgs = append(commandArgs, args...) - if err := runPythonResolverFollowupCommand( + if err := session.runDockerCommand( CommandSpec{Name: "docker", Args: commandArgs}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}, ); err != nil { diff --git a/internal/dockerdeploy/python_interpreter_selection_test.go b/internal/dockerdeploy/python_interpreter_selection_test.go index d943eb67..0155e1f8 100644 --- a/internal/dockerdeploy/python_interpreter_selection_test.go +++ b/internal/dockerdeploy/python_interpreter_selection_test.go @@ -5,6 +5,7 @@ import ( "errors" "strings" "testing" + "time" "github.com/omry/reploy/internal/probe" "github.com/omry/reploy/internal/providers" @@ -82,55 +83,49 @@ func pythonInterpreterCandidate(component string, path string) providers.Realize func stubPythonInterpreterSelectionCommands(t *testing.T, probeResponse []byte, inspectionResponses []string, resolveWheels func() error) *[]CommandSpec { t.Helper() - previousOpen := runPythonResolverOpenCommand - previousFollowup := runPythonResolverFollowupCommand + previousBind := bindPythonResolverCommandRunner commands := []CommandSpec{} inspectionIndex := 0 - t.Cleanup(func() { - runPythonResolverOpenCommand = previousOpen - runPythonResolverFollowupCommand = previousFollowup - }) - runPythonResolverOpenCommand = func(spec CommandSpec, _ RunOptions) error { - commands = append(commands, spec) - return nil - } - runPythonResolverFollowupCommand = func(spec CommandSpec, options RunOptions) error { - commands = append(commands, spec) - if len(spec.Args) == 0 { - return errors.New("empty Docker command") - } - if spec.Args[0] != "exec" { - return nil - } - if spec.Args[len(spec.Args)-1] == ProbeContainerExecutable { - _, _ = options.Stdout.Write(probeResponse) - return nil - } - for index := 0; index+1 < len(spec.Args); index++ { - if spec.Args[index] == "-m" && spec.Args[index+1] == "pip" { - if containsInOrder(spec.Args[index+2:], []string{"wheel"}) && resolveWheels != nil { - return resolveWheels() - } - return nil + t.Cleanup(func() { bindPythonResolverCommandRunner = previousBind }) + bindPythonResolverCommandRunner = func(context.Context, CommandSpec, time.Duration) (commandRunner, error) { + return func(spec CommandSpec, options RunOptions) error { + commands = append(commands, spec) + if len(spec.Args) == 0 { + return errors.New("empty Docker command") } - if spec.Args[index] == "-m" && spec.Args[index+1] == "uv" { - if resolveWheels != nil { - return resolveWheels() - } + if spec.Args[0] != "exec" { return nil } - } - for _, operation := range []string{"rm", "mkdir", "cp"} { - if containsInOrder(spec.Args, []string{operation}) { + if spec.Args[len(spec.Args)-1] == ProbeContainerExecutable { + _, _ = options.Stdout.Write(probeResponse) return nil } - } - if inspectionIndex >= len(inspectionResponses) { - return errors.New("unexpected interpreter inspection") - } - _, _ = options.Stdout.Write([]byte(inspectionResponses[inspectionIndex])) - inspectionIndex++ - return nil + for index := 0; index+1 < len(spec.Args); index++ { + if spec.Args[index] == "-m" && spec.Args[index+1] == "pip" { + if containsInOrder(spec.Args[index+2:], []string{"wheel"}) && resolveWheels != nil { + return resolveWheels() + } + return nil + } + if spec.Args[index] == "-m" && spec.Args[index+1] == "uv" { + if resolveWheels != nil { + return resolveWheels() + } + return nil + } + } + for _, operation := range []string{"rm", "mkdir", "cp"} { + if containsInOrder(spec.Args, []string{operation}) { + return nil + } + } + if inspectionIndex >= len(inspectionResponses) { + return errors.New("unexpected interpreter inspection") + } + _, _ = options.Stdout.Write([]byte(inspectionResponses[inspectionIndex])) + inspectionIndex++ + return nil + }, nil } t.Cleanup(func() { if inspectionIndex != len(inspectionResponses) { diff --git a/internal/dockerdeploy/python_resolver_session.go b/internal/dockerdeploy/python_resolver_session.go index 3fbdbb58..cd24edf9 100644 --- a/internal/dockerdeploy/python_resolver_session.go +++ b/internal/dockerdeploy/python_resolver_session.go @@ -24,6 +24,7 @@ type PythonResolverSession struct { workspace PreparedProbeWorkspace artifacts PreparedPythonResolverArtifacts containerName string + runDocker commandRunner observations map[string]probe.ExecutableObservationV1 inspected map[string]string buildTools []PortableBuildToolEvidenceV1 @@ -31,8 +32,8 @@ type PythonResolverSession struct { closed bool } -var runPythonResolverOpenCommand = runCommand -var runPythonResolverFollowupCommand = runCommandWithoutDockerPreflight +var bindPythonResolverCommandRunner = bindDockerCommandRunnerV1 +var runPythonResolverFollowupCommand = runDockerCommand type pythonSourceBuildCommand struct { operation string @@ -108,26 +109,37 @@ func OpenPythonResolverSession( }} var stdout bytes.Buffer var stderr bytes.Buffer - if err := runPythonResolverOpenCommand(spec, RunOptions{Context: context.WithoutCancel(ctx), Stdout: &stdout, Stderr: &stderr}); err != nil { + runDocker, err := bindPythonResolverCommandRunner(context.WithoutCancel(ctx), spec, 0) + if err != nil { + return nil, pythonResolverCommandError("create", descriptor.Platform.Canonical, stderr.String(), err) + } + if err := runDocker(spec, RunOptions{Context: context.WithoutCancel(ctx), Stdout: &stdout, Stderr: &stderr}); err != nil { return nil, pythonResolverCommandError("create", descriptor.Platform.Canonical, stderr.String(), err) } if err := ctx.Err(); err != nil { - cleanupErr := removePythonResolverContainer(context.WithoutCancel(ctx), containerName) + cleanupErr := removePythonResolverContainer(context.WithoutCancel(ctx), containerName, runDocker) return nil, errors.Join(fmt.Errorf("open Python resolver session: %w", err), cleanupErr) } stderr.Reset() - if err := runPythonResolverFollowupCommand(CommandSpec{Name: "docker", Args: []string{"start", containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := runDocker(CommandSpec{Name: "docker", Args: []string{"start", containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { startErr := pythonResolverCommandError("start", descriptor.Platform.Canonical, stderr.String(), err) - cleanupErr := removePythonResolverContainer(context.WithoutCancel(ctx), containerName) + cleanupErr := removePythonResolverContainer(context.WithoutCancel(ctx), containerName, runDocker) return nil, errors.Join(startErr, cleanupErr) } return &PythonResolverSession{ - descriptor: descriptor, workspace: workspace, artifacts: artifacts, containerName: containerName, + descriptor: descriptor, workspace: workspace, artifacts: artifacts, containerName: containerName, runDocker: runDocker, observations: map[string]probe.ExecutableObservationV1{}, inspected: map[string]string{}, }, nil } +func (session *PythonResolverSession) runDockerCommand(spec CommandSpec, options RunOptions) error { + if session.runDocker != nil { + return session.runDocker(spec, options) + } + return runPythonResolverFollowupCommand(spec, options) +} + // Probe performs the fixed filesystem observation as the first operation in // this already-running resolver container. func (session *PythonResolverSession) Probe(ctx context.Context, request probe.RequestV1) (probe.ResponseV1, error) { @@ -153,7 +165,7 @@ func (session *PythonResolverSession) Probe(ctx context.Context, request probe.R "exec", "--interactive", "--user", "0:0", "--workdir", "/", session.containerName, session.workspace.ContainerExecutable, }} - if err := runPythonResolverFollowupCommand(spec, RunOptions{Context: ctx, Stdin: bytes.NewReader(encoded), Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := session.runDockerCommand(spec, RunOptions{Context: ctx, Stdin: bytes.NewReader(encoded), Stdout: &stdout, Stderr: &stderr}); err != nil { return probe.ResponseV1{}, pythonResolverCommandError("probe", session.descriptor.Platform.Canonical, stderr.String(), err) } response, err := probe.DecodeResponseV1(request, stdout.Bytes()) @@ -301,7 +313,7 @@ func (session *PythonResolverSession) InspectInterpreter( args = append(args, inspection...) var stdout bytes.Buffer var stderr bytes.Buffer - if err := runPythonResolverFollowupCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := session.runDockerCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { return "", pythonResolverCommandError("inspect interpreter", session.descriptor.Platform.Canonical, stderr.String(), err) } version, err := pythonprovider.ParseInterpreterInspectionOutput(stdout.Bytes()) @@ -350,7 +362,7 @@ func (session *PythonResolverSession) ResolveWheels( args = append(args, resolverArgv...) var stdout bytes.Buffer var stderr bytes.Buffer - if err := runPythonResolverFollowupCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := session.runDockerCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { commandErr := pythonResolverCommandError("resolve wheels", session.descriptor.Platform.Canonical, stderr.String(), err) if strings.Contains(strings.ToLower(stderr.String()), "no module named pip") { return fmt.Errorf("selected Python interpreter has no pip module; ensure its providing packages include pip support: %w", commandErr) @@ -642,7 +654,7 @@ func (session *PythonResolverSession) runWheelEnvironmentCommand( args = append(args, command...) var stdout bytes.Buffer var stderr bytes.Buffer - if err := runPythonResolverFollowupCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := session.runDockerCommand(CommandSpec{Name: "docker", Args: args}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { return pythonResolverCommandError(operation, session.descriptor.Platform.Canonical, stderr.String(), err) } return nil @@ -661,7 +673,7 @@ func (session *PythonResolverSession) Stop(ctx context.Context) error { } var stdout bytes.Buffer var stderr bytes.Buffer - if err := runPythonResolverFollowupCommand(CommandSpec{Name: "docker", Args: []string{"kill", "--signal", "KILL", session.containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := session.runDockerCommand(CommandSpec{Name: "docker", Args: []string{"kill", "--signal", "KILL", session.containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { return pythonResolverCommandError("stop", session.descriptor.Platform.Canonical, stderr.String(), err) } session.stopped = true @@ -675,17 +687,17 @@ func (session *PythonResolverSession) Close(ctx context.Context) error { if ctx == nil { return fmt.Errorf("close Python resolver session context is required") } - if err := removePythonResolverContainer(ctx, session.containerName); err != nil { + if err := removePythonResolverContainer(ctx, session.containerName, session.runDockerCommand); err != nil { return err } session.closed = true return nil } -func removePythonResolverContainer(ctx context.Context, containerName string) error { +func removePythonResolverContainer(ctx context.Context, containerName string, runDocker commandRunner) error { var stdout bytes.Buffer var stderr bytes.Buffer - if err := runPythonResolverFollowupCommand(CommandSpec{Name: "docker", Args: []string{"rm", "--force", containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { + if err := runDocker(CommandSpec{Name: "docker", Args: []string{"rm", "--force", containerName}}, RunOptions{Context: ctx, Stdout: &stdout, Stderr: &stderr}); err != nil { return markProviderHelperCleanupError(pythonResolverCommandError("remove", "local", stderr.String(), err)) } return nil diff --git a/internal/dockerdeploy/python_resolver_session_test.go b/internal/dockerdeploy/python_resolver_session_test.go index cbce5e71..dd030edb 100644 --- a/internal/dockerdeploy/python_resolver_session_test.go +++ b/internal/dockerdeploy/python_resolver_session_test.go @@ -10,6 +10,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/omry/reploy/internal/canonical" "github.com/omry/reploy/internal/probe" @@ -170,28 +171,29 @@ func TestOpenPythonResolverSessionFinishesCreateBeforeHonoringCancellation(t *te descriptor := testProbeImageDescriptor(t, "linux/amd64") workspace := testPreparedProbeWorkspace(t, descriptor.Platform, t.TempDir()) artifacts := testPreparedPythonResolverArtifacts(t) - previousOpen := runPythonResolverOpenCommand - previousFollowup := runPythonResolverFollowupCommand - t.Cleanup(func() { - runPythonResolverOpenCommand = previousOpen - runPythonResolverFollowupCommand = previousFollowup - }) + previousBind := bindPythonResolverCommandRunner + t.Cleanup(func() { bindPythonResolverCommandRunner = previousBind }) ctx, cancel := context.WithCancel(context.Background()) removed := false - runPythonResolverOpenCommand = func(_ CommandSpec, options RunOptions) error { - cancel() - if err := options.Context.Err(); err != nil { - t.Fatalf("Docker create inherited cancellation: %v", err) + bindPythonResolverCommandRunner = func(bindCtx context.Context, _ CommandSpec, _ time.Duration) (commandRunner, error) { + if err := bindCtx.Err(); err != nil { + t.Fatalf("Docker endpoint binding inherited cancellation: %v", err) } - return nil - } - runPythonResolverFollowupCommand = func(spec CommandSpec, _ RunOptions) error { - if len(spec.Args) == 3 && spec.Args[0] == "rm" && spec.Args[1] == "--force" { - removed = true + return func(spec CommandSpec, options RunOptions) error { + if len(spec.Args) != 0 && spec.Args[0] == "create" { + cancel() + if err := options.Context.Err(); err != nil { + t.Fatalf("Docker create inherited cancellation: %v", err) + } + return nil + } + if len(spec.Args) == 3 && spec.Args[0] == "rm" && spec.Args[1] == "--force" { + removed = true + return nil + } + t.Fatalf("unexpected follow-up command: %#v", spec) return nil - } - t.Fatalf("unexpected follow-up command: %#v", spec) - return nil + }, nil } if _, err := OpenPythonResolverSession(ctx, descriptor, workspace, artifacts); !errors.Is(err, context.Canceled) { t.Fatalf("error = %v", err) @@ -245,8 +247,8 @@ func TestPythonResolverSessionBuildsSdistThenWheelWithSelectedInterpreterAndPinn if err := session.BuildSourceDistributions(context.Background(), launcher, requirement, interpreter.Evidence, snapshots); err != nil { t.Fatal(err) } - successfulFollowup := runPythonResolverFollowupCommand - runPythonResolverFollowupCommand = func(spec CommandSpec, options RunOptions) error { + successfulFollowup := session.runDocker + session.runDocker = func(spec CommandSpec, options RunOptions) error { if containsInOrder(spec.Args, []string{"-m", "uv", "build", "--no-progress", "--sdist"}) { _, _ = options.Stderr.Write([]byte("backend requires repository metadata\n")) return errors.New("exit status 1") @@ -258,7 +260,7 @@ func TestPythonResolverSessionBuildsSdistThenWheelWithSelectedInterpreterAndPinn ); err == nil || !strings.Contains(err.Error(), "ordinary source tree without VCS metadata") { t.Fatalf("source distribution failure = %v", err) } - runPythonResolverFollowupCommand = successfulFollowup + session.runDocker = successfulFollowup buildEnvironmentDigest, err := session.SourceBuildEnvironmentDigest(interpreter.Evidence) if err != nil { t.Fatal(err) @@ -362,41 +364,35 @@ func stubPythonResolverCommands( startErr error, ) (*[]CommandSpec, *[]byte) { t.Helper() - previousOpen := runPythonResolverOpenCommand - previousFollowup := runPythonResolverFollowupCommand + previousBind := bindPythonResolverCommandRunner commands := []CommandSpec{} probeInput := []byte(nil) - t.Cleanup(func() { - runPythonResolverOpenCommand = previousOpen - runPythonResolverFollowupCommand = previousFollowup - }) - runPythonResolverOpenCommand = func(spec CommandSpec, _ RunOptions) error { - commands = append(commands, spec) - return nil - } - runPythonResolverFollowupCommand = func(spec CommandSpec, options RunOptions) error { - commands = append(commands, spec) - if len(spec.Args) == 0 { - return errors.New("empty Docker command") - } - if spec.Args[0] == "start" && startErr != nil { - _, _ = options.Stderr.Write([]byte("exec format error\n")) - return startErr - } - if spec.Args[0] != "exec" { - return nil - } - if spec.Args[len(spec.Args)-1] == ProbeContainerExecutable { - input, err := io.ReadAll(options.Stdin) - if err != nil { - return err + t.Cleanup(func() { bindPythonResolverCommandRunner = previousBind }) + bindPythonResolverCommandRunner = func(context.Context, CommandSpec, time.Duration) (commandRunner, error) { + return func(spec CommandSpec, options RunOptions) error { + commands = append(commands, spec) + if len(spec.Args) == 0 { + return errors.New("empty Docker command") + } + if spec.Args[0] == "start" && startErr != nil { + _, _ = options.Stderr.Write([]byte("exec format error\n")) + return startErr } - probeInput = input - _, _ = options.Stdout.Write(probeResponse) + if spec.Args[0] != "exec" { + return nil + } + if spec.Args[len(spec.Args)-1] == ProbeContainerExecutable { + input, err := io.ReadAll(options.Stdin) + if err != nil { + return err + } + probeInput = input + _, _ = options.Stdout.Write(probeResponse) + return nil + } + _, _ = options.Stdout.Write(inspectionResponse) return nil - } - _, _ = options.Stdout.Write(inspectionResponse) - return nil + }, nil } return &commands, &probeInput } diff --git a/internal/dockerdeploy/runtime_host_filesystem_darwin.go b/internal/dockerdeploy/runtime_host_filesystem_darwin.go new file mode 100644 index 00000000..97d41620 --- /dev/null +++ b/internal/dockerdeploy/runtime_host_filesystem_darwin.go @@ -0,0 +1,32 @@ +//go:build darwin + +package dockerdeploy + +import "golang.org/x/sys/unix" + +func protectedRuntimeHostPathV1(string) (string, error) { + return "", nil +} + +func protectedRuntimeHostFilesystemV1(path string) (string, error) { + var filesystem unix.Statfs_t + if err := unix.Statfs(path, &filesystem); err != nil { + return "", err + } + kind := darwinFilesystemNameV1(filesystem.Fstypename[:]) + switch kind { + case "devfs", "procfs": + return kind, nil + default: + return "", nil + } +} + +func darwinFilesystemNameV1(value []byte) string { + for index, item := range value { + if item == 0 { + return string(value[:index]) + } + } + return string(value) +} diff --git a/internal/dockerdeploy/runtime_host_filesystem_linux.go b/internal/dockerdeploy/runtime_host_filesystem_linux.go new file mode 100644 index 00000000..77753b59 --- /dev/null +++ b/internal/dockerdeploy/runtime_host_filesystem_linux.go @@ -0,0 +1,626 @@ +//go:build linux + +package dockerdeploy + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "golang.org/x/sys/unix" +) + +const ( + legacyDevfsSuperMagicV1 = 0x1373 + fuseCtlSuperMagicV1 = 0x65735543 + mqueueMagicV1 = 0x19800202 +) + +func protectedRuntimeHostPathV1(path string) (string, error) { + return protectedRuntimeHostPathWithResolverV1(path, runtimeHostResolveNoMagicLinksV1) +} + +func runtimeHostResolveNoMagicLinksV1(path string) error { + fd, err := unix.Openat2(unix.AT_FDCWD, path, &unix.OpenHow{ + Flags: uint64(unix.O_PATH | unix.O_CLOEXEC), + Resolve: unix.RESOLVE_NO_MAGICLINKS, + }) + if err != nil { + return err + } + return unix.Close(fd) +} + +func protectedRuntimeHostPathWithResolverV1(path string, resolve func(string) error) (string, error) { + err := resolve(path) + if err == nil { + return "", nil + } + if errors.Is(err, unix.ELOOP) { + return "procfs magic link", nil + } + if errors.Is(err, unix.ENOSYS) { + symlink, err := runtimeHostPathContainsSymlinkV1(path) + if err != nil { + return "", fmt.Errorf("inspect host path without openat2: %w", err) + } + if symlink { + return "", fmt.Errorf("kernel does not support safe validation of symlinked host paths") + } + return "", nil + } + return "", fmt.Errorf("resolve without procfs magic links: %w", err) +} + +func runtimeHostPathContainsSymlinkV1(path string) (bool, error) { + for _, component := range strings.Split(path, string(filepath.Separator)) { + if component == "." || component == ".." { + return false, fmt.Errorf("host path contains unsupported %q traversal without openat2", component) + } + } + absolute, err := filepath.Abs(path) + if err != nil { + return false, err + } + current := string(filepath.Separator) + for _, component := range strings.Split(strings.TrimPrefix(filepath.Clean(absolute), current), current) { + if component == "" { + continue + } + current = filepath.Join(current, component) + info, err := os.Lstat(current) + if err != nil { + return false, err + } + if info.Mode()&os.ModeSymlink != 0 { + return true, nil + } + } + return false, nil +} + +func protectedRuntimeHostFilesystemV1(path string) (string, error) { + rootFilesystem, err := runtimeHostSharesRootFilesystemV1(path) + if err != nil { + return "", err + } + if rootFilesystem { + return "host filesystem root", nil + } + mountFilesystem, err := runtimeHostMountFilesystemV1(path) + if err != nil { + return "", err + } + if protectedRuntimeHostFilesystemNameV1(mountFilesystem) { + if mountFilesystem == "proc" { + return "procfs", nil + } + return mountFilesystem, nil + } + var filesystem unix.Statfs_t + if err := unix.Statfs(path, &filesystem); err != nil { + return "", err + } + filesystemMagic := uint64(filesystem.Type) + if kind := protectedRuntimeHostFilesystemKindV1(filesystemMagic); kind != "" { + return kind, nil + } + protectedMount, err := runtimeHostSharesProtectedMountV1(path) + if err != nil { + return "", err + } + if protectedMount { + return "protected host submount", nil + } + protectedSubmount, err := runtimeHostContainsProtectedSubmountV1(path) + if err != nil { + return "", err + } + if protectedSubmount { + return "protected nested host submount", nil + } + if filesystemMagic != unix.TMPFS_MAGIC { + return "", nil + } + // devtmpfs deliberately shares tmpfs's superblock implementation. Use + // the exact mount identity to distinguish it without rejecting ordinary + // tmpfs sources. + + kind, err := runtimeHostMountFilesystemV1(path) + if err != nil { + return "", err + } + switch kind { + case "devtmpfs": + return kind, nil + } + devFilesystem, err := runtimeHostSharesDedicatedDevFilesystemV1(path) + if err != nil { + return "", err + } + if devFilesystem { + return "host /dev filesystem", nil + } + return "", nil +} + +func protectedRuntimeHostFilesystemKindV1(filesystemMagic uint64) string { + switch filesystemMagic { + case unix.PROC_SUPER_MAGIC: + return "procfs" + case unix.SYSFS_MAGIC: + return "sysfs" + case unix.DEVPTS_SUPER_MAGIC: + return "devpts" + case legacyDevfsSuperMagicV1: + return "devfs" + case unix.CGROUP_SUPER_MAGIC: + return "cgroup" + case unix.CGROUP2_SUPER_MAGIC: + return "cgroup2" + case unix.DEBUGFS_MAGIC: + return "debugfs" + case unix.TRACEFS_MAGIC: + return "tracefs" + case unix.SECURITYFS_MAGIC: + return "securityfs" + case unix.BPF_FS_MAGIC: + return "bpf" + case unix.BINFMTFS_MAGIC: + return "binfmt_misc" + case unix.EFIVARFS_MAGIC: + return "efivarfs" + case unix.NSFS_MAGIC: + return "nsfs" + case unix.PSTOREFS_MAGIC: + return "pstore" + case unix.SELINUX_MAGIC: + return "selinuxfs" + case fuseCtlSuperMagicV1: + return "fusectl" + case mqueueMagicV1: + return "mqueue" + } + return "" +} + +func runtimeHostSharesDedicatedDevFilesystemV1(path string) (bool, error) { + var candidate unix.Stat_t + if err := unix.Stat(path, &candidate); err != nil { + return false, fmt.Errorf("stat candidate filesystem: %w", err) + } + var dev unix.Stat_t + if err := unix.Stat("/dev", &dev); err != nil { + return false, fmt.Errorf("stat /dev filesystem: %w", err) + } + var root unix.Stat_t + if err := unix.Stat("/", &root); err != nil { + return false, fmt.Errorf("stat root filesystem: %w", err) + } + if dev.Dev == root.Dev { + return false, nil + } + return candidate.Dev == dev.Dev, nil +} + +func runtimeHostSharesProtectedMountV1(path string) (bool, error) { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return false, fmt.Errorf("read /proc/self/mountinfo: %w", err) + } + candidate, err := runtimeHostMountIdentityForPathV1(data, path) + if err != nil { + return false, err + } + for _, protectedTree := range []string{"/proc", "/dev", "/sys"} { + matched, err := runtimeHostMountIdentitySharesProtectedTreeV1(data, candidate, path, protectedTree) + if err != nil || matched { + return matched, err + } + } + return false, nil +} + +func runtimeHostContainsProtectedSubmountV1(path string) (bool, error) { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return false, fmt.Errorf("read /proc/self/mountinfo: %w", err) + } + return runtimeHostMountContainsProtectedSubmountV1(data, path) +} + +func runtimeHostMountContainsProtectedSubmountV1(data []byte, path string) (bool, error) { + cleanPath := filepath.Clean(path) + root, err := runtimeHostMountIdentityByPathV1(data, string(filepath.Separator)) + if err != nil { + return false, err + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + identity, err := runtimeHostMountIdentityFromFieldsV1(fields) + if err != nil { + return false, err + } + mountPoint := filepath.Clean(identity.mountPoint) + if mountPoint == cleanPath || !pathWithinV1(mountPoint, cleanPath) { + continue + } + visible, err := runtimeHostMountIdentityByPathV1(data, mountPoint) + if err != nil { + return false, fmt.Errorf("resolve visible mount at %q: %w", mountPoint, err) + } + if visible.mountID != identity.mountID { + continue + } + if protectedRuntimeHostFilesystemNameV1(identity.filesystem) { + return true, nil + } + exposesRoot, err := runtimeHostMountIdentitiesExposeSameRootV1(identity, root, mountPoint) + if err != nil { + return false, err + } + if exposesRoot { + return true, nil + } + for _, protectedTree := range []string{"/proc", "/dev", "/sys"} { + exposesProtectedTree, err := runtimeHostMountIdentitySharesProtectedTreeV1(data, identity, mountPoint, protectedTree) + if err != nil { + return false, err + } + if exposesProtectedTree { + return true, nil + } + } + } + return false, nil +} + +func protectedRuntimeHostFilesystemNameV1(filesystem string) bool { + switch filesystem { + case "anon_inodefs", "bdev", "binder", "binderfs", "binfmt_misc", "bpf", + "cgroup", "cgroup2", "configfs", "cpuset", "debugfs", "devfs", + "devmem", "devpts", "devtmpfs", "dma_buf", "efivarfs", "fusectl", + "futexfs", "hugetlbfs", "mqueue", "nfsd", "nsfs", "pipefs", "proc", + "pstore", "resctrl", "rpc_pipefs", "secretmem", "securityfs", + "selinuxfs", "smackfs", "sockfs", "sysfs", "tracefs", "usbfs", "xenfs": + return true + default: + return false + } +} + +func runtimeHostMountFilesystemV1(path string) (string, error) { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return "", fmt.Errorf("read /proc/self/mountinfo: %w", err) + } + identity, err := runtimeHostMountIdentityForPathV1(data, path) + if err != nil { + return "", err + } + return identity.filesystem, nil +} + +func runtimeHostSharesRootFilesystemV1(path string) (bool, error) { + data, err := os.ReadFile("/proc/self/mountinfo") + if err != nil { + return false, fmt.Errorf("read /proc/self/mountinfo: %w", err) + } + candidate, err := runtimeHostMountIdentityForPathV1(data, path) + if err != nil { + return false, err + } + root, err := runtimeHostMountIdentityForPathV1(data, "/") + if err != nil { + return false, err + } + return runtimeHostMountIdentitiesExposeSameRootV1(candidate, root, path) +} + +func runtimeHostMountIDV1(path string) (uint64, bool, error) { + var status unix.Statx_t + err := unix.Statx(unix.AT_FDCWD, path, unix.AT_STATX_SYNC_AS_STAT, unix.STATX_MNT_ID, &status) + if err != nil && !errors.Is(err, unix.ENOSYS) && !errors.Is(err, unix.EINVAL) && !errors.Is(err, unix.EOPNOTSUPP) { + return 0, false, fmt.Errorf("statx mount identity: %w", err) + } + if err != nil || status.Mask&unix.STATX_MNT_ID == 0 { + return 0, false, nil + } + return status.Mnt_id, true, nil +} + +func runtimeHostMountFilesystemByIDV1(data []byte, mountID uint64) (string, bool, error) { + identity, found, err := runtimeHostMountIdentityByIDV1(data, mountID) + return identity.filesystem, found, err +} + +type runtimeHostMountIdentityV1 struct { + mountID uint64 + parentID uint64 + device string + root string + mountPoint string + filesystem string +} + +func runtimeHostMountIdentityForPathV1(data []byte, path string) (runtimeHostMountIdentityV1, error) { + return runtimeHostMountIdentityForPathWithResolverV1(data, path, runtimeHostMountIDV1) +} + +func runtimeHostMountIdentityForPathWithResolverV1( + data []byte, + path string, + resolveMountID func(string) (uint64, bool, error), +) (runtimeHostMountIdentityV1, error) { + mountID, found, err := resolveMountID(path) + if err != nil { + return runtimeHostMountIdentityV1{}, err + } + if found { + identity, found, err := runtimeHostMountIdentityByIDV1(data, mountID) + if err != nil { + return runtimeHostMountIdentityV1{}, err + } + if !found { + return runtimeHostMountIdentityV1{}, fmt.Errorf("mount ID %d is absent from /proc/self/mountinfo", mountID) + } + return identity, nil + } + return runtimeHostMountIdentityByPathV1(data, path) +} + +func runtimeHostMountIdentityByPathV1(data []byte, path string) (runtimeHostMountIdentityV1, error) { + cleanPath := filepath.Clean(path) + byMountPoint := make(map[string][]runtimeHostMountIdentityV1) + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + identity, err := runtimeHostMountIdentityFromFieldsV1(fields) + if err != nil { + return runtimeHostMountIdentityV1{}, err + } + if !pathWithinV1(cleanPath, identity.mountPoint) { + continue + } + mountPoint := filepath.Clean(identity.mountPoint) + byMountPoint[mountPoint] = append(byMountPoint[mountPoint], identity) + } + rootMounts := byMountPoint[string(filepath.Separator)] + visible, err := runtimeHostTopmostMountAtLocationV1(rootMounts) + if err != nil { + return runtimeHostMountIdentityV1{}, fmt.Errorf("resolve visible root mount: %w", err) + } + if visible.mountPoint == "" { + return runtimeHostMountIdentityV1{}, fmt.Errorf("host path %q is absent from /proc/self/mountinfo", path) + } + + mountPoints := make([]string, 0, len(byMountPoint)) + for mountPoint := range byMountPoint { + if mountPoint != string(filepath.Separator) { + mountPoints = append(mountPoints, mountPoint) + } + } + sort.Slice(mountPoints, func(left int, right int) bool { + return len(mountPoints[left]) < len(mountPoints[right]) + }) + for _, mountPoint := range mountPoints { + candidate, found, err := runtimeHostVisibleMountAtLocationV1(byMountPoint[mountPoint], visible.mountID) + if err != nil { + return runtimeHostMountIdentityV1{}, fmt.Errorf("resolve visible mount at %q: %w", mountPoint, err) + } + if found { + visible = candidate + } + } + return visible, nil +} + +func runtimeHostTopmostMountAtLocationV1(mounts []runtimeHostMountIdentityV1) (runtimeHostMountIdentityV1, error) { + parents := make(map[uint64]struct{}, len(mounts)) + for _, mount := range mounts { + if mount.parentID != mount.mountID { + parents[mount.parentID] = struct{}{} + } + } + var top runtimeHostMountIdentityV1 + found := false + for _, mount := range mounts { + if _, hidden := parents[mount.mountID]; hidden { + continue + } + if found { + return runtimeHostMountIdentityV1{}, fmt.Errorf("mount topology has multiple topmost entries") + } + top = mount + found = true + } + return top, nil +} + +func runtimeHostVisibleMountAtLocationV1( + mounts []runtimeHostMountIdentityV1, + visibleParentID uint64, +) (runtimeHostMountIdentityV1, bool, error) { + children := make(map[uint64][]runtimeHostMountIdentityV1, len(mounts)) + for _, mount := range mounts { + children[mount.parentID] = append(children[mount.parentID], mount) + } + currentID := visibleParentID + var visible runtimeHostMountIdentityV1 + found := false + seen := make(map[uint64]struct{}, len(mounts)) + for { + candidates := children[currentID] + if len(candidates) == 0 { + return visible, found, nil + } + if len(candidates) != 1 { + return runtimeHostMountIdentityV1{}, false, fmt.Errorf("mount topology has multiple visible children") + } + next := candidates[0] + if _, duplicate := seen[next.mountID]; duplicate { + return runtimeHostMountIdentityV1{}, false, fmt.Errorf("mount topology contains a cycle") + } + seen[next.mountID] = struct{}{} + visible = next + found = true + currentID = next.mountID + } +} + +func runtimeHostMountsExposeSameRootV1(data []byte, candidateID uint64, rootID uint64, path string) (bool, error) { + if candidateID == rootID { + return false, nil + } + candidate, found, err := runtimeHostMountIdentityByIDV1(data, candidateID) + if err != nil { + return false, err + } + if !found { + return false, fmt.Errorf("mount ID %d is absent from /proc/self/mountinfo", candidateID) + } + root, found, err := runtimeHostMountIdentityByIDV1(data, rootID) + if err != nil { + return false, err + } + if !found { + return false, fmt.Errorf("root mount ID %d is absent from /proc/self/mountinfo", rootID) + } + return runtimeHostMountIdentitiesExposeSameRootV1(candidate, root, path) +} + +func runtimeHostMountIdentitiesExposeSameRootV1(candidate runtimeHostMountIdentityV1, root runtimeHostMountIdentityV1, path string) (bool, error) { + if candidate.device != root.device { + return false, nil + } + effective, err := runtimeHostEffectiveBackingPathV1(candidate, path) + if err != nil { + return false, err + } + return pathWithinV1(root.root, effective), nil +} + +func runtimeHostMountSharesProtectedTreeV1(data []byte, candidateID uint64, path string, protectedTree string) (bool, error) { + candidate, found, err := runtimeHostMountIdentityByIDV1(data, candidateID) + if err != nil { + return false, err + } + if !found { + return false, fmt.Errorf("mount ID %d is absent from /proc/self/mountinfo", candidateID) + } + return runtimeHostMountIdentitySharesProtectedTreeV1(data, candidate, path, protectedTree) +} + +func runtimeHostMountIdentitySharesProtectedTreeV1(data []byte, candidate runtimeHostMountIdentityV1, path string, protectedTree string) (bool, error) { + effective, err := runtimeHostEffectiveBackingPathV1(candidate, path) + if err != nil { + return false, err + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + identity, err := runtimeHostMountIdentityFromFieldsV1(fields) + if err != nil { + return false, err + } + if !pathWithinV1(identity.mountPoint, protectedTree) || identity.device != candidate.device { + continue + } + visible, err := runtimeHostMountIdentityByPathV1(data, identity.mountPoint) + if err != nil { + return false, fmt.Errorf("resolve visible mount at %q: %w", identity.mountPoint, err) + } + if visible.mountID != identity.mountID { + continue + } + if pathWithinV1(effective, identity.root) || pathWithinV1(identity.root, effective) { + return true, nil + } + } + return false, nil +} + +func runtimeHostEffectiveBackingPathV1(identity runtimeHostMountIdentityV1, path string) (string, error) { + if !pathWithinV1(path, identity.mountPoint) { + return "", fmt.Errorf("host path %q is outside mount point %q", path, identity.mountPoint) + } + relative, err := filepath.Rel(filepath.Clean(identity.mountPoint), filepath.Clean(path)) + if err != nil { + return "", fmt.Errorf("resolve path within host mount: %w", err) + } + return filepath.Clean(filepath.Join(identity.root, relative)), nil +} + +func runtimeHostMountIdentityByIDV1(data []byte, mountID uint64) (runtimeHostMountIdentityV1, bool, error) { + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + candidate, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return runtimeHostMountIdentityV1{}, false, fmt.Errorf("parse mount ID %q: %w", fields[0], err) + } + if candidate != mountID { + continue + } + identity, err := runtimeHostMountIdentityFromFieldsV1(fields) + return identity, true, err + } + return runtimeHostMountIdentityV1{}, false, nil +} + +func runtimeHostMountFilesystemFromFieldsV1(fields []string) (string, error) { + identity, err := runtimeHostMountIdentityFromFieldsV1(fields) + return identity.filesystem, err +} + +func runtimeHostMountIdentityFromFieldsV1(fields []string) (runtimeHostMountIdentityV1, error) { + if len(fields) < 5 { + return runtimeHostMountIdentityV1{}, fmt.Errorf("mountinfo record is missing identity fields") + } + mountID, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + return runtimeHostMountIdentityV1{}, fmt.Errorf("parse mount ID %q: %w", fields[0], err) + } + parentID, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return runtimeHostMountIdentityV1{}, fmt.Errorf("parse parent mount ID %q: %w", fields[1], err) + } + for index, field := range fields { + if field == "-" { + if index+1 >= len(fields) { + return runtimeHostMountIdentityV1{}, fmt.Errorf("mountinfo record is missing filesystem type") + } + return runtimeHostMountIdentityV1{ + mountID: mountID, + parentID: parentID, + device: fields[2], + root: runtimeHostMountPathV1(fields[3]), + mountPoint: runtimeHostMountPathV1(fields[4]), + filesystem: fields[index+1], + }, nil + } + } + return runtimeHostMountIdentityV1{}, fmt.Errorf("mountinfo record is missing field separator") +} + +func runtimeHostMountPathV1(path string) string { + return strings.NewReplacer( + `\040`, " ", + `\011`, "\t", + `\012`, "\n", + `\134`, `\`, + ).Replace(path) +} diff --git a/internal/dockerdeploy/runtime_host_filesystem_linux_test.go b/internal/dockerdeploy/runtime_host_filesystem_linux_test.go new file mode 100644 index 00000000..6aa46475 --- /dev/null +++ b/internal/dockerdeploy/runtime_host_filesystem_linux_test.go @@ -0,0 +1,486 @@ +//go:build linux + +package dockerdeploy + +import ( + "os" + "path/filepath" + "syscall" + "testing" + + "golang.org/x/sys/unix" +) + +func TestProtectedRuntimeHostPathV1RejectsProcMagicLinkAliases(t *testing.T) { + root := t.TempDir() + directAlias := filepath.Join(root, "cwd") + if err := os.Symlink("/proc/self/cwd", directAlias); err != nil { + t.Fatal(err) + } + procAlias := filepath.Join(root, "proc") + if err := os.Symlink("/proc", procAlias); err != nil { + t.Fatal(err) + } + for _, path := range []string{directAlias, filepath.Join(procAlias, "self", "cwd")} { + got, err := protectedRuntimeHostPathV1(path) + if err != nil { + t.Fatal(err) + } + if got != "procfs magic link" { + t.Fatalf("protected path %q = %q, want procfs magic link", path, got) + } + } +} + +func TestProtectedRuntimeHostPathV1AllowsOrdinarySymlinks(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + got, err := protectedRuntimeHostPathV1(alias) + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("ordinary symlink classified as %q", got) + } +} + +func TestProtectedRuntimeHostPathWithResolverV1FallsBackWithoutOpenat2(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatal(err) + } + alias := filepath.Join(root, "alias") + if err := os.Symlink(target, alias); err != nil { + t.Fatal(err) + } + unsupported := func(string) error { return unix.ENOSYS } + + got, err := protectedRuntimeHostPathWithResolverV1(target, unsupported) + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Fatalf("ordinary path classified as %q", got) + } + if _, err := protectedRuntimeHostPathWithResolverV1(alias, unsupported); err == nil { + t.Fatal("symlinked path accepted without openat2") + } + for _, path := range []string{alias + string(filepath.Separator) + "..", target + string(filepath.Separator) + "."} { + if _, err := protectedRuntimeHostPathWithResolverV1(path, unsupported); err == nil { + t.Fatalf("non-normalized path %q accepted without openat2", path) + } + } +} + +func TestProtectedRuntimeHostPathWithResolverV1ClassifiesMagicLinks(t *testing.T) { + magicLink := func(string) error { return unix.ELOOP } + got, err := protectedRuntimeHostPathWithResolverV1("/ordinary/path", magicLink) + if err != nil { + t.Fatal(err) + } + if got != "procfs magic link" { + t.Fatalf("magic link classified as %q", got) + } +} + +func TestProtectedRuntimeHostFilesystemV1RecognizesKernelFilesystems(t *testing.T) { + for _, test := range []struct { + path string + want string + }{ + {path: "/proc", want: "procfs"}, + {path: "/sys", want: "sysfs"}, + {path: "/dev/pts", want: "devpts"}, + } { + t.Run(test.want, func(t *testing.T) { + if _, err := os.Stat(test.path); os.IsNotExist(err) { + t.Skipf("host path %q is absent", test.path) + } else if err != nil { + t.Fatal(err) + } + got, err := protectedRuntimeHostFilesystemV1(test.path) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("filesystem for %q = %q, want %q", test.path, got, test.want) + } + }) + } +} + +func TestProtectedRuntimeHostFilesystemV1DoesNotRejectOrdinaryTmpfs(t *testing.T) { + for _, candidate := range []string{"/run", "/tmp"} { + if _, err := os.Stat(candidate); err != nil { + continue + } + kind, err := runtimeHostMountFilesystemV1(candidate) + if err != nil { + t.Fatal(err) + } + if kind != "tmpfs" { + continue + } + protected, err := protectedRuntimeHostFilesystemV1(candidate) + if err != nil { + t.Fatal(err) + } + if protected == "protected nested host submount" { + continue + } + if protected != "" { + t.Fatalf("ordinary tmpfs %q classified as %q", candidate, protected) + } + return + } + t.Skip("host has no ordinary tmpfs candidate") +} + +func TestProtectedRuntimeHostFilesystemKindV1RecognizesKernelControlFilesystems(t *testing.T) { + for _, test := range []struct { + magic uint64 + want string + }{ + {magic: unix.CGROUP_SUPER_MAGIC, want: "cgroup"}, + {magic: unix.CGROUP2_SUPER_MAGIC, want: "cgroup2"}, + {magic: unix.DEBUGFS_MAGIC, want: "debugfs"}, + {magic: unix.TRACEFS_MAGIC, want: "tracefs"}, + {magic: unix.SECURITYFS_MAGIC, want: "securityfs"}, + {magic: unix.BPF_FS_MAGIC, want: "bpf"}, + {magic: unix.BINFMTFS_MAGIC, want: "binfmt_misc"}, + {magic: unix.EFIVARFS_MAGIC, want: "efivarfs"}, + {magic: unix.NSFS_MAGIC, want: "nsfs"}, + {magic: unix.PSTOREFS_MAGIC, want: "pstore"}, + {magic: unix.SELINUX_MAGIC, want: "selinuxfs"}, + {magic: fuseCtlSuperMagicV1, want: "fusectl"}, + {magic: mqueueMagicV1, want: "mqueue"}, + } { + if got := protectedRuntimeHostFilesystemKindV1(test.magic); got != test.want { + t.Fatalf("filesystem magic %#x = %q, want %q", test.magic, got, test.want) + } + } +} + +func TestProtectedRuntimeHostFilesystemNameV1RecognizesKernelInterfaces(t *testing.T) { + for _, filesystem := range []string{ + "proc", "sysfs", "devpts", "devtmpfs", "cgroup2", "configfs", + "debugfs", "securityfs", "bpf", "binfmt_misc", "efivarfs", + "fusectl", "hugetlbfs", "mqueue", "resctrl", "rpc_pipefs", + } { + if !protectedRuntimeHostFilesystemNameV1(filesystem) { + t.Fatalf("kernel interface filesystem %q was not protected", filesystem) + } + } + for _, filesystem := range []string{"ext4", "tmpfs", "fuse", "overlay", "rootfs", "9p"} { + if protectedRuntimeHostFilesystemNameV1(filesystem) { + t.Fatalf("ordinary data filesystem %q was protected", filesystem) + } + } +} + +func TestRuntimeHostSharesDedicatedDevFilesystemV1(t *testing.T) { + dev, err := runtimeHostSharesDedicatedDevFilesystemV1("/dev") + if err != nil { + t.Fatal(err) + } + var devInfo, rootInfo syscall.Stat_t + if err := syscall.Stat("/dev", &devInfo); err != nil { + t.Fatal(err) + } + if err := syscall.Stat("/", &rootInfo); err != nil { + t.Fatal(err) + } + if devInfo.Dev == rootInfo.Dev { + if dev { + t.Fatal("non-dedicated /dev filesystem classified as dedicated") + } + return + } + if !dev { + t.Fatal("dedicated /dev filesystem was not recognized") + } +} + +func TestRuntimeHostMountFilesystemV1ParsesIdentity(t *testing.T) { + data := []byte("41 1 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /safe\\040tree rw - devtmpfs udev rw\n" + + "43 42 0:3 / /safe\\040tree/nested rw - proc proc rw\n") + + kind, found, err := runtimeHostMountFilesystemByIDV1(data, 42) + if err != nil { + t.Fatal(err) + } + if !found || kind != "devtmpfs" { + t.Fatalf("mount ID lookup = %q, %t", kind, found) + } + identity, found, err := runtimeHostMountIdentityByIDV1(data, 42) + if err != nil { + t.Fatal(err) + } + if !found || identity.mountPoint != "/safe tree" { + t.Fatalf("mount point lookup = %q, %t", identity.mountPoint, found) + } +} + +func TestRuntimeHostMountsExposeSameRootV1(t *testing.T) { + data := []byte("41 1 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:1 / /safe/root rw - ext4 /dev/root rw\n" + + "43 41 0:1 /var /safe/var rw - ext4 /dev/root rw\n" + + "44 41 0:2 / /other rw - ext4 /dev/other rw\n") + + for _, test := range []struct { + name string + candidate uint64 + path string + want bool + }{ + {name: "root alias", candidate: 42, path: "/safe/root", want: true}, + {name: "path below root alias", candidate: 42, path: "/safe/root/home/me/app", want: false}, + {name: "subdirectory bind", candidate: 43, path: "/safe/var", want: false}, + {name: "different filesystem", candidate: 44, path: "/other", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := runtimeHostMountsExposeSameRootV1(data, test.candidate, 41, test.path) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("same root = %t, want %t", got, test.want) + } + }) + } +} + +func TestRuntimeHostMountIdentityForPathV1FallsBackWithoutMountIDs(t *testing.T) { + data := []byte("41 1 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:1 / /safe/root rw - ext4 /dev/root rw\n" + + "43 41 0:3 / /dev/shm rw - tmpfs shm rw\n" + + "44 41 0:3 / /safe/shm rw - tmpfs shm rw\n") + unsupported := func(string) (uint64, bool, error) { return 0, false, nil } + + rootAlias, err := runtimeHostMountIdentityForPathWithResolverV1(data, "/safe/root", unsupported) + if err != nil { + t.Fatal(err) + } + root, err := runtimeHostMountIdentityForPathWithResolverV1(data, "/", unsupported) + if err != nil { + t.Fatal(err) + } + exposesRoot, err := runtimeHostMountIdentitiesExposeSameRootV1(rootAlias, root, "/safe/root") + if err != nil { + t.Fatal(err) + } + if !exposesRoot { + t.Fatal("root bind alias was not recognized without statx mount IDs") + } + + devAlias, err := runtimeHostMountIdentityForPathWithResolverV1(data, "/safe/shm", unsupported) + if err != nil { + t.Fatal(err) + } + protected, err := runtimeHostMountIdentitySharesProtectedTreeV1(data, devAlias, "/safe/shm", "/dev") + if err != nil { + t.Fatal(err) + } + if !protected { + t.Fatal("protected submount alias was not recognized without statx mount IDs") + } +} + +func TestRuntimeHostMountIdentityByPathV1ResolvesVisibleMountTopology(t *testing.T) { + data := []byte("41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /stack rw - tmpfs lower rw\n" + + "43 42 0:3 / /stack/hidden rw - tmpfs hidden rw\n" + + "44 42 0:4 / /stack rw - tmpfs upper rw\n" + + "45 44 0:5 / /stack/visible rw - tmpfs visible rw\n") + + for _, test := range []struct { + path string + wantMountID uint64 + }{ + {path: "/stack", wantMountID: 44}, + {path: "/stack/file", wantMountID: 44}, + {path: "/stack/hidden", wantMountID: 44}, + {path: "/stack/visible", wantMountID: 45}, + {path: "/stack/visible/file", wantMountID: 45}, + } { + identity, err := runtimeHostMountIdentityByPathV1(data, test.path) + if err != nil { + t.Fatal(err) + } + if identity.mountID != test.wantMountID { + t.Fatalf("visible mount for %q = %d, want %d", test.path, identity.mountID, test.wantMountID) + } + } +} + +func TestRuntimeHostMountContainsProtectedSubmountV1(t *testing.T) { + for _, test := range []struct { + name string + data string + want bool + }{ + { + name: "root alias", + data: "41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:1 / /safe/root rw - ext4 /dev/root rw\n", + want: true, + }, + { + name: "proc alias", + data: "41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /proc rw - proc proc rw\n" + + "43 41 0:2 / /safe/proc rw - proc proc rw\n", + want: true, + }, + { + name: "hidden proc alias", + data: "41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /proc rw - proc proc rw\n" + + "43 41 0:2 / /safe/proc rw - proc proc rw\n" + + "44 43 0:3 / /safe/proc rw - tmpfs visible rw\n", + want: false, + }, + { + name: "ordinary nested filesystem", + data: "41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:4 / /safe/data rw - ext4 /dev/data rw\n", + want: false, + }, + { + name: "standalone nested kernel filesystem", + data: "41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:9 / /safe/control rw - cgroup2 cgroup2 rw\n", + want: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + got, err := runtimeHostMountContainsProtectedSubmountV1([]byte(test.data), "/safe") + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("protected nested mount = %t, want %t", got, test.want) + } + }) + } +} + +func TestRuntimeHostMountIdentitiesExposeSameRootV1UsesEffectiveBackingPath(t *testing.T) { + root := runtimeHostMountIdentityV1{device: "0:1", root: "/@", mountPoint: "/", filesystem: "btrfs"} + topLevel := runtimeHostMountIdentityV1{device: "0:1", root: "/", mountPoint: "/mnt/btrfs", filesystem: "btrfs"} + + for _, test := range []struct { + path string + want bool + }{ + {path: "/mnt/btrfs", want: true}, + {path: "/mnt/btrfs/@", want: true}, + {path: "/mnt/btrfs/@/home", want: false}, + {path: "/mnt/btrfs/other", want: false}, + } { + got, err := runtimeHostMountIdentitiesExposeSameRootV1(topLevel, root, test.path) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("root exposure for %q = %t, want %t", test.path, got, test.want) + } + } +} + +func TestRuntimeHostMountSharesProtectedTreeV1(t *testing.T) { + data := []byte("41 1 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /dev rw - tmpfs tmpfs rw\n" + + "43 42 0:3 / /dev/shm rw - tmpfs shm rw\n" + + "44 41 0:3 / /safe/shm rw - tmpfs shm rw\n" + + "45 41 0:4 / /run rw - tmpfs tmpfs rw\n" + + "46 41 0:3 /session /safe/session rw - tmpfs shm rw\n" + + "47 41 0:5 / /sys/fs/resctrl rw - resctrl resctrl rw\n" + + "48 41 0:5 / /safe/resctrl rw - resctrl resctrl rw\n") + + for _, test := range []struct { + name string + candidate uint64 + path string + tree string + want bool + }{ + {name: "dev shm alias", candidate: 44, path: "/safe/shm", tree: "/dev", want: true}, + {name: "dev shm subdirectory alias", candidate: 46, path: "/safe/session", tree: "/dev", want: true}, + {name: "sys resctrl alias", candidate: 48, path: "/safe/resctrl", tree: "/sys", want: true}, + {name: "unrelated tmpfs", candidate: 45, path: "/run", tree: "/dev", want: false}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := runtimeHostMountSharesProtectedTreeV1(data, test.candidate, test.path, test.tree) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("protected tree match = %t, want %t", got, test.want) + } + }) + } +} + +func TestRuntimeHostMountSharesProtectedTreeV1UsesEffectiveBackingPath(t *testing.T) { + data := []byte("41 1 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:3 / /run rw - tmpfs tmpfs rw\n" + + "43 41 0:3 /protected /dev/protected rw - tmpfs tmpfs rw\n") + candidate, found, err := runtimeHostMountIdentityByIDV1(data, 42) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("candidate mount not found") + } + + for _, test := range []struct { + path string + want bool + }{ + {path: "/run", want: true}, + {path: "/run/protected", want: true}, + {path: "/run/protected/child", want: true}, + {path: "/run/ordinary", want: false}, + } { + got, err := runtimeHostMountIdentitySharesProtectedTreeV1(data, candidate, test.path, "/dev") + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Fatalf("protected mount exposure for %q = %t, want %t", test.path, got, test.want) + } + } +} + +func TestRuntimeHostMountSharesProtectedTreeV1IgnoresHiddenMount(t *testing.T) { + data := []byte("41 41 0:1 / / rw - ext4 /dev/root rw\n" + + "42 41 0:2 / /dev rw - tmpfs tmpfs rw\n" + + "43 42 0:3 /secret /dev/x rw - tmpfs hidden rw\n" + + "44 43 0:4 / /dev/x rw - tmpfs visible rw\n" + + "45 41 0:3 / /safe rw - tmpfs candidate rw\n") + candidate, found, err := runtimeHostMountIdentityByIDV1(data, 45) + if err != nil { + t.Fatal(err) + } + if !found { + t.Fatal("candidate mount not found") + } + + got, err := runtimeHostMountIdentitySharesProtectedTreeV1(data, candidate, "/safe/secret", "/dev") + if err != nil { + t.Fatal(err) + } + if got { + t.Fatal("hidden protected-tree mount was treated as visible") + } +} diff --git a/internal/dockerdeploy/runtime_host_filesystem_other.go b/internal/dockerdeploy/runtime_host_filesystem_other.go new file mode 100644 index 00000000..5954b7f2 --- /dev/null +++ b/internal/dockerdeploy/runtime_host_filesystem_other.go @@ -0,0 +1,11 @@ +//go:build !linux && !darwin + +package dockerdeploy + +func protectedRuntimeHostPathV1(string) (string, error) { + return "", nil +} + +func protectedRuntimeHostFilesystemV1(string) (string, error) { + return "", nil +} diff --git a/internal/dockerdeploy/runtime_host_preflight.go b/internal/dockerdeploy/runtime_host_preflight.go index 3ad5ad46..c1ae8b2f 100644 --- a/internal/dockerdeploy/runtime_host_preflight.go +++ b/internal/dockerdeploy/runtime_host_preflight.go @@ -4,7 +4,9 @@ import ( "fmt" "os" "path/filepath" + "runtime" "sort" + "strings" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" @@ -123,6 +125,16 @@ func ValidateRuntimeHostSourcesV1(policy deploy.RuntimePolicyV1, planID string, if err != nil { return fmt.Errorf("runtime plan %q mount %q host source: %w", planID, mount.Destination, err) } + protected, err := protectedRuntimeHostTreeV1(source.HostPath) + if err != nil { + return fmt.Errorf("runtime plan %q mount %q host source: %w", planID, mount.Destination, err) + } + if protected != "" { + return fmt.Errorf( + "runtime plan %q mount %q host source resolves to protected host system source %q; ordinary host binds cannot expose the host filesystem root or protected kernel filesystems, including /proc, /dev, and /sys", + planID, mount.Destination, protected, + ) + } switch mount.SourceKind { case deploy.RuntimeMountSourceDirectory: if !info.IsDir() { @@ -147,6 +159,67 @@ func ValidateRuntimeHostSourcesV1(policy deploy.RuntimePolicyV1, planID string, return nil } +func protectedRuntimeHostTreeV1(hostPath string) (string, error) { + if runtime.GOOS != "windows" { + original := filepath.Clean(hostPath) + for _, candidate := range []string{"/proc", "/dev", "/sys"} { + if pathWithinV1(original, candidate) { + return candidate, nil + } + } + } + protected, err := protectedRuntimeHostPathV1(hostPath) + if err != nil { + return "", fmt.Errorf("validate host path resolution: %w", err) + } + if protected != "" { + return protected, nil + } + + resolved, err := filepath.EvalSymlinks(hostPath) + if err != nil { + return "", fmt.Errorf("resolve canonical path: %w", err) + } + resolved = filepath.Clean(resolved) + filesystem, err := protectedRuntimeHostFilesystemV1(resolved) + if err != nil { + return "", fmt.Errorf("identify host filesystem: %w", err) + } + if filesystem != "" { + return filesystem, nil + } + + volumeRoot := filepath.VolumeName(resolved) + string(filepath.Separator) + if filepath.Clean(volumeRoot) == resolved { + return volumeRoot, nil + } + if runtime.GOOS == "windows" { + return "", nil + } + + for _, candidate := range []string{"/proc", "/dev", "/sys"} { + canonical, err := filepath.EvalSymlinks(candidate) + if err != nil { + if os.IsNotExist(err) { + continue + } + return "", fmt.Errorf("resolve protected host system tree %q: %w", candidate, err) + } + if pathWithinV1(resolved, canonical) { + return candidate, nil + } + } + return "", nil +} + +func pathWithinV1(path string, root string) bool { + relative, err := filepath.Rel(filepath.Clean(root), filepath.Clean(path)) + if err != nil { + return false + } + return relative == "." || relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} + func ValidateRootRuntimeHostAuthorityV1(policy deploy.RuntimePolicyV1, plan DockerExecutionPlan) error { if plan.Sandbox.RuntimeUser.UID != 0 { return nil diff --git a/internal/dockerdeploy/runtime_host_preflight_test.go b/internal/dockerdeploy/runtime_host_preflight_test.go index af9ab4c5..383ad3a9 100644 --- a/internal/dockerdeploy/runtime_host_preflight_test.go +++ b/internal/dockerdeploy/runtime_host_preflight_test.go @@ -3,6 +3,7 @@ package dockerdeploy import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -79,6 +80,75 @@ func TestValidateRuntimeHostSourcesV1RejectsChangedFilesystemKind(t *testing.T) } } +func TestValidateRuntimeHostSourcesV1RejectsProtectedHostSystemTrees(t *testing.T) { + root := string(filepath.Separator) + if volume := filepath.VolumeName(t.TempDir()); volume != "" { + root = volume + string(filepath.Separator) + } + type testCase struct { + name string + path string + kind string + } + tests := []testCase{ + {name: "filesystem root", path: root, kind: deploy.RuntimeMountSourceDirectory}, + } + if runtime.GOOS != "windows" { + tests = append(tests, + testCase{name: "proc descendant", path: "/proc/self", kind: deploy.RuntimeMountSourceDirectory}, + testCase{name: "proc process-relative directory", path: "/proc/self/cwd", kind: deploy.RuntimeMountSourceDirectory}, + testCase{name: "dev descendant", path: "/dev/null", kind: deploy.RuntimeMountSourceFile}, + testCase{name: "sys", path: "/sys", kind: deploy.RuntimeMountSourceDirectory}, + ) + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := os.Stat(test.path); os.IsNotExist(err) { + t.Skipf("host path %q is absent", test.path) + } else if err != nil { + t.Fatal(err) + } + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/host", SourceKind: test.kind, ReadOnly: true, + }}) + err := ValidateRuntimeHostSourcesV1(policy, "command/check", 1000, []RuntimeHostSourceV1{{ + Destination: "/mnt/host", HostPath: test.path, SourceKind: test.kind, + Authority: runtimeHostAuthorityInputV1, ReadOnly: true, + }}) + if err == nil || !strings.Contains(err.Error(), "protected host system source") { + t.Fatalf("protected host tree error = %v", err) + } + }) + } +} + +func TestValidateRuntimeHostSourcesV1RejectsProtectedHostSystemTreeAlias(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("creating a directory symlink requires additional privileges on Windows") + } + protected := "/dev" + if _, err := os.Stat(protected); os.IsNotExist(err) { + t.Skipf("host path %q is absent", protected) + } else if err != nil { + t.Fatal(err) + } + alias := filepath.Join(t.TempDir(), "device-alias") + if err := os.Symlink(protected, alias); err != nil { + t.Fatal(err) + } + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/host", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + }}) + err := ValidateRuntimeHostSourcesV1(policy, "command/check", 1000, []RuntimeHostSourceV1{{ + Destination: "/mnt/host", HostPath: alias, SourceKind: deploy.RuntimeMountSourceDirectory, + Authority: runtimeHostAuthorityInputV1, ReadOnly: true, + }}) + if err == nil || !strings.Contains(err.Error(), "protected host system source") { + t.Fatalf("protected host tree alias error = %v", err) + } +} + func TestValidateRuntimeHostSourcesV1RejectsEveryHostAuthorityForRoot(t *testing.T) { root := t.TempDir() for _, test := range []struct { diff --git a/internal/dockerdeploy/runtime_host_preflight_unix_test.go b/internal/dockerdeploy/runtime_host_preflight_unix_test.go new file mode 100644 index 00000000..95e6e3ad --- /dev/null +++ b/internal/dockerdeploy/runtime_host_preflight_unix_test.go @@ -0,0 +1,27 @@ +//go:build linux || darwin + +package dockerdeploy + +import ( + "path/filepath" + "syscall" + "testing" + + "github.com/omry/reploy/internal/deploy" +) + +func TestValidateRuntimeHostSourcesV1PreservesExplicitDirectoryWithNestedSpecialObject(t *testing.T) { + root := t.TempDir() + if err := syscall.Mkfifo(filepath.Join(root, "service.pipe"), 0o600); err != nil { + t.Fatal(err) + } + policy := runtimeHostPolicy([]deploy.RuntimeMountV1{{ + Destination: "/mnt/project", SourceKind: deploy.RuntimeMountSourceDirectory, ReadOnly: true, + }}) + if err := ValidateRuntimeHostSourcesV1(policy, "command/check", 1000, []RuntimeHostSourceV1{{ + Destination: "/mnt/project", HostPath: root, SourceKind: deploy.RuntimeMountSourceDirectory, + Authority: runtimeHostAuthorityInputV1, ReadOnly: true, + }}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/dockerdeploy/test.go b/internal/dockerdeploy/test.go index 9065b858..8c85e6c6 100644 --- a/internal/dockerdeploy/test.go +++ b/internal/dockerdeploy/test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io" + "os" "os/exec" "strings" "time" @@ -163,12 +164,17 @@ func commandOutput(spec CommandSpec, options RunOptions) ([]byte, error) { ctx = context.Background() } if spec.Name == "docker" { - if err := dockerPreflight(ctx, spec, effectiveDockerPreflightTimeout(options.DockerPreflightTimeout)); err != nil { + endpoint, err := dockerPreflight(ctx, spec, effectiveDockerPreflightTimeout(options.DockerPreflightTimeout)) + if err != nil { return nil, err } + spec = pinDockerEndpointV1(spec, endpoint) } command := exec.CommandContext(ctx, spec.Name, spec.Args...) command.Dir = spec.Dir + if len(spec.Env) > 0 { + command.Env = append(os.Environ(), spec.Env...) + } var stdout bytes.Buffer var stderr bytes.Buffer command.Stdout = &stdout