diff --git a/.changes/unreleased/+application-kernel-sandbox.yaml b/.changes/unreleased/+application-kernel-sandbox.yaml new file mode 100644 index 00000000..b2ebbad5 --- /dev/null +++ b/.changes/unreleased/+application-kernel-sandbox.yaml @@ -0,0 +1,2 @@ +kind: Security +body: Run application containers with canonical supplementary groups, no Linux capabilities, no-new-privileges, and Docker's built-in seccomp profile. diff --git a/docs/APT_PROVIDER_DETAIL_DESIGN.md b/docs/APT_PROVIDER_DETAIL_DESIGN.md index 13dcedd7..a542b45a 100644 --- a/docs/APT_PROVIDER_DETAIL_DESIGN.md +++ b/docs/APT_PROVIDER_DETAIL_DESIGN.md @@ -2166,14 +2166,13 @@ an adjacent hidden staging directory and publishes its fixed regular-file result atomically after success. The chosen host path is operation state, not a build identity input. -Each one-shot command and `reploy shell` mounts a fresh anonymous Docker volume -at `/mnt/reploy-home` for `HOME` and `TMPDIR`. It is disk-backed, unnamed, and -removed with the transient container. The embedded platform helper assigns the -empty volume to the selected runtime UID/GID, restricts it to that identity, -drops root privileges, and directly executes the requested command. Explicit -interruption cleanup uses forced container removal with anonymous-volume -removal. Workload containers retain a separate tmpfs home at -`/mnt/reploy-home`. +Each one-shot command and `reploy shell` mounts a fresh 64 MiB tmpfs at +`/mnt/reploy-home` for `HOME` and `TMPDIR`. The mount is mode `0700`, owned by +the selected runtime UID/GID, and removed with the transient container. Docker +starts the resolved executable directly under that final numeric identity; no +root bootstrap helper is involved. Explicit interruption cleanup force-removes +the transient container. Workload containers use the same bounded tmpfs-home +policy at `/mnt/reploy-home`. The policy digest is `canonical.Sum("runtime-policy", "runtime-policy-v1", policy)`. It is recorded diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index ff0bce09..33df94ac 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -70,6 +70,16 @@ This file is the day-to-day queue for design and implementation gaps. protection, build locks, cache identity, and actionable missing-export diagnostics. Do not add a parallel direct-command form such as `base.rm`. +- [ ] `P1` Implement the promised Docker Desktop runtime identity. + Current macOS and Windows user-scope planning does not provide the stable + Reploy-managed non-root Linux identity described by the environment + model; native Windows runtime discovery can currently collapse to + container identity `0:0`. Define and persist a non-root identity for + Docker Desktop, propagate its canonical supplementary groups, reject + accidental root, and add native macOS/Windows plus Docker integration + evidence across staged and installed current-user workloads and + transient commands. + - [ ] `P2` Define cancellation at the admission boundary. Specify the authoritative outcome when a waiting caller is promoted at the same instant its context is cancelled. Preserve the invariant that diff --git a/docs/BLUEPRINT_ENVIRONMENT_MODEL.md b/docs/BLUEPRINT_ENVIRONMENT_MODEL.md index 34c5abf1..4bc80919 100644 --- a/docs/BLUEPRINT_ENVIRONMENT_MODEL.md +++ b/docs/BLUEPRINT_ENVIRONMENT_MODEL.md @@ -277,16 +277,14 @@ recovery. The two output options are mutually exclusive. Reploy never discovers or copies arbitrary files from the transient container. The transient `$HOME` is operation-local workspace, not an output channel. -Docker backs `/mnt/reploy-home` with a fresh anonymous volume for each one-shot -container. Normal `--rm` completion removes the volume, and Reploy's -interruption cleanup force-removes the container with its anonymous volumes. -It is disk-backed rather than a size-limited tmpfs, but it is never named or -reused. Before launching the requested command, Reploy's embedded Linux helper -assigns the empty volume to the selected runtime UID/GID, restricts it to that -identity, drops root privileges, and directly executes the command. Files -survive a one-shot invocation only through its selected output contract or -another writable mount declared by the blueprint. The persistent workload -container keeps its separate tmpfs home at the same container path. +Docker backs `/mnt/reploy-home` with a fresh 64 MiB tmpfs for each one-shot +container. The mount is restricted to mode `0700`, owned by the selected +runtime UID/GID, and removed with the container. Docker starts the resolved +executable directly as that final numeric identity; no root bootstrap helper +is involved. Files survive a one-shot invocation only through its selected +output contract or another writable mount declared by the blueprint. The +persistent workload container uses the same bounded tmpfs-home policy at the +same container path. Lifecycle actions invoke commands through the same one-shot mechanism. An `after_start` command may communicate with the running workload through its diff --git a/docs/CONTROLLED_SESSION_DESIGN.md b/docs/CONTROLLED_SESSION_DESIGN.md index 15c4e9c6..c840ca6c 100644 --- a/docs/CONTROLLED_SESSION_DESIGN.md +++ b/docs/CONTROLLED_SESSION_DESIGN.md @@ -1038,6 +1038,17 @@ ordinary Reploy application containers. Prove staged workloads, installed workloads, transient commands, shells, and later controlled sessions consume the same baseline. This is global runtime work, not controlled-session code. +Implementation status: the canonical application sandbox plan and its identity +and kernel baseline are implemented for persistent Compose workloads and +transient application commands. Reploy now imports canonical supplementary +groups, rejects root-group membership for non-root identities, starts transient +commands directly as the final identity, drops all capabilities, enables +`no-new-privileges`, explicitly selects Docker's built-in seccomp profile, and +prohibits privileged mode, host namespaces, and host devices in the common +plan. Live Docker tests inspect both runtime paths. Trusted production startup +verification, mount/root authority, network denial, and resource limits remain +separate prerequisite slices. + ### Slice 2: Controlled-Session Lifecycle Core Using synthetic controller and workload images with networking disabled: diff --git a/docs/REDESIGN_EVALUATION.md b/docs/REDESIGN_EVALUATION.md index 02ba0ddd..6d68ba00 100644 --- a/docs/REDESIGN_EVALUATION.md +++ b/docs/REDESIGN_EVALUATION.md @@ -32,8 +32,10 @@ integration testing against a real Docker daemon. Fix operation-local home initialization for non-root transient containers. The solution must apply consistently to shells, app commands, and lifecycle -actions; preserve the read-only image filesystem; retain anonymous-volume -cleanup; and avoid host-owned persistent state. +actions; preserve the read-only image filesystem; retain operation-local home +isolation; and avoid host-owned persistent state. The original anonymous-volume +implementation was subsequently simplified to a bounded UID/GID-owned tmpfs, +which also removed the trusted root bootstrap helper. **Primary finding:** `Transient shell home is not writable by the runtime user`. @@ -42,7 +44,7 @@ cleanup; and avoid host-owned persistent state. - a transient container running as a numeric non-root UID/GID can create, modify, and remove files under `$HOME` and `$TMPDIR`; - its image filesystem remains read-only; -- its anonymous home volume is removed after normal exit and forced stop; and +- its bounded tmpfs home is removed after normal exit and forced stop; and - shell, app-command, and lifecycle integration tests exercise the shared path. ### Slice 2: Stop app commands from refreshing the environment diff --git a/internal/dockerdeploy/application_sandbox_plan.go b/internal/dockerdeploy/application_sandbox_plan.go index b359b2ae..698442a7 100644 --- a/internal/dockerdeploy/application_sandbox_plan.go +++ b/internal/dockerdeploy/application_sandbox_plan.go @@ -3,9 +3,21 @@ package dockerdeploy import ( "fmt" "path" + "slices" "strconv" ) +const applicationSeccompProfileBuiltinV1 = "builtin" + +type ApplicationKernelPolicyV1 struct { + DropAllCapabilities bool + NoNewPrivileges bool + SeccompProfile string + Privileged bool + HostNamespaces []string + HostDevices []string +} + // ApplicationSandboxPlanV1 is the common security boundary consumed by every // application-container renderer. It contains only policies that Reploy // currently enforces; later sandbox slices extend this plan rather than adding @@ -14,6 +26,7 @@ type ApplicationSandboxPlanV1 struct { RuntimeUser RuntimeUserPlan ReadOnlyRoot bool TemporaryHome string + Kernel ApplicationKernelPolicyV1 } func newApplicationSandboxPlanV1(runtimeUser RuntimeUserPlan) ApplicationSandboxPlanV1 { @@ -21,6 +34,13 @@ func newApplicationSandboxPlanV1(runtimeUser RuntimeUserPlan) ApplicationSandbox RuntimeUser: runtimeUser, ReadOnlyRoot: true, TemporaryHome: environmentTemporaryHome, + Kernel: ApplicationKernelPolicyV1{ + DropAllCapabilities: true, + NoNewPrivileges: true, + SeccompProfile: applicationSeccompProfileBuiltinV1, + HostNamespaces: []string{}, + HostDevices: []string{}, + }, } } @@ -32,11 +52,57 @@ func ValidateApplicationSandboxPlanV1(plan ApplicationSandboxPlanV1) error { if plan.RuntimeUser.DockerUser != wantUser { return fmt.Errorf("application sandbox Docker user must match its numeric UID and GID") } + wantGroups, err := normalizeSupplementaryGIDsV1(plan.RuntimeUser.GID, plan.RuntimeUser.SupplementaryGIDs) + if err != nil { + return fmt.Errorf("application sandbox supplementary groups: %w", err) + } + if !slices.Equal(plan.RuntimeUser.SupplementaryGIDs, wantGroups) { + return fmt.Errorf("application sandbox supplementary groups must be unique, sorted, and exclude the primary GID") + } + if plan.RuntimeUser.UID != 0 { + if plan.RuntimeUser.GID == 0 || slices.Contains(plan.RuntimeUser.SupplementaryGIDs, 0) { + return fmt.Errorf("non-root application sandbox identity must not include the root group") + } + } if !plan.ReadOnlyRoot { return fmt.Errorf("application sandbox requires a read-only container root") } if plan.TemporaryHome != environmentTemporaryHome || !path.IsAbs(plan.TemporaryHome) || path.Clean(plan.TemporaryHome) != plan.TemporaryHome { return fmt.Errorf("application sandbox temporary home must be %s", environmentTemporaryHome) } + if !plan.Kernel.DropAllCapabilities { + return fmt.Errorf("application sandbox must drop all Linux capabilities") + } + if !plan.Kernel.NoNewPrivileges { + return fmt.Errorf("application sandbox must enable no-new-privileges") + } + if plan.Kernel.SeccompProfile != applicationSeccompProfileBuiltinV1 { + return fmt.Errorf("application sandbox seccomp profile must be %q", applicationSeccompProfileBuiltinV1) + } + if plan.Kernel.Privileged { + return fmt.Errorf("application sandbox must not use privileged mode") + } + if plan.Kernel.HostNamespaces == nil || len(plan.Kernel.HostNamespaces) != 0 { + return fmt.Errorf("application sandbox must prohibit host namespaces") + } + if plan.Kernel.HostDevices == nil || len(plan.Kernel.HostDevices) != 0 { + return fmt.Errorf("application sandbox must prohibit host devices") + } return nil } + +func normalizeSupplementaryGIDsV1(primary int, values []int) ([]int, error) { + result := append([]int(nil), values...) + for _, gid := range result { + if gid < 0 { + return nil, fmt.Errorf("GID must be non-negative") + } + } + slices.Sort(result) + result = slices.Compact(result) + result = slices.DeleteFunc(result, func(gid int) bool { return gid == primary }) + if result == nil { + result = []int{} + } + return result, nil +} diff --git a/internal/dockerdeploy/application_sandbox_plan_test.go b/internal/dockerdeploy/application_sandbox_plan_test.go index 7c0d32d9..f27f4b80 100644 --- a/internal/dockerdeploy/application_sandbox_plan_test.go +++ b/internal/dockerdeploy/application_sandbox_plan_test.go @@ -1,6 +1,7 @@ package dockerdeploy import ( + "slices" "strings" "testing" @@ -9,11 +10,6 @@ import ( ) func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) { - platform, err := blueprint.ParsePlatform("linux/amd64") - if err != nil { - t.Fatal(err) - } - workspace := testPreparedProbeWorkspace(t, platform, t.TempDir()) plan := DockerExecutionPlan{ EnvironmentID: "demo", DeploymentDir: t.TempDir(), @@ -22,7 +18,7 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) { ContainerName: "demo-staging-abcd", NetworkName: "demo-staging-abcd", Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{ - UID: 501, GID: 20, DockerUser: "501:20", + UID: 501, GID: 20, SupplementaryGIDs: []int{33, 44}, DockerUser: "501:20", }), } @@ -38,6 +34,9 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) { if service.User != plan.Sandbox.RuntimeUser.DockerUser || !service.ReadOnly { t.Fatalf("persistent sandbox identity/read-only = user %q, read-only %t", service.User, service.ReadOnly) } + if !slices.Equal(service.GroupAdd, []string{"33", "44"}) || !slices.Equal(service.CapDrop, []string{"ALL"}) || !slices.Equal(service.SecurityOpt, []string{"no-new-privileges:true", "seccomp=builtin"}) { + t.Fatalf("persistent kernel sandbox = groups %#v, caps %#v, security %#v", service.GroupAdd, service.CapDrop, service.SecurityOpt) + } if service.Environment["HOME"] != plan.Sandbox.TemporaryHome || service.Environment["TMPDIR"] != plan.Sandbox.TemporaryHome { t.Fatalf("persistent sandbox environment = %#v", service.Environment) } @@ -48,7 +47,6 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) { transient, err := TransientCommandSpec( plan, ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}}, - workspace, nil, false, false, @@ -56,7 +54,7 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) { if err != nil { t.Fatal(err) } - if !containsInOrder(transient.Args, []string{"--read-only", "--mount", transientHomeMountForPlan(plan)}) { + if !containsInOrder(transient.Args, []string{"--read-only", "--tmpfs", transientHomeMountForPlan(plan)}) { t.Fatalf("transient sandbox read-only root/home = %#v", transient.Args) } if !containsInOrder(transient.Args, []string{ @@ -65,10 +63,10 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) { }) { t.Fatalf("transient sandbox environment = %#v", transient.Args) } - if !containsInOrder(transient.Args, []string{ - "--entrypoint", ProbeContainerExecutable, - plan.Image, "run-transient", "501", "20", "/bin/true", - }) { + if !containsInOrder(transient.Args, []string{"--user", "501:20", "--cap-drop", "ALL"}) || + !containsInOrder(transient.Args, []string{"--group-add", "33", "--group-add", "44"}) || + !containsInOrder(transient.Args, []string{"--security-opt", "no-new-privileges=true", "--security-opt", "seccomp=builtin"}) || + !containsInOrder(transient.Args, []string{"--entrypoint", "/bin/true", plan.Image}) { t.Fatalf("transient sandbox runtime identity = %#v", transient.Args) } @@ -77,11 +75,44 @@ func TestApplicationRenderersConsumeCanonicalSandboxPlan(t *testing.T) { if _, err := RenderDockerInputs(invalid, "demo"); err == nil || !strings.Contains(err.Error(), "read-only container root") { t.Fatalf("persistent invalid sandbox error = %v", err) } - if _, err := TransientCommandSpec(invalid, ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}}, workspace, nil, false, false); err == nil || !strings.Contains(err.Error(), "read-only container root") { + if _, err := TransientCommandSpec(invalid, ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}}, nil, false, false); err == nil || !strings.Contains(err.Error(), "read-only container root") { t.Fatalf("transient invalid sandbox error = %v", err) } } +func TestApplicationSandboxPlanRejectsIdentityAndKernelEscapes(t *testing.T) { + base := newApplicationSandboxPlanV1(RuntimeUserPlan{ + UID: 501, GID: 20, SupplementaryGIDs: []int{33, 44}, DockerUser: "501:20", + }) + tests := []struct { + name string + mutate func(*ApplicationSandboxPlanV1) + want string + }{ + {name: "root primary group", mutate: func(plan *ApplicationSandboxPlanV1) { plan.RuntimeUser.GID, plan.RuntimeUser.DockerUser = 0, "501:0" }, want: "root group"}, + {name: "root supplementary group", mutate: func(plan *ApplicationSandboxPlanV1) { plan.RuntimeUser.SupplementaryGIDs = []int{0, 33} }, want: "root group"}, + {name: "noncanonical groups", mutate: func(plan *ApplicationSandboxPlanV1) { plan.RuntimeUser.SupplementaryGIDs = []int{44, 33} }, want: "unique, sorted"}, + {name: "capabilities", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.DropAllCapabilities = false }, want: "drop all"}, + {name: "privilege escalation", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.NoNewPrivileges = false }, want: "no-new-privileges"}, + {name: "seccomp", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.SeccompProfile = "" }, want: "seccomp"}, + {name: "privileged", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.Privileged = true }, want: "privileged"}, + {name: "host namespace", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.HostNamespaces = []string{"pid"} }, want: "host namespaces"}, + {name: "host device", mutate: func(plan *ApplicationSandboxPlanV1) { plan.Kernel.HostDevices = []string{"/dev/kvm"} }, want: "host devices"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan := base + plan.RuntimeUser.SupplementaryGIDs = append([]int(nil), base.RuntimeUser.SupplementaryGIDs...) + plan.Kernel.HostNamespaces = []string{} + plan.Kernel.HostDevices = []string{} + test.mutate(&plan) + if err := ValidateApplicationSandboxPlanV1(plan); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } +} + func containsString(values []string, want string) bool { for _, value := range values { if value == want { diff --git a/internal/dockerdeploy/command_execution.go b/internal/dockerdeploy/command_execution.go index 20129990..b5227121 100644 --- a/internal/dockerdeploy/command_execution.go +++ b/internal/dockerdeploy/command_execution.go @@ -5,12 +5,10 @@ import ( "path" "path/filepath" "sort" - "strconv" "strings" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" - "github.com/omry/reploy/internal/probe" "github.com/omry/reploy/internal/providers" ) @@ -166,14 +164,13 @@ func validateForwardedArguments(commandName string, allowedFlags []string, argum return result, nil } -func TransientCommandSpec(plan DockerExecutionPlan, command ResolvedEnvironmentCommand, workspace PreparedProbeWorkspace, output *transientOutputMount, interactive bool, tty bool) (CommandSpec, error) { - return transientContainerCommandSpecV1("run", transientCommandContainerName(plan), plan, command, workspace, output, interactive, tty) +func TransientCommandSpec(plan DockerExecutionPlan, command ResolvedEnvironmentCommand, output *transientOutputMount, interactive bool, tty bool) (CommandSpec, error) { + return transientContainerCommandSpecV1("run", transientCommandContainerName(plan), plan, command, output, interactive, tty) } func PlanTransientContainerExecutionV1( plan DockerExecutionPlan, command ResolvedEnvironmentCommand, - workspace PreparedProbeWorkspace, output *transientOutputMount, runID string, interactive bool, @@ -186,7 +183,7 @@ func PlanTransientContainerExecutionV1( return TransientContainerExecutionV1{}, fmt.Errorf("transient container execution requires a base container name") } container := plan.ContainerName + "-" + runID - create, err := transientContainerCommandSpecV1("create", container, plan, command, workspace, output, interactive, tty) + create, err := transientContainerCommandSpecV1("create", container, plan, command, output, interactive, tty) if err != nil { return TransientContainerExecutionV1{}, err } @@ -203,7 +200,7 @@ func PlanTransientContainerExecutionV1( }, nil } -func transientContainerCommandSpecV1(operation string, container string, plan DockerExecutionPlan, command ResolvedEnvironmentCommand, workspace PreparedProbeWorkspace, output *transientOutputMount, interactive bool, tty bool) (CommandSpec, error) { +func transientContainerCommandSpecV1(operation string, container string, plan DockerExecutionPlan, command ResolvedEnvironmentCommand, output *transientOutputMount, interactive bool, tty bool) (CommandSpec, error) { if len(command.Argv) == 0 || !path.IsAbs(command.Argv[0]) { return CommandSpec{}, fmt.Errorf("transient command requires an absolute resolved executable") } @@ -216,35 +213,22 @@ func transientContainerCommandSpecV1(operation string, container string, plan Do if err := ValidateApplicationSandboxPlanV1(plan.Sandbox); err != nil { return CommandSpec{}, fmt.Errorf("prepare transient application sandbox: %w", err) } - if err := validatePreparedProbeWorkspaceShape(workspace); err != nil { - return CommandSpec{}, fmt.Errorf("transient helper: %w", err) - } - runtimeUID := strconv.Itoa(plan.Sandbox.RuntimeUser.UID) - runtimeGID := strconv.Itoa(plan.Sandbox.RuntimeUser.GID) home := temporaryHomeForPlan(plan) - if home != probe.TransientHome { - return CommandSpec{}, fmt.Errorf("transient home must be %s", probe.TransientHome) - } - homeMount, err := dockerMountArgument("type=volume", "destination="+home, "volume-nocopy") - if err != nil { - return CommandSpec{}, fmt.Errorf("render transient home mount: %w", err) - } - helperMount, err := dockerMountArgument( - "type=bind", "source="+workspace.HostDir, "target="+workspace.ContainerDir, "readonly", - ) - if err != nil { - return CommandSpec{}, fmt.Errorf("render transient helper mount: %w", err) - } args := []string{ operation, "--pull", "never", "--rm", "--name", container, - "--user", "0:0", + "--user", plan.Sandbox.RuntimeUser.DockerUser, + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges=true", + "--security-opt", "seccomp=" + plan.Sandbox.Kernel.SeccompProfile, + } + for _, group := range dockerSupplementaryGroupsV1(plan.Sandbox.RuntimeUser.SupplementaryGIDs) { + args = append(args, "--group-add", group) } if plan.Sandbox.ReadOnlyRoot { args = append(args, "--read-only") } args = append(args, - "--mount", homeMount, - "--mount", helperMount, + "--tmpfs", transientHomeMountForPlan(plan), "--env", "HOME="+home, "--env", "TMPDIR="+home, ) if interactive { @@ -299,10 +283,10 @@ func transientContainerCommandSpecV1(operation string, container string, plan Do ) } args = append(args, - "--entrypoint", workspace.ContainerExecutable, - plan.Image, "run-transient", runtimeUID, runtimeGID, + "--entrypoint", command.Argv[0], + plan.Image, ) - args = append(args, command.Argv...) + args = append(args, command.Argv[1:]...) return CommandSpec{Name: "docker", Args: args}, nil } @@ -310,9 +294,9 @@ func transientCommandContainerName(plan DockerExecutionPlan) string { return temporaryOneOffContainerName(plan.ContainerName, "command") } -func ShellCommandSpec(plan DockerExecutionPlan, workspace PreparedProbeWorkspace, interactive bool, tty bool) CommandSpec { +func ShellCommandSpec(plan DockerExecutionPlan, interactive bool, tty bool) CommandSpec { command := ResolvedEnvironmentCommand{Argv: []string{"/bin/sh"}} - spec, _ := TransientCommandSpec(plan, command, workspace, nil, interactive, tty) + spec, _ := TransientCommandSpec(plan, command, nil, interactive, tty) return spec } diff --git a/internal/dockerdeploy/command_execution_integration_test.go b/internal/dockerdeploy/command_execution_integration_test.go index a29fcbc3..eb0e15d7 100644 --- a/internal/dockerdeploy/command_execution_integration_test.go +++ b/internal/dockerdeploy/command_execution_integration_test.go @@ -4,36 +4,28 @@ import ( "context" "os" "os/exec" - "path/filepath" "strings" "testing" - - "github.com/omry/reploy/internal/blueprint" ) -func TestTransientCommandDockerIntegrationInitializesPrivateHomeAndDropsPrivileges(t *testing.T) { +func TestTransientCommandDockerIntegrationEnforcesIdentityAndKernelBaseline(t *testing.T) { if os.Getenv("REPLOY_DOCKER_INTEGRATION") != "1" { t.Skip("set REPLOY_DOCKER_INTEGRATION=1 to run Docker integration evidence") } ctx := context.Background() - helperDir := t.TempDir() - helper := filepath.Join(helperDir, "reploy-probe") - build := exec.CommandContext(ctx, "go", "build", "-buildvcs=false", "-o", helper, "github.com/omry/reploy/cmd/reploy-probe") - if output, err := build.CombinedOutput(); err != nil { - t.Fatalf("build transient helper: %v\n%s", err, output) - } - platform, err := blueprint.ParsePlatform("linux/amd64") - if err != nil { - t.Fatal(err) - } - workspace := testPreparedProbeWorkspace(t, platform, helperDir) plan := DockerExecutionPlan{ DeploymentDir: t.TempDir(), Image: "debian:bookworm-slim", ContainerName: "reploy-transient-home-integration", - Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 12345, GID: 23456, DockerUser: "12345:23456"}), + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 12345, GID: 23456, SupplementaryGIDs: []int{34567, 45678}, DockerUser: "12345:23456"}), } command := ResolvedEnvironmentCommand{Argv: []string{ "/bin/sh", "-eu", "-c", `test "$(id -u):$(id -g)" = "12345:23456" +test "$(id -G)" = "23456 34567 45678" +test "$(awk '/^CapEff:/ {print $2}' /proc/self/status)" = "0000000000000000" +test "$(awk '/^CapPrm:/ {print $2}' /proc/self/status)" = "0000000000000000" +test "$(awk '/^CapBnd:/ {print $2}' /proc/self/status)" = "0000000000000000" +test "$(awk '/^NoNewPrivs:/ {print $2}' /proc/self/status)" = "1" +test "$(awk '/^Seccomp:/ {print $2}' /proc/self/status)" = "2" test "$HOME" = "/mnt/reploy-home" test "$TMPDIR" = "$HOME" printf writable > "$HOME/proof" @@ -48,7 +40,7 @@ if touch /reploy-root-proof 2>/dev/null; then exit 41; fi printf 'transient-home-pass\n'`, }} execution, err := PlanTransientContainerExecutionV1( - plan, command, workspace, nil, "run-0000000000000001", false, false, + plan, command, nil, "run-0000000000000001", false, false, ) if err != nil { t.Fatal(err) @@ -57,18 +49,16 @@ printf 'transient-home-pass\n'`, _ = exec.CommandContext(context.Background(), "docker", execution.Cleanup.Args...).Run() }) runDockerIntegration(t, ctx, execution.Create.Args...) - volume := transientHomeVolumeName(t, ctx, execution.Container) output := runDockerIntegration(t, ctx, execution.Start.Args...) if strings.TrimSpace(output) != "transient-home-pass" { t.Fatalf("transient command output = %q", output) } - requireDockerObjectMissing(t, ctx, "volume", volume) requireDockerObjectMissing(t, ctx, "container", execution.Container) forced, err := PlanTransientContainerExecutionV1( plan, ResolvedEnvironmentCommand{Argv: []string{"/bin/sleep", "300"}}, - workspace, nil, "run-0000000000000002", false, false, + nil, "run-0000000000000002", false, false, ) if err != nil { t.Fatal(err) @@ -77,27 +67,11 @@ printf 'transient-home-pass\n'`, _ = exec.CommandContext(context.Background(), "docker", forced.Cleanup.Args...).Run() }) runDockerIntegration(t, ctx, forced.Create.Args...) - forcedVolume := transientHomeVolumeName(t, ctx, forced.Container) runDockerIntegration(t, ctx, "start", forced.Container) runDockerIntegration(t, ctx, forced.Cleanup.Args...) - requireDockerObjectMissing(t, ctx, "volume", forcedVolume) requireDockerObjectMissing(t, ctx, "container", forced.Container) } -func transientHomeVolumeName(t *testing.T, ctx context.Context, container string) string { - t.Helper() - value := runDockerIntegration( - t, ctx, "container", "inspect", - "--format", `{{range .Mounts}}{{if eq .Destination "/mnt/reploy-home"}}{{.Name}}{{end}}{{end}}`, - container, - ) - value = strings.TrimSpace(value) - if value == "" { - t.Fatalf("container %s has no anonymous transient-home volume", container) - } - return value -} - func requireDockerObjectMissing(t *testing.T, ctx context.Context, kind string, name string) { t.Helper() if err := exec.CommandContext(ctx, "docker", kind, "inspect", name).Run(); err == nil { diff --git a/internal/dockerdeploy/command_execution_test.go b/internal/dockerdeploy/command_execution_test.go index 1915787d..ac2b19e3 100644 --- a/internal/dockerdeploy/command_execution_test.go +++ b/internal/dockerdeploy/command_execution_test.go @@ -76,22 +76,16 @@ func TestMatchEnvironmentCommandLongestTriggerAndForwarding(t *testing.T) { } func TestTransientAndShellCommandsUseDockerExecArgv(t *testing.T) { - platform, err := blueprint.ParsePlatform("linux/amd64") - if err != nil { - t.Fatal(err) - } - probeDir := t.TempDir() - workspace := testPreparedProbeWorkspace(t, platform, probeDir) mountDir := t.TempDir() outputDir := t.TempDir() - plan := DockerExecutionPlan{DeploymentDir: t.TempDir(), Image: "reploy/demo:staging", ContainerName: "demo", Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}), Mounts: []MountExecutionPlan{{Mode: blueprint.MountManagedBind, Source: mountDir, Target: "/conf", ReadOnly: true}}} + plan := DockerExecutionPlan{DeploymentDir: t.TempDir(), Image: "reploy/demo:staging", ContainerName: "demo", Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 501, GID: 20, SupplementaryGIDs: []int{33, 44}, DockerUser: "501:20"}), Mounts: []MountExecutionPlan{{Mode: blueprint.MountManagedBind, Source: mountDir, Target: "/conf", ReadOnly: true}}} output := &transientOutputMount{HostDirectory: outputDir, Variable: runtimeOutputFileVariable, ContainerPath: runtimeOutputRoot + "/output"} - spec, err := TransientCommandSpec(plan, ResolvedEnvironmentCommand{Argv: []string{"/opt/demo", ";rm", "$(touch pwned)"}}, workspace, output, true, false) + spec, err := TransientCommandSpec(plan, ResolvedEnvironmentCommand{Argv: []string{"/opt/demo", ";rm", "$(touch pwned)"}}, output, true, false) if err != nil { t.Fatal(err) } joined := strings.Join(spec.Args, "|") - if !strings.Contains(joined, "/opt/demo|;rm|$(touch pwned)") || strings.Contains(joined, "sh|-c") { + if strings.Contains(joined, "sh|-c") || !reflect.DeepEqual(spec.Args[len(spec.Args)-5:], []string{"--entrypoint", "/opt/demo", plan.Image, ";rm", "$(touch pwned)"}) { t.Fatalf("spec = %#v", spec) } if !containsInOrder(spec.Args, []string{"--mount", "type=bind,source=" + outputDir + ",target=" + runtimeOutputRoot, "--env", runtimeOutputFileVariable + "=" + runtimeOutputRoot + "/output"}) { @@ -100,44 +94,28 @@ func TestTransientAndShellCommandsUseDockerExecArgv(t *testing.T) { if !containsAdjacent(spec.Args, "--pull", "never") { t.Fatalf("transient command permits image pulls: %#v", spec.Args) } - if containsAdjacent(spec.Args, "--user", plan.Sandbox.RuntimeUser.DockerUser) { - t.Fatalf("transient container starts as the runtime user before its anonymous home is initialized: %#v", spec.Args) - } - if !containsInOrder(spec.Args, []string{"--user", "0:0"}) || - !containsInOrder(spec.Args, []string{"--mount", "type=bind,source=" + probeDir + ",target=" + ProbeContainerRoot + ",readonly"}) || - !containsInOrder(spec.Args, []string{ - "--entrypoint", ProbeContainerExecutable, - plan.Image, "run-transient", "501", "20", - "/opt/demo", ";rm", "$(touch pwned)", - }) { - t.Fatalf("transient command does not initialize its anonymous home and drop to the runtime user: %#v", spec.Args) + if !containsInOrder(spec.Args, []string{"--user", "501:20", "--cap-drop", "ALL"}) || + !containsInOrder(spec.Args, []string{"--group-add", "33", "--group-add", "44"}) || + !containsInOrder(spec.Args, []string{"--entrypoint", "/opt/demo", plan.Image, ";rm", "$(touch pwned)"}) { + t.Fatalf("transient command does not start directly with its final identity and command: %#v", spec.Args) } - shell := ShellCommandSpec(plan, workspace, true, true) - if !strings.Contains(strings.Join(shell.Args, " "), "--interactive --tty") || shell.Args[len(shell.Args)-1] != "/bin/sh" { + shell := ShellCommandSpec(plan, true, true) + if !strings.Contains(strings.Join(shell.Args, " "), "--interactive --tty") || !containsInOrder(shell.Args, []string{"--entrypoint", "/bin/sh", plan.Image}) { t.Fatalf("shell = %#v", shell) } - if !containsInOrder(shell.Args, []string{"--read-only", "--mount", transientHomeMountForPlan(plan)}) || + if !containsInOrder(shell.Args, []string{"--read-only", "--tmpfs", transientHomeMountForPlan(plan)}) || !containsInOrder(shell.Args, []string{ "--env", "HOME=" + environmentTemporaryHome, "--env", "TMPDIR=" + environmentTemporaryHome, }) { - t.Fatalf("shell lacks a read-only root and anonymous temporary home: %#v", shell.Args) - } - if strings.Contains(strings.Join(shell.Args, " "), "--tmpfs") { - t.Fatalf("transient home unexpectedly uses tmpfs: %#v", shell.Args) + t.Fatalf("shell lacks a read-only root and private temporary home: %#v", shell.Args) } } func TestTransientCommandSpecQuotesCommaContainingMountFields(t *testing.T) { - platform, err := blueprint.ParsePlatform("linux/amd64") - if err != nil { - t.Fatal(err) - } hostRoot := t.TempDir() - probeDir := filepath.Join(hostRoot, "probe,workspace") mountDir := filepath.Join(hostRoot, "deployment,preview", "conf") outputDir := filepath.Join(hostRoot, "output,preview") - workspace := testPreparedProbeWorkspace(t, platform, probeDir) plan := DockerExecutionPlan{ DeploymentDir: t.TempDir(), Image: "reploy/demo:staging", ContainerName: "demo", Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}), @@ -150,13 +128,11 @@ func TestTransientCommandSpecQuotesCommaContainingMountFields(t *testing.T) { HostDirectory: outputDir, Variable: runtimeOutputDirectoryVariable, ContainerPath: runtimeOutputRoot, } - spec, err := TransientCommandSpec(plan, ResolvedEnvironmentCommand{Argv: []string{"/opt/demo"}}, workspace, output, false, false) + spec, err := TransientCommandSpec(plan, ResolvedEnvironmentCommand{Argv: []string{"/opt/demo"}}, output, false, false) if err != nil { t.Fatal(err) } if !containsInOrder(spec.Args, []string{ - "--mount", `type=bind,"source=` + probeDir + `",target=` + ProbeContainerRoot + ",readonly", - }) || !containsInOrder(spec.Args, []string{ "--mount", `type=bind,"target=/conf,preview","source=` + mountDir + `",readonly`, "--mount", `type=bind,"source=` + outputDir + `",target=` + runtimeOutputRoot, }) { @@ -165,10 +141,6 @@ func TestTransientCommandSpecQuotesCommaContainingMountFields(t *testing.T) { } func TestTransientCommandSpecMasksDeploymentPrivatePaths(t *testing.T) { - platform, err := blueprint.ParsePlatform("linux/amd64") - if err != nil { - t.Fatal(err) - } deploymentDir := t.TempDir() if err := os.MkdirAll(filepath.Join(deploymentDir, privateRuntimeMetadataDirectoryName), 0o700); err != nil { t.Fatal(err) @@ -176,7 +148,6 @@ func TestTransientCommandSpecMasksDeploymentPrivatePaths(t *testing.T) { if _, err := preparePrivateWorkloadEnvironmentV1(deploymentDir); err != nil { t.Fatal(err) } - workspace := testPreparedProbeWorkspace(t, platform, t.TempDir()) plan := DockerExecutionPlan{ DeploymentDir: deploymentDir, Image: "reploy/demo:staging", ContainerName: "demo", Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}), @@ -188,7 +159,6 @@ func TestTransientCommandSpecMasksDeploymentPrivatePaths(t *testing.T) { spec, err := TransientCommandSpec( plan, ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}}, - workspace, nil, false, false, @@ -206,11 +176,6 @@ func TestTransientCommandSpecMasksDeploymentPrivatePaths(t *testing.T) { } func TestPlanTransientContainerExecutionV1SeparatesCreateStartAndCleanup(t *testing.T) { - platform, err := blueprint.ParsePlatform("linux/amd64") - if err != nil { - t.Fatal(err) - } - workspace := testPreparedProbeWorkspace(t, platform, t.TempDir()) plan := DockerExecutionPlan{ DeploymentDir: t.TempDir(), Image: "reploy/demo:staging", ContainerName: "demo-staging-abcd", Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}), @@ -223,7 +188,6 @@ func TestPlanTransientContainerExecutionV1SeparatesCreateStartAndCleanup(t *test execution, err := PlanTransientContainerExecutionV1( plan, ResolvedEnvironmentCommand{Argv: []string{"/opt/demo", "export"}}, - workspace, output, "run-00010203040506ff", true, @@ -240,7 +204,7 @@ func TestPlanTransientContainerExecutionV1SeparatesCreateStartAndCleanup(t *test t.Fatalf("create prefix = %#v", execution.Create.Args) } if !containsInOrder(execution.Create.Args, []string{"--interactive", "--tty"}) || - !reflect.DeepEqual(execution.Create.Args[len(execution.Create.Args)-6:], []string{plan.Image, "run-transient", "501", "20", "/opt/demo", "export"}) { + !reflect.DeepEqual(execution.Create.Args[len(execution.Create.Args)-4:], []string{"--entrypoint", "/opt/demo", plan.Image, "export"}) { t.Fatalf("create args = %#v", execution.Create.Args) } if !reflect.DeepEqual(execution.Start.Args, []string{"start", "--attach", "--interactive", wantContainer}) { @@ -255,16 +219,11 @@ func TestPlanTransientContainerExecutionV1SeparatesCreateStartAndCleanup(t *test } func TestPlanTransientContainerExecutionV1RejectsInvalidIdentity(t *testing.T) { - platform, err := blueprint.ParsePlatform("linux/amd64") - if err != nil { - t.Fatal(err) - } - workspace := testPreparedProbeWorkspace(t, platform, t.TempDir()) command := ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}} - if _, err := PlanTransientContainerExecutionV1(DockerExecutionPlan{ContainerName: "demo"}, command, workspace, nil, "invalid", false, false); err == nil || !strings.Contains(err.Error(), "run ID") { + if _, err := PlanTransientContainerExecutionV1(DockerExecutionPlan{ContainerName: "demo"}, command, nil, "invalid", false, false); err == nil || !strings.Contains(err.Error(), "run ID") { t.Fatalf("invalid run ID error = %v", err) } - if _, err := PlanTransientContainerExecutionV1(DockerExecutionPlan{}, command, workspace, nil, "run-0000000000000001", false, false); err == nil || !strings.Contains(err.Error(), "base container name") { + if _, err := PlanTransientContainerExecutionV1(DockerExecutionPlan{}, command, nil, "run-0000000000000001", false, false); err == nil || !strings.Contains(err.Error(), "base container name") { t.Fatalf("missing base name error = %v", err) } } diff --git a/internal/dockerdeploy/current_app_command_run.go b/internal/dockerdeploy/current_app_command_run.go index 145f424f..47294fcc 100644 --- a/internal/dockerdeploy/current_app_command_run.go +++ b/internal/dockerdeploy/current_app_command_run.go @@ -42,8 +42,7 @@ type currentAppCommandRunBackendV1 struct { acquireLease func(*deploy.OperationLock, string) (*deploy.QueueEntryLeaseV1, error) await func(context.Context, string, *deploy.OperationLock, deploy.LiveRunV1, bool, io.Writer) (*deploy.OperationLock, error) runPublished func(context.Context, PublishedRuntimeContainerInput, PublishedRuntimeContainerRunnerV1) error - prepareProbe func(context.Context, providerstore.Store, blueprint.Platform) (PreparedProbeWorkspace, func() error, error) - execution func(DockerExecutionPlan, ResolvedEnvironmentCommand, PreparedProbeWorkspace, *transientOutputMount, string, bool, bool) (TransientContainerExecutionV1, error) + execution func(DockerExecutionPlan, ResolvedEnvironmentCommand, *transientOutputMount, string, bool, bool) (TransientContainerExecutionV1, error) runAdmitted func(context.Context, string, *deploy.OperationLock, string, TransientContainerExecutionV1, RunOptions) error } @@ -71,7 +70,6 @@ func RunCurrentAppCommandV1(ctx context.Context, input CurrentAppCommandRunInput }, await: AwaitLiveRunAdmissionWithNoticeV1, runPublished: RunPublishedRuntimeContainerV1, - prepareProbe: PrepareProbeWorkspace, execution: PlanTransientContainerExecutionV1, runAdmitted: RunAdmittedTransientContainerV1, }) @@ -90,7 +88,7 @@ func runCurrentAppCommandV1(ctx context.Context, input CurrentAppCommandRunInput if len(input.Arguments) == 0 { return fmt.Errorf("run current app command requires command arguments") } - if backend.acquire == nil || backend.newStore == nil || backend.readState == nil || backend.loadCurrent == nil || backend.planRuntime == nil || backend.matches == nil || backend.planCommand == nil || backend.prepareOutput == nil || backend.abortOutput == nil || backend.publishOutput == nil || backend.invocation == nil || backend.concurrency == nil || backend.newRunID == nil || backend.acquireLease == nil || backend.await == nil || backend.runPublished == nil || backend.prepareProbe == nil || backend.execution == nil || backend.runAdmitted == nil { + if backend.acquire == nil || backend.newStore == nil || backend.readState == nil || backend.loadCurrent == nil || backend.planRuntime == nil || backend.matches == nil || backend.planCommand == nil || backend.prepareOutput == nil || backend.abortOutput == nil || backend.publishOutput == nil || backend.invocation == nil || backend.concurrency == nil || backend.newRunID == nil || backend.acquireLease == nil || backend.await == nil || backend.runPublished == nil || backend.execution == nil || backend.runAdmitted == nil { return fmt.Errorf("run current app command requires a complete backend") } dir, err := filepath.Abs(input.DeploymentDir) @@ -211,7 +209,6 @@ func runCurrentAppCommandV1(ctx context.Context, input CurrentAppCommandRunInput DeploymentDir: dir, DockerPlan: planned.Docker, Invocation: invocation, } var commandRunErr error - var helperCleanupErr error callbackEntered := false runErr := backend.runPublished(ctx, published, func(runCtx context.Context, gated CurrentBuild) error { callbackEntered = true @@ -220,27 +217,22 @@ func runCurrentAppCommandV1(ctx context.Context, input CurrentAppCommandRunInput "deployment generation changed while live run %q was waiting; retry the command", runID, )) } - workspace, cleanup, err := backend.prepareProbe(runCtx, store, gated.Lock.Platform) - if err != nil { - return removeAdmittedTransientBeforeCreateV1(operation, runID, err) - } interactive := runOptions.Stdin != nil - execution, err := backend.execution(planned.Docker, command, workspace, output.mount, runID, interactive, interactive && input.TTY) + execution, err := backend.execution(planned.Docker, command, output.mount, runID, interactive, interactive && input.TTY) if err != nil { - return removeAdmittedTransientBeforeCreateV1(operation, runID, errors.Join(err, cleanup())) + return removeAdmittedTransientBeforeCreateV1(operation, runID, err) } options := runOptions options.Context = runCtx commandRunErr = backend.runAdmitted(runCtx, dir, operation, runID, execution, options) - helperCleanupErr = cleanup() - return errors.Join(commandRunErr, helperCleanupErr) + return commandRunErr }) if runErr != nil { if !callbackEntered { runErr = removeAdmittedTransientBeforeCreateV1(operation, runID, runErr) } if commandRunErr != nil { - return abort(errors.Join(appCommandError(commandRunErr), helperCleanupErr)) + return abort(appCommandError(commandRunErr)) } return abort(runErr) } diff --git a/internal/dockerdeploy/current_app_command_run_test.go b/internal/dockerdeploy/current_app_command_run_test.go index 652cc029..e4c2501f 100644 --- a/internal/dockerdeploy/current_app_command_run_test.go +++ b/internal/dockerdeploy/current_app_command_run_test.go @@ -24,11 +24,11 @@ func TestRunCurrentAppCommandV1OrdersStaleCheckOutputGateAndContainer(t *testing order := []string{} backend := currentAppCommandRunTestBackend(t, dir, current, planned, &order) originalExecution := backend.execution - backend.execution = func(plan DockerExecutionPlan, command ResolvedEnvironmentCommand, workspace PreparedProbeWorkspace, output *transientOutputMount, runID string, interactive bool, tty bool) (TransientContainerExecutionV1, error) { + backend.execution = func(plan DockerExecutionPlan, command ResolvedEnvironmentCommand, output *transientOutputMount, runID string, interactive bool, tty bool) (TransientContainerExecutionV1, error) { if output == nil || !interactive || !tty { t.Fatalf("execution input = %#v, interactive=%t, tty=%t", output, interactive, tty) } - return originalExecution(plan, command, workspace, output, runID, interactive, tty) + return originalExecution(plan, command, output, runID, interactive, tty) } var stdout bytes.Buffer @@ -40,7 +40,7 @@ func TestRunCurrentAppCommandV1OrdersStaleCheckOutputGateAndContainer(t *testing if err != nil { t.Fatal(err) } - want := []string{"acquire", "store", "state", "current", "plan runtime", "match", "plan command", "prepare output", "invocation", "concurrency", "run id", "admit", "final gate", "prepare probe", "execution", "run admitted", "cleanup probe", "publish output"} + want := []string{"acquire", "store", "state", "current", "plan runtime", "match", "plan command", "prepare output", "invocation", "concurrency", "run id", "admit", "final gate", "execution", "run admitted", "publish output"} if !reflect.DeepEqual(order, want) { t.Fatalf("order = %v, want %v", order, want) } @@ -277,17 +277,7 @@ func currentAppCommandRunTestBackend( } return run(ctx, current) }, - prepareProbe: func(_ context.Context, _ providerstore.Store, platform blueprint.Platform) (PreparedProbeWorkspace, func() error, error) { - *order = append(*order, "prepare probe") - if !reflect.DeepEqual(platform, current.Lock.Platform) { - t.Fatalf("probe platform = %#v, want %#v", platform, current.Lock.Platform) - } - return PreparedProbeWorkspace{}, func() error { - *order = append(*order, "cleanup probe") - return nil - }, nil - }, - execution: func(_ DockerExecutionPlan, _ ResolvedEnvironmentCommand, _ PreparedProbeWorkspace, _ *transientOutputMount, runID string, _ bool, _ bool) (TransientContainerExecutionV1, error) { + execution: func(_ DockerExecutionPlan, _ ResolvedEnvironmentCommand, _ *transientOutputMount, runID string, _ bool, _ bool) (TransientContainerExecutionV1, error) { *order = append(*order, "execution") return TransientContainerExecutionV1{Container: "demo-" + runID}, nil }, diff --git a/internal/dockerdeploy/current_runtime_plan.go b/internal/dockerdeploy/current_runtime_plan.go index 1dfa640d..1146d91a 100644 --- a/internal/dockerdeploy/current_runtime_plan.go +++ b/internal/dockerdeploy/current_runtime_plan.go @@ -62,6 +62,7 @@ func planCurrentRuntimeV1(input CurrentRuntimePlanInputV1, backend currentRuntim DeploymentDir: dir, Phase: blueprint.PhaseStaged, GeneratedImage: input.Current.Generation.Reference, Host: input.Runtime.Host, UID: input.Runtime.UID, GID: input.Runtime.GID, + SupplementaryGIDs: append([]int(nil), input.Runtime.SupplementaryGIDs...), } if deployment := input.Current.State.Deployment; deployment != nil { installation := deployment.Installation @@ -99,6 +100,7 @@ func planCurrentRuntimeV1(input CurrentRuntimePlanInputV1, backend currentRuntim context.SystemGroup = document.Environment.Install.System.Account.Group context.UID = owner.UID context.GID = owner.GID + context.SupplementaryGIDs = append([]int(nil), owner.SupplementaryGIDs...) } } plan, err := PlanDockerExecution(document, context) diff --git a/internal/dockerdeploy/current_shell_run.go b/internal/dockerdeploy/current_shell_run.go index b5af8b27..261650fa 100644 --- a/internal/dockerdeploy/current_shell_run.go +++ b/internal/dockerdeploy/current_shell_run.go @@ -34,8 +34,7 @@ type currentShellRunBackendV1 struct { acquireLease func(*deploy.OperationLock, string) (*deploy.QueueEntryLeaseV1, error) await func(context.Context, string, *deploy.OperationLock, deploy.LiveRunV1, bool, io.Writer) (*deploy.OperationLock, error) runPublished func(context.Context, PublishedRuntimeContainerInput, PublishedRuntimeContainerRunnerV1) error - prepareProbe func(context.Context, providerstore.Store, blueprint.Platform) (PreparedProbeWorkspace, func() error, error) - execution func(DockerExecutionPlan, PreparedProbeWorkspace, string, bool, bool) (TransientContainerExecutionV1, error) + execution func(DockerExecutionPlan, string, bool, bool) (TransientContainerExecutionV1, error) runAdmitted func(context.Context, string, *deploy.OperationLock, string, TransientContainerExecutionV1, RunOptions) error } @@ -58,10 +57,9 @@ func RunCurrentShellV1(ctx context.Context, input CurrentShellRunInputV1) error }, await: AwaitLiveRunAdmissionWithNoticeV1, runPublished: RunPublishedRuntimeContainerV1, - prepareProbe: PrepareProbeWorkspace, - execution: func(plan DockerExecutionPlan, workspace PreparedProbeWorkspace, runID string, interactive bool, tty bool) (TransientContainerExecutionV1, error) { + execution: func(plan DockerExecutionPlan, runID string, interactive bool, tty bool) (TransientContainerExecutionV1, error) { return PlanTransientContainerExecutionV1( - plan, ResolvedEnvironmentCommand{Argv: []string{"/bin/sh"}}, workspace, nil, runID, interactive, tty, + plan, ResolvedEnvironmentCommand{Argv: []string{"/bin/sh"}}, nil, runID, interactive, tty, ) }, runAdmitted: RunAdmittedTransientContainerV1, @@ -78,7 +76,7 @@ func runCurrentShellV1(ctx context.Context, input CurrentShellRunInputV1, backen if input.DeploymentDir == "" { return fmt.Errorf("run current shell requires a deployment directory") } - if backend.acquire == nil || backend.newStore == nil || backend.readState == nil || backend.loadCurrent == nil || backend.plan == nil || backend.matches == nil || backend.invocation == nil || backend.concurrency == nil || backend.newRunID == nil || backend.acquireLease == nil || backend.await == nil || backend.runPublished == nil || backend.prepareProbe == nil || backend.execution == nil || backend.runAdmitted == nil { + if backend.acquire == nil || backend.newStore == nil || backend.readState == nil || backend.loadCurrent == nil || backend.plan == nil || backend.matches == nil || backend.invocation == nil || backend.concurrency == nil || backend.newRunID == nil || backend.acquireLease == nil || backend.await == nil || backend.runPublished == nil || backend.execution == nil || backend.runAdmitted == nil { return fmt.Errorf("run current shell requires a complete backend") } dir, err := filepath.Abs(input.DeploymentDir) @@ -186,18 +184,14 @@ func runCurrentShellV1(ctx context.Context, input CurrentShellRunInputV1, backen "deployment generation changed while live run %q was waiting; retry the shell", runID, )) } - workspace, cleanup, err := backend.prepareProbe(runCtx, store, gated.Lock.Platform) - if err != nil { - return removeAdmittedTransientBeforeCreateV1(operation, runID, err) - } interactive := runOptions.Stdin != nil - execution, err := backend.execution(effectivePlan, workspace, runID, interactive, interactive && input.TTY) + execution, err := backend.execution(effectivePlan, runID, interactive, interactive && input.TTY) if err != nil { - return removeAdmittedTransientBeforeCreateV1(operation, runID, errors.Join(err, cleanup())) + return removeAdmittedTransientBeforeCreateV1(operation, runID, err) } options := runOptions options.Context = runCtx - return errors.Join(backend.runAdmitted(runCtx, dir, operation, runID, execution, options), cleanup()) + return backend.runAdmitted(runCtx, dir, operation, runID, execution, options) }) if runErr != nil && !callbackEntered { return removeAdmittedTransientBeforeCreateV1(operation, runID, runErr) diff --git a/internal/dockerdeploy/current_shell_run_test.go b/internal/dockerdeploy/current_shell_run_test.go index a33f7b04..da34116a 100644 --- a/internal/dockerdeploy/current_shell_run_test.go +++ b/internal/dockerdeploy/current_shell_run_test.go @@ -26,7 +26,7 @@ func TestRunCurrentShellV1OrdersStaleCheckFinalGateAndInteractiveContainer(t *te if err != nil { t.Fatal(err) } - want := []string{"acquire", "store", "state", "current", "plan", "match", "invocation", "concurrency", "run id", "admit", "final gate", "prepare probe", "execution", "run admitted", "cleanup probe"} + want := []string{"acquire", "store", "state", "current", "plan", "match", "invocation", "concurrency", "run id", "admit", "final gate", "execution", "run admitted"} if !reflect.DeepEqual(order, want) { t.Fatalf("order = %v, want %v", order, want) } @@ -76,7 +76,7 @@ func TestRunCurrentShellV1ReadOnlyChangesOnlyTransientMounts(t *testing.T) { } return LiveRunConcurrencyDecisionV1{AllowsOverlap: true}, nil } - backend.execution = func(plan DockerExecutionPlan, _ PreparedProbeWorkspace, runID string, interactive bool, tty bool) (TransientContainerExecutionV1, error) { + backend.execution = func(plan DockerExecutionPlan, runID string, interactive bool, tty bool) (TransientContainerExecutionV1, error) { for _, mount := range plan.Mounts { if !mount.ReadOnly { t.Fatalf("read-only shell execution saw writable mount %#v", mount) @@ -181,17 +181,7 @@ func currentShellRunTestBackend(t *testing.T, dir string, current CurrentBuild, } return run(ctx, current) }, - prepareProbe: func(_ context.Context, _ providerstore.Store, platform blueprint.Platform) (PreparedProbeWorkspace, func() error, error) { - *order = append(*order, "prepare probe") - if !reflect.DeepEqual(platform, current.Lock.Platform) { - t.Fatalf("probe platform = %#v, want %#v", platform, current.Lock.Platform) - } - return PreparedProbeWorkspace{}, func() error { - *order = append(*order, "cleanup probe") - return nil - }, nil - }, - execution: func(_ DockerExecutionPlan, _ PreparedProbeWorkspace, runID string, interactive bool, tty bool) (TransientContainerExecutionV1, error) { + execution: func(_ DockerExecutionPlan, runID string, interactive bool, tty bool) (TransientContainerExecutionV1, error) { *order = append(*order, "execution") if !interactive || !tty { t.Fatalf("shell interactive=%t, tty=%t", interactive, tty) diff --git a/internal/dockerdeploy/current_workload_lifecycle.go b/internal/dockerdeploy/current_workload_lifecycle.go index 6468d9ce..34c2c80e 100644 --- a/internal/dockerdeploy/current_workload_lifecycle.go +++ b/internal/dockerdeploy/current_workload_lifecycle.go @@ -8,7 +8,6 @@ import ( "strings" "time" - "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" "github.com/omry/reploy/internal/providerstore" ) @@ -41,8 +40,7 @@ type currentWorkloadLifecycleBackendV1 struct { execute func(context.Context, LifecyclePlan, LifecycleExecutor) error runPublished func(context.Context, PublishedRuntimeContainerInput, PublishedRuntimeContainerRunnerV1) error command func(string, string) (CommandSpec, error) - prepareProbe func(context.Context, providerstore.Store, blueprint.Platform) (PreparedProbeWorkspace, func() error, error) - transient func(DockerExecutionPlan, ResolvedEnvironmentCommand, PreparedProbeWorkspace, *transientOutputMount, bool, bool) (CommandSpec, error) + transient func(DockerExecutionPlan, ResolvedEnvironmentCommand, *transientOutputMount, bool, bool) (CommandSpec, error) cleanup func(string) CommandSpec runTemporary func(temporaryCommandRunner, CommandSpec, CommandSpec, RunOptions) error runCommand func(CommandSpec, RunOptions) error @@ -66,7 +64,6 @@ func RunCurrentWorkloadLifecycleV1(ctx context.Context, input CurrentWorkloadLif execute: ExecuteLifecycle, runPublished: RunPublishedRuntimeContainerV1, command: RuntimeCommand, - prepareProbe: PrepareProbeWorkspace, transient: TransientCommandSpec, cleanup: TemporaryContainerCleanupCommand, runTemporary: runTemporaryContainerCommand, @@ -94,7 +91,7 @@ func runCurrentWorkloadLifecycleV1(ctx context.Context, input CurrentWorkloadLif if input.Action != "up" && input.Action != "down" && input.Action != "restart" { return fmt.Errorf("current workload lifecycle action must be up, down, or restart") } - if backend.acquire == nil || backend.planStart == nil || backend.planStop == nil || backend.planRestart == nil || backend.execute == nil || backend.runPublished == nil || backend.command == nil || backend.prepareProbe == nil || backend.transient == nil || backend.cleanup == nil || backend.runTemporary == nil || backend.runCommand == nil || backend.inject == nil || backend.readiness == nil || backend.serviceCheck == nil { + if backend.acquire == nil || backend.planStart == nil || backend.planStop == nil || backend.planRestart == nil || backend.execute == nil || backend.runPublished == nil || backend.command == nil || backend.transient == nil || backend.cleanup == nil || backend.runTemporary == nil || backend.runCommand == nil || backend.inject == nil || backend.readiness == nil || backend.serviceCheck == nil { return fmt.Errorf("run current workload lifecycle requires a complete backend") } var lifecycle LifecyclePlan @@ -145,20 +142,13 @@ func runCurrentWorkloadLifecycleV1(ctx context.Context, input CurrentWorkloadLif return err } return runPublished(commandCtx, invocation, func(runCtx context.Context, gated CurrentBuild) error { - workspace, cleanup, err := backend.prepareProbe(runCtx, input.Store, gated.Lock.Platform) + spec, err := backend.transient(input.Plan.Docker, command, nil, false, false) if err != nil { return err } - spec, err := backend.transient(input.Plan.Docker, command, workspace, nil, false, false) - if err != nil { - return errors.Join(err, cleanup()) - } options := runOptions options.Context = runCtx - return errors.Join( - backend.runTemporary(backend.runCommand, spec, backend.cleanup(transientCommandContainerName(input.Plan.Docker)), options), - cleanup(), - ) + return backend.runTemporary(backend.runCommand, spec, backend.cleanup(transientCommandContainerName(input.Plan.Docker)), options) }) }, Readiness: func(readinessCtx context.Context, endpoint EndpointExecutionPlan) error { diff --git a/internal/dockerdeploy/current_workload_lifecycle_test.go b/internal/dockerdeploy/current_workload_lifecycle_test.go index 4b6bd8ba..bfac86ed 100644 --- a/internal/dockerdeploy/current_workload_lifecycle_test.go +++ b/internal/dockerdeploy/current_workload_lifecycle_test.go @@ -14,7 +14,6 @@ import ( "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" - "github.com/omry/reploy/internal/providerstore" ) func TestRunCurrentWorkloadLifecycleV1GatesEveryCreatedContainer(t *testing.T) { @@ -42,10 +41,10 @@ func TestRunCurrentWorkloadLifecycleV1GatesEveryCreatedContainer(t *testing.T) { } want := []string{ "plan start", - "gate command/check", "prepare probe", "transient check", "cleanup", "temporary", "run transient", "cleanup probe", + "gate command/check", "transient check", "cleanup", "temporary", "run transient", "gate workload", "command up", "run compose-up", "service check", "readiness", "service check", - "gate command/check", "prepare probe", "transient check", "cleanup", "temporary", "run transient", "cleanup probe", + "gate command/check", "transient check", "cleanup", "temporary", "run transient", } if !reflect.DeepEqual(order, want) { t.Fatalf("lifecycle order = %v, want %v", order, want) @@ -451,14 +450,7 @@ func currentWorkloadLifecycleTestBackend(t *testing.T, lifecycle LifecyclePlan, *order = append(*order, "command "+action) return CommandSpec{Name: "compose-" + action}, nil }, - prepareProbe: func(context.Context, providerstore.Store, blueprint.Platform) (PreparedProbeWorkspace, func() error, error) { - *order = append(*order, "prepare probe") - return PreparedProbeWorkspace{}, func() error { - *order = append(*order, "cleanup probe") - return nil - }, nil - }, - transient: func(_ DockerExecutionPlan, command ResolvedEnvironmentCommand, _ PreparedProbeWorkspace, _ *transientOutputMount, _ bool, _ bool) (CommandSpec, error) { + transient: func(_ DockerExecutionPlan, command ResolvedEnvironmentCommand, _ *transientOutputMount, _ bool, _ bool) (CommandSpec, error) { *order = append(*order, "transient "+command.Name) return CommandSpec{Name: "transient"}, nil }, diff --git a/internal/dockerdeploy/environment_lifecycle_executor.go b/internal/dockerdeploy/environment_lifecycle_executor.go index 848e45d1..4c504bcf 100644 --- a/internal/dockerdeploy/environment_lifecycle_executor.go +++ b/internal/dockerdeploy/environment_lifecycle_executor.go @@ -2,35 +2,27 @@ package dockerdeploy import ( "context" - "errors" "io" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/providerstore" ) -func environmentLifecycleExecutor(options RuntimeOptions, plan DockerExecutionPlan, store providerstore.Store, platform blueprint.Platform, stdout io.Writer, stderr io.Writer) LifecycleExecutor { +func environmentLifecycleExecutor(options RuntimeOptions, plan DockerExecutionPlan, _ providerstore.Store, _ blueprint.Platform, stdout io.Writer, stderr io.Writer) LifecycleExecutor { return LifecycleExecutor{ RunCommand: func(ctx context.Context, command ResolvedEnvironmentCommand) error { if _, err := preparePrivateWorkloadEnvironmentV1(options.Dir); err != nil { return err } - workspace, cleanup, err := PrepareProbeWorkspace(ctx, store, platform) + spec, err := TransientCommandSpec(plan, command, nil, false, false) if err != nil { return err } - spec, err := TransientCommandSpec(plan, command, workspace, nil, false, false) - if err != nil { - return errors.Join(err, cleanup()) - } - return errors.Join( - runTemporaryContainerCommand( - runRuntimeCommand, - spec, - TemporaryContainerCleanupCommand(transientCommandContainerName(plan)), - RunOptions{Context: ctx, Stdout: stdout, Stderr: stderr, DockerPreflightTimeout: options.DockerPreflightTimeout}, - ), - cleanup(), + return runTemporaryContainerCommand( + runRuntimeCommand, + spec, + TemporaryContainerCleanupCommand(transientCommandContainerName(plan)), + RunOptions{Context: ctx, Stdout: stdout, Stderr: stderr, DockerPreflightTimeout: options.DockerPreflightTimeout}, ) }, Readiness: func(ctx context.Context, endpoint EndpointExecutionPlan) error { diff --git a/internal/dockerdeploy/execution_plan.go b/internal/dockerdeploy/execution_plan.go index 854b2dea..357f00bd 100644 --- a/internal/dockerdeploy/execution_plan.go +++ b/internal/dockerdeploy/execution_plan.go @@ -14,18 +14,19 @@ import ( ) type DockerPlanContext struct { - DeploymentDir string - InstallTarget string - Phase blueprint.Phase - Scope *blueprint.InstallScope - GeneratedImage string - Host blueprint.HostOS - UID int - GID int - SystemUser string - SystemGroup string - PortOverrides map[string]int - PortOverrideArgs []PortOverride + DeploymentDir string + InstallTarget string + Phase blueprint.Phase + Scope *blueprint.InstallScope + GeneratedImage string + Host blueprint.HostOS + UID int + GID int + SupplementaryGIDs []int + SystemUser string + SystemGroup string + PortOverrides map[string]int + PortOverrideArgs []PortOverride } type DockerExecutionPlan struct { @@ -76,12 +77,13 @@ type MountExecutionPlan struct { } type RuntimeUserPlan struct { - User string - Group string - UID int - GID int - DockerUser string - Warnings []string + User string + Group string + UID int + GID int + SupplementaryGIDs []int + DockerUser string + Warnings []string } func PlanDockerExecution(document blueprint.Document, context DockerPlanContext) (DockerExecutionPlan, error) { @@ -311,13 +313,18 @@ func normalizeProbeHost(address string) string { } func planRuntimeUser(document blueprint.Document, context DockerPlanContext) (RuntimeUserPlan, error) { + supplementaryGIDs, err := normalizeSupplementaryGIDsV1(context.GID, context.SupplementaryGIDs) + if err != nil { + return RuntimeUserPlan{}, err + } if context.Phase == blueprint.PhaseStaged || context.Scope != nil && *context.Scope == blueprint.InstallScopeUser { if context.UID < 0 || context.GID < 0 { return RuntimeUserPlan{}, fmt.Errorf("current-user Docker plan requires numeric UID and GID") } plan := RuntimeUserPlan{ User: strconv.Itoa(context.UID), Group: strconv.Itoa(context.GID), UID: context.UID, GID: context.GID, - DockerUser: strconv.Itoa(context.UID) + ":" + strconv.Itoa(context.GID), + SupplementaryGIDs: supplementaryGIDs, + DockerUser: strconv.Itoa(context.UID) + ":" + strconv.Itoa(context.GID), } if context.Phase == blueprint.PhaseInstalled { plan.Warnings = append(plan.Warnings, @@ -339,7 +346,8 @@ func planRuntimeUser(document blueprint.Document, context DockerPlanContext) (Ru } return RuntimeUserPlan{ User: context.SystemUser, Group: context.SystemGroup, UID: context.UID, GID: context.GID, - DockerUser: strconv.Itoa(context.UID) + ":" + strconv.Itoa(context.GID), + SupplementaryGIDs: supplementaryGIDs, + DockerUser: strconv.Itoa(context.UID) + ":" + strconv.Itoa(context.GID), }, nil } return RuntimeUserPlan{}, fmt.Errorf("cannot resolve Docker runtime user") diff --git a/internal/dockerdeploy/execution_render.go b/internal/dockerdeploy/execution_render.go index 34aa1f7f..73d7c7db 100644 --- a/internal/dockerdeploy/execution_render.go +++ b/internal/dockerdeploy/execution_render.go @@ -50,6 +50,9 @@ type composePlanService struct { PullPolicy string `yaml:"pull_policy"` ContainerName string `yaml:"container_name"` User string `yaml:"user"` + GroupAdd []string `yaml:"group_add"` + CapDrop []string `yaml:"cap_drop"` + SecurityOpt []string `yaml:"security_opt"` Restart string `yaml:"restart,omitempty"` Entrypoint []string `yaml:"entrypoint,omitempty,flow"` Command []string `yaml:"command,omitempty,flow"` @@ -101,7 +104,10 @@ func RenderDockerInputs(plan DockerExecutionPlan, controlScript string) (DockerR } service := composePlanService{ Image: plan.Image, PullPolicy: "never", ContainerName: plan.ContainerName, User: plan.Sandbox.RuntimeUser.DockerUser, Restart: plan.Restart, - ReadOnly: plan.Sandbox.ReadOnlyRoot, Environment: temporaryEnvironmentForPlan(plan), Tmpfs: []string{temporaryHomeMountForPlan(plan)}, + GroupAdd: dockerSupplementaryGroupsV1(plan.Sandbox.RuntimeUser.SupplementaryGIDs), + CapDrop: []string{"ALL"}, + SecurityOpt: []string{"no-new-privileges:true", "seccomp=" + plan.Sandbox.Kernel.SeccompProfile}, + ReadOnly: plan.Sandbox.ReadOnlyRoot, Environment: temporaryEnvironmentForPlan(plan), Tmpfs: []string{temporaryHomeMountForPlan(plan)}, } if plan.Workload != nil { service.Command = append([]string(nil), plan.Workload.Argv...) @@ -209,11 +215,20 @@ func temporaryHomeForPlan(plan DockerExecutionPlan) string { } func temporaryHomeMountForPlan(plan DockerExecutionPlan) string { - return temporaryHomeForPlan(plan) + ":rw,noexec,nosuid,nodev,size=64m,mode=1777" + user := plan.Sandbox.RuntimeUser + return temporaryHomeForPlan(plan) + ":rw,noexec,nosuid,nodev,size=64m,mode=0700,uid=" + strconv.Itoa(user.UID) + ",gid=" + strconv.Itoa(user.GID) } func transientHomeMountForPlan(plan DockerExecutionPlan) string { - return "type=volume,destination=" + temporaryHomeForPlan(plan) + ",volume-nocopy" + return temporaryHomeMountForPlan(plan) +} + +func dockerSupplementaryGroupsV1(groups []int) []string { + result := make([]string, len(groups)) + for index, gid := range groups { + result[index] = strconv.Itoa(gid) + } + return result } func temporaryEnvironmentForPlan(plan DockerExecutionPlan) map[string]string { diff --git a/internal/dockerdeploy/execution_render_test.go b/internal/dockerdeploy/execution_render_test.go index 4a5f8a09..d4da5db2 100644 --- a/internal/dockerdeploy/execution_render_test.go +++ b/internal/dockerdeploy/execution_render_test.go @@ -30,7 +30,7 @@ func TestRenderDockerInputsFromResolvedPlan(t *testing.T) { if compose != normalizedGolden { t.Fatalf("compose golden mismatch\nactual:\n%s\nwant:\n%s", compose, wantGolden) } - for _, want := range []string{"image: reploy/demo:staging", "pull_policy: never", `user: "501:20"`, "read_only: true", "HOME: /mnt/reploy-home", "TMPDIR: /mnt/reploy-home", "- /mnt/reploy-home:rw,noexec,nosuid,nodev,size=64m,mode=1777", "type: bind", "127.0.0.1:18080:8080", "/opt/reploy/python/bin/demo", "name: demo-staging-abcd"} { + for _, want := range []string{"image: reploy/demo:staging", "pull_policy: never", `user: "501:20"`, "cap_drop:", "- ALL", "no-new-privileges:true", "seccomp=builtin", "read_only: true", "HOME: /mnt/reploy-home", "TMPDIR: /mnt/reploy-home", "- /mnt/reploy-home:rw,noexec,nosuid,nodev,size=64m,mode=0700,uid=501,gid=20", "type: bind", "127.0.0.1:18080:8080", "/opt/reploy/python/bin/demo", "name: demo-staging-abcd"} { if !strings.Contains(compose, want) { t.Fatalf("compose missing %q:\n%s", want, compose) } diff --git a/internal/dockerdeploy/install_account_support.go b/internal/dockerdeploy/install_account_support.go index 1ecf672f..880afbcf 100644 --- a/internal/dockerdeploy/install_account_support.go +++ b/internal/dockerdeploy/install_account_support.go @@ -10,10 +10,11 @@ import ( ) type resolvedInstallOwner struct { - Spec string - UID int - GID int - ContainerUser string + Spec string + UID int + GID int + SupplementaryGIDs []int + ContainerUser string } const ( @@ -23,6 +24,7 @@ const ( var installLookupUser = user.Lookup var installLookupGroup = user.LookupGroup +var installLookupUserGroupIDs = func(value *user.User) ([]string, error) { return value.GroupIds() } var installRunCommandOutput = func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() } func resolveInstallOwner(values map[string]string) (resolvedInstallOwner, error) { @@ -34,7 +36,49 @@ func resolveInstallOwner(values map[string]string) (resolvedInstallOwner, error) if err != nil { return resolvedInstallOwner{}, err } - return resolvedInstallOwner{Spec: spec, UID: uid, GID: gid, ContainerUser: fmt.Sprintf("%d:%d", uid, gid)}, nil + groups, err := resolveInstallOwnerSupplementaryGIDs(spec, uid, gid) + if err != nil { + return resolvedInstallOwner{}, err + } + return resolvedInstallOwner{ + Spec: spec, UID: uid, GID: gid, SupplementaryGIDs: groups, + ContainerUser: fmt.Sprintf("%d:%d", uid, gid), + }, nil +} + +func resolveInstallOwnerSupplementaryGIDs(spec string, uid int, gid int) ([]int, error) { + userPart, _, _ := strings.Cut(spec, ":") + if _, numeric := parseNumericInstallID(userPart); numeric { + return []int{}, nil + } + lookedUp, err := installLookupUser(userPart) + if err != nil { + return nil, fmt.Errorf("resolve REPLOY_INSTALL_OWNER supplementary groups for user %q: %w", userPart, err) + } + values, err := installLookupUserGroupIDs(lookedUp) + if err != nil { + return nil, fmt.Errorf("resolve REPLOY_INSTALL_OWNER supplementary groups for user %q: %w", userPart, err) + } + groups := make([]int, 0, len(values)) + for _, value := range values { + parsed, ok := parseNumericInstallID(value) + if !ok { + return nil, fmt.Errorf("resolved REPLOY_INSTALL_OWNER user has non-numeric supplementary GID %q: %s", value, spec) + } + groups = append(groups, parsed) + } + groups, err = normalizeSupplementaryGIDsV1(gid, groups) + if err != nil { + return nil, err + } + if uid != 0 { + for _, group := range groups { + if group == 0 { + return nil, fmt.Errorf("REPLOY_INSTALL_OWNER non-root user must not belong to the root group: %s", spec) + } + } + } + return groups, nil } func installOwnerOnMissingPolicy(values map[string]string) string { diff --git a/internal/dockerdeploy/install_account_support_test.go b/internal/dockerdeploy/install_account_support_test.go new file mode 100644 index 00000000..eb0add8a --- /dev/null +++ b/internal/dockerdeploy/install_account_support_test.go @@ -0,0 +1,55 @@ +package dockerdeploy + +import ( + "os/user" + "reflect" + "strings" + "testing" +) + +func TestResolveInstallOwnerCarriesCanonicalSupplementaryGroups(t *testing.T) { + originalLookupUser := installLookupUser + originalLookupGroup := installLookupGroup + originalLookupGroups := installLookupUserGroupIDs + t.Cleanup(func() { + installLookupUser = originalLookupUser + installLookupGroup = originalLookupGroup + installLookupUserGroupIDs = originalLookupGroups + }) + installLookupUser = func(name string) (*user.User, error) { + return &user.User{Username: name, Uid: "991", Gid: "992"}, nil + } + installLookupGroup = func(name string) (*user.Group, error) { return &user.Group{Name: name, Gid: "992"}, nil } + installLookupUserGroupIDs = func(*user.User) ([]string, error) { + return []string{"44", "992", "33", "44"}, nil + } + + owner, err := resolveInstallOwner(map[string]string{reployInstallOwnerEnv: "service:service"}) + if err != nil { + t.Fatal(err) + } + if owner.UID != 991 || owner.GID != 992 || !reflect.DeepEqual(owner.SupplementaryGIDs, []int{33, 44}) { + t.Fatalf("owner = %#v", owner) + } +} + +func TestResolveInstallOwnerRejectsRootSupplementaryGroup(t *testing.T) { + originalLookupUser := installLookupUser + originalLookupGroup := installLookupGroup + originalLookupGroups := installLookupUserGroupIDs + t.Cleanup(func() { + installLookupUser = originalLookupUser + installLookupGroup = originalLookupGroup + installLookupUserGroupIDs = originalLookupGroups + }) + installLookupUser = func(name string) (*user.User, error) { + return &user.User{Username: name, Uid: "991", Gid: "992"}, nil + } + installLookupGroup = func(name string) (*user.Group, error) { return &user.Group{Name: name, Gid: "992"}, nil } + installLookupUserGroupIDs = func(*user.User) ([]string, error) { return []string{"0", "992"}, nil } + + _, err := resolveInstallOwner(map[string]string{reployInstallOwnerEnv: "service:service"}) + if err == nil || !strings.Contains(err.Error(), "root group") { + t.Fatalf("error = %v", err) + } +} diff --git a/internal/dockerdeploy/live_run_container_test.go b/internal/dockerdeploy/live_run_container_test.go index ad5f6e76..873f5da6 100644 --- a/internal/dockerdeploy/live_run_container_test.go +++ b/internal/dockerdeploy/live_run_container_test.go @@ -8,7 +8,6 @@ import ( "strings" "testing" - "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/deploy" ) @@ -22,14 +21,9 @@ func admittedTransientFixtureV1(t *testing.T, dir string) (*deploy.OperationLock if _, err := operation.AdmitLiveRunV1(run, false); err != nil { t.Fatal(err) } - platform, err := blueprint.ParsePlatform("linux/amd64") - if err != nil { - t.Fatal(err) - } - workspace := testPreparedProbeWorkspace(t, platform, t.TempDir()) execution, err := PlanTransientContainerExecutionV1( DockerExecutionPlan{DeploymentDir: dir, ContainerName: "demo", Image: "demo:image", Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 1000, GID: 1000, DockerUser: "1000:1000"})}, - ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}}, workspace, nil, run.ID, false, false, + ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}}, nil, run.ID, false, false, ) if err != nil { t.Fatal(err) diff --git a/internal/dockerdeploy/private_workload_environment_integration_test.go b/internal/dockerdeploy/private_workload_environment_integration_test.go index 512fead8..690ed4a2 100644 --- a/internal/dockerdeploy/private_workload_environment_integration_test.go +++ b/internal/dockerdeploy/private_workload_environment_integration_test.go @@ -9,7 +9,6 @@ import ( "os" "os/exec" "path/filepath" - "runtime" "strings" "testing" "time" @@ -62,6 +61,13 @@ func TestPrivateWorkloadEnvironmentDockerIntegrationMasksFilesAndInjectsValues(t container := unique + "-container" workloadScript := fmt.Sprintf(`if [ "${TOKEN+x}" != x ]; then echo token-missing; exit 31; fi if [ "$(printf '%%s' "$TOKEN" | sha256sum | cut -d' ' -f1)" != %s ]; then echo token-mismatch; exit 32; fi +test "$(id -u):$(id -g)" = "12345:23456" +test "$(id -G)" = "23456 34567 45678" +test "$(awk '/^CapEff:/ {print $2}' /proc/self/status)" = "0000000000000000" +test "$(awk '/^CapPrm:/ {print $2}' /proc/self/status)" = "0000000000000000" +test "$(awk '/^CapBnd:/ {print $2}' /proc/self/status)" = "0000000000000000" +test "$(awk '/^NoNewPrivs:/ {print $2}' /proc/self/status)" = "1" +test "$(awk '/^Seccomp:/ {print $2}' /proc/self/status)" = "2" while [ ! -e /host/deployment/ready ]; do sleep 0.05; done if [ -s /host/deployment/.env ]; then echo env-readable; exit 41; fi if cat /host/deployment/.reploy/before >/dev/null 2>&1; then echo metadata-readable; exit 42; fi @@ -71,7 +77,7 @@ printf 'private-mask-pass\n'`, expectedTokenDigest) plan := DockerExecutionPlan{ EnvironmentID: "private-mask", DeploymentDir: deploymentDir, Phase: blueprint.PhaseStaged, Image: image, ContainerName: container, NetworkName: unique, - Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 12345, GID: 23456, DockerUser: "12345:23456"}), + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 12345, GID: 23456, SupplementaryGIDs: []int{34567, 45678}, DockerUser: "12345:23456"}), PrivateEnvironment: true, Workload: &WorkloadExecutionPlan{Argv: []string{ "/bin/sh", "-eu", "-c", @@ -151,12 +157,6 @@ func TestPrivateRuntimeMasksDockerIntegrationProtectTransientContainer(t *testin const image = "debian:bookworm-slim" runDockerIntegration(t, ctx, "pull", image) - platform, err := blueprint.ParsePlatform("linux/" + runtime.GOARCH) - if err != nil { - t.Fatal(err) - } - workspace := buildIntegrationProbeWorkspace(t, platform) - deploymentDir := dockerIntegrationSharedTempDir(t) if err := os.Chmod(deploymentDir, 0o755); err != nil { t.Fatal(err) @@ -197,7 +197,6 @@ printf 'transient-private-mask-pass\n'`, execution, err := PlanTransientContainerExecutionV1( plan, command, - workspace, nil, "run-0000000000000001", false, diff --git a/internal/dockerdeploy/provider_build_run.go b/internal/dockerdeploy/provider_build_run.go index 411b6b9d..00fa21a7 100644 --- a/internal/dockerdeploy/provider_build_run.go +++ b/internal/dockerdeploy/provider_build_run.go @@ -45,16 +45,25 @@ type LockedProviderBuildRunInputV1 struct { } type StagedProviderBuildRuntimeV1 struct { - Host blueprint.HostOS - UID int - GID int + Host blueprint.HostOS + UID int + GID int + SupplementaryGIDs []int } func CurrentStagedProviderBuildRuntimeV1() (StagedProviderBuildRuntimeV1, error) { - return stagedProviderBuildRuntimeV1(runtime.GOOS, os.Getuid(), os.Getgid()) + groups := []int{} + if runtime.GOOS != "windows" { + var err error + groups, err = os.Getgroups() + if err != nil { + return StagedProviderBuildRuntimeV1{}, fmt.Errorf("resolve current supplementary groups: %w", err) + } + } + return stagedProviderBuildRuntimeV1(runtime.GOOS, os.Getuid(), os.Getgid(), groups) } -func stagedProviderBuildRuntimeV1(goos string, uid int, gid int) (StagedProviderBuildRuntimeV1, error) { +func stagedProviderBuildRuntimeV1(goos string, uid int, gid int, groups []int) (StagedProviderBuildRuntimeV1, error) { host := blueprint.HostOS("") switch goos { case "linux": @@ -63,6 +72,7 @@ func stagedProviderBuildRuntimeV1(goos string, uid int, gid int) (StagedProvider host = blueprint.HostMacOS case "windows": host = blueprint.HostWindows + groups = []int{} if uid < 0 { uid = 0 } @@ -72,7 +82,11 @@ func stagedProviderBuildRuntimeV1(goos string, uid int, gid int) (StagedProvider default: return StagedProviderBuildRuntimeV1{}, fmt.Errorf("provider build is unsupported on host OS %q", goos) } - return StagedProviderBuildRuntimeV1{Host: host, UID: uid, GID: gid}, nil + groups, err := normalizeSupplementaryGIDsV1(gid, groups) + if err != nil { + return StagedProviderBuildRuntimeV1{}, fmt.Errorf("provider build runtime supplementary groups: %w", err) + } + return StagedProviderBuildRuntimeV1{Host: host, UID: uid, GID: gid, SupplementaryGIDs: groups}, nil } type providerBuildRunBackend struct { @@ -336,12 +350,13 @@ func runLockedProviderBuildV1( } dockerPlan, err := PlanDockerExecution(document, DockerPlanContext{ - DeploymentDir: deploymentDir, - Phase: blueprint.PhaseStaged, - GeneratedImage: providerBuildPlanImage, - Host: input.Runtime.Host, - UID: input.Runtime.UID, - GID: input.Runtime.GID, + DeploymentDir: deploymentDir, + Phase: blueprint.PhaseStaged, + GeneratedImage: providerBuildPlanImage, + Host: input.Runtime.Host, + UID: input.Runtime.UID, + GID: input.Runtime.GID, + SupplementaryGIDs: append([]int(nil), input.Runtime.SupplementaryGIDs...), }) if err != nil { return LockedProviderBuildExecutionResultV1{}, fmt.Errorf("plan provider build runtime: %w", err) diff --git a/internal/dockerdeploy/provider_build_run_test.go b/internal/dockerdeploy/provider_build_run_test.go index df37cc34..49eb20fb 100644 --- a/internal/dockerdeploy/provider_build_run_test.go +++ b/internal/dockerdeploy/provider_build_run_test.go @@ -791,21 +791,25 @@ func TestStagedProviderBuildRuntimeV1MapsSupportedHosts(t *testing.T) { } for _, test := range tests { t.Run(test.goos, func(t *testing.T) { - got, err := stagedProviderBuildRuntimeV1(test.goos, 501, 20) + got, err := stagedProviderBuildRuntimeV1(test.goos, 501, 20, []int{44, 20, 33, 44}) if err != nil { t.Fatal(err) } - want := StagedProviderBuildRuntimeV1{Host: test.host, UID: 501, GID: 20} - if got != want { + wantGroups := []int{33, 44} + if test.goos == "windows" { + wantGroups = []int{} + } + want := StagedProviderBuildRuntimeV1{Host: test.host, UID: 501, GID: 20, SupplementaryGIDs: wantGroups} + if !reflect.DeepEqual(got, want) { t.Fatalf("runtime = %#v, want %#v", got, want) } }) } - got, err := stagedProviderBuildRuntimeV1("windows", -1, -1) + got, err := stagedProviderBuildRuntimeV1("windows", -1, -1, nil) if err != nil || got.UID != 0 || got.GID != 0 { t.Fatalf("Windows runtime identity = %#v, %v", got, err) } - if _, err := stagedProviderBuildRuntimeV1("plan9", 1, 2); err == nil || !strings.Contains(err.Error(), "unsupported") { + if _, err := stagedProviderBuildRuntimeV1("plan9", 1, 2, nil); err == nil || !strings.Contains(err.Error(), "unsupported") { t.Fatalf("error = %v", err) } } diff --git a/internal/dockerdeploy/provider_install_account.go b/internal/dockerdeploy/provider_install_account.go index 73138575..b8797620 100644 --- a/internal/dockerdeploy/provider_install_account.go +++ b/internal/dockerdeploy/provider_install_account.go @@ -114,6 +114,7 @@ func prepareProviderInstallAccountWithV1( input.Install.SystemGroup = "" input.Install.SystemUID = 0 input.Install.SystemGID = 0 + input.Install.SystemSupplementaryGIDs = nil if scope != InstallScopeSystem { return input, nil } @@ -169,6 +170,7 @@ func providerInstallInputWithAccountV1(input providerInstallRunInputV1, account input.Install.SystemGroup = strings.TrimSpace(account.Group) input.Install.SystemUID = owner.UID input.Install.SystemGID = owner.GID + input.Install.SystemSupplementaryGIDs = append([]int(nil), owner.SupplementaryGIDs...) return input } diff --git a/internal/dockerdeploy/provider_install_account_test.go b/internal/dockerdeploy/provider_install_account_test.go index 1950095a..03589759 100644 --- a/internal/dockerdeploy/provider_install_account_test.go +++ b/internal/dockerdeploy/provider_install_account_test.go @@ -37,7 +37,10 @@ func TestPrepareProviderInstallAccountChecksBulkDiskBeforeCreatingMissingAccount if resolveCalls == 1 { return resolvedInstallOwner{}, missing } - return resolvedInstallOwner{Spec: "service:service", UID: 991, GID: 992, ContainerUser: "991:992"}, nil + return resolvedInstallOwner{ + Spec: "service:service", UID: 991, GID: 992, + SupplementaryGIDs: []int{33, 44}, ContainerUser: "991:992", + }, nil }, creationReadiness: func(_ map[string]string, resolveErr error) (string, error) { events = append(events, "check-create") @@ -66,7 +69,7 @@ func TestPrepareProviderInstallAccountChecksBulkDiskBeforeCreatingMissingAccount if err != nil { t.Fatal(err) } - if got.Install.SystemUser != "service" || got.Install.SystemGroup != "service" || got.Install.SystemUID != 991 || got.Install.SystemGID != 992 { + if got.Install.SystemUser != "service" || got.Install.SystemGroup != "service" || got.Install.SystemUID != 991 || got.Install.SystemGID != 992 || !reflect.DeepEqual(got.Install.SystemSupplementaryGIDs, []int{33, 44}) { t.Fatalf("resolved install account = %#v", got.Install) } wantEvents := []string{"resolve", "check-create", "measure-bulk", "check-disk", "create", "resolve"} @@ -106,7 +109,7 @@ func TestInspectProviderInstallAccountReportsExistingNumericIdentity(t *testing. blueprint.SystemAccount{User: "service", Group: "service", OnMissing: "create"}, providerInstallAccountInspectionBackendV1{ resolve: func(map[string]string) (resolvedInstallOwner, error) { - return resolvedInstallOwner{UID: 991, GID: 992}, nil + return resolvedInstallOwner{UID: 991, GID: 992, SupplementaryGIDs: []int{33, 44}}, nil }, creationReadiness: func(map[string]string, error) (string, error) { t.Fatal("existing account checked creation readiness") @@ -186,7 +189,7 @@ func TestPrepareProviderInstallAccountReusesExistingAccountWithoutCreationPrefli input, providerInstallAccountBackendV1{ resolve: func(map[string]string) (resolvedInstallOwner, error) { - return resolvedInstallOwner{UID: 991, GID: 992}, nil + return resolvedInstallOwner{UID: 991, GID: 992, SupplementaryGIDs: []int{33, 44}}, nil }, creationReadiness: func(map[string]string, error) (string, error) { unexpected("readiness") @@ -209,7 +212,7 @@ func TestPrepareProviderInstallAccountReusesExistingAccountWithoutCreationPrefli if err != nil { t.Fatal(err) } - if got.Install.SystemUID != 991 || got.Install.SystemGID != 992 { + if got.Install.SystemUID != 991 || got.Install.SystemGID != 992 || !reflect.DeepEqual(got.Install.SystemSupplementaryGIDs, []int{33, 44}) { t.Fatalf("reused install account = %#v", got.Install) } } diff --git a/internal/dockerdeploy/provider_install_entry_test.go b/internal/dockerdeploy/provider_install_entry_test.go index 7913bac2..26fad3f2 100644 --- a/internal/dockerdeploy/provider_install_entry_test.go +++ b/internal/dockerdeploy/provider_install_entry_test.go @@ -25,7 +25,7 @@ func TestProviderInstallEntryMapsPublicOptionsWithoutAccountState(t *testing.T) _, err := runProviderInstallEntryV1(t.Context(), want, providerInstallEntryBackendV1{ run: func(_ context.Context, got providerInstallRunInputV1, backend providerInstallRunBackend) (deploy.StateV1, error) { called = true - if got.SourceDeploymentDir != want.SourceDeploymentDir || got.DestinationDeploymentDir != want.DestinationDeploymentDir || got.Runtime != want.Runtime || got.ControlMode != want.ControlMode { + if got.SourceDeploymentDir != want.SourceDeploymentDir || got.DestinationDeploymentDir != want.DestinationDeploymentDir || !reflect.DeepEqual(got.Runtime, want.Runtime) || got.ControlMode != want.ControlMode { t.Fatalf("provider install input = %#v", got) } if got.Install.Scope != want.Scope || got.Install.Service != want.Service || !reflect.DeepEqual(got.Install.PortOverrides, want.PortOverrides) || !reflect.DeepEqual(got.Install.Replace, want.Replace) || got.Install.Clean != want.Clean || got.Install.Start != want.Start { diff --git a/internal/dockerdeploy/provider_install_plan.go b/internal/dockerdeploy/provider_install_plan.go index 061404c4..2ebf8d30 100644 --- a/internal/dockerdeploy/provider_install_plan.go +++ b/internal/dockerdeploy/provider_install_plan.go @@ -62,9 +62,11 @@ func planProviderInstallationV1(ctx context.Context, input providerInstallPlanni dockerContext.SystemGroup = input.Input.Install.SystemGroup dockerContext.UID = input.Input.Install.SystemUID dockerContext.GID = input.Input.Install.SystemGID + dockerContext.SupplementaryGIDs = append([]int(nil), input.Input.Install.SystemSupplementaryGIDs...) } else { dockerContext.UID = input.Input.Runtime.UID dockerContext.GID = input.Input.Runtime.GID + dockerContext.SupplementaryGIDs = append([]int(nil), input.Input.Runtime.SupplementaryGIDs...) } dockerPlan, err := PlanDockerExecution(document, dockerContext) if err != nil { diff --git a/internal/dockerdeploy/provider_install_plan_test.go b/internal/dockerdeploy/provider_install_plan_test.go index 613c71dc..3b9325e9 100644 --- a/internal/dockerdeploy/provider_install_plan_test.go +++ b/internal/dockerdeploy/provider_install_plan_test.go @@ -70,8 +70,9 @@ func TestPlanProviderInstallationV1UsesLockedBlueprintAndDestinationReference(t Install: providerInstallOptionsV1{ Scope: InstallScopeSystem, Service: "demo-service", SystemUser: "demo", SystemGroup: "demo", SystemUID: 991, SystemGID: 992, - PortOverrides: []PortOverride{{Name: "http", HostPort: "19090"}}, - Replace: []string{"conf"}, Start: true, + SystemSupplementaryGIDs: []int{33, 44}, + PortOverrides: []PortOverride{{Name: "http", HostPort: "19090"}}, + Replace: []string{"conf"}, Start: true, }, }, }) @@ -94,7 +95,7 @@ func TestPlanProviderInstallationV1UsesLockedBlueprintAndDestinationReference(t if !reflect.DeepEqual(plan.Installation, wantInstallation) { t.Fatalf("installation = %#v, want %#v", plan.Installation, wantInstallation) } - if plan.Backend != installBackendLinuxSystemd || plan.Docker.Image != references.Generation || plan.Docker.Sandbox.RuntimeUser.DockerUser != "991:992" { + if plan.Backend != installBackendLinuxSystemd || plan.Docker.Image != references.Generation || plan.Docker.Sandbox.RuntimeUser.DockerUser != "991:992" || !reflect.DeepEqual(plan.Docker.Sandbox.RuntimeUser.SupplementaryGIDs, []int{33, 44}) { t.Fatalf("provider installation plan = %#v", plan) } if !reflect.DeepEqual(plan.Docker.Workload.Argv, []string{"/opt/demo", "serve"}) { diff --git a/internal/dockerdeploy/provider_install_public_test.go b/internal/dockerdeploy/provider_install_public_test.go index ff881aaa..da73d050 100644 --- a/internal/dockerdeploy/provider_install_public_test.go +++ b/internal/dockerdeploy/provider_install_public_test.go @@ -37,7 +37,7 @@ func TestInstallProviderV1MapsPublicOptions(t *testing.T) { if captured.SourceDeploymentDir != "/staging" || captured.DestinationDeploymentDir != "/installed" { t.Fatalf("install directories = %q -> %q", captured.SourceDeploymentDir, captured.DestinationDeploymentDir) } - if captured.Runtime != runtime || captured.ControlMode != ControlAdmissionDrainV1 || captured.Scope != InstallScopeSystem || captured.Service != "demo" { + if !reflect.DeepEqual(captured.Runtime, runtime) || captured.ControlMode != ControlAdmissionDrainV1 || captured.Scope != InstallScopeSystem || captured.Service != "demo" { t.Fatalf("install identity options = %#v", captured) } if !reflect.DeepEqual(captured.PortOverrides, []PortOverride{{Name: "web", HostPort: "8080"}}) || !reflect.DeepEqual(captured.Replace, []string{"config"}) || !captured.Clean || !captured.Start { @@ -72,7 +72,7 @@ func TestDirectInstallProviderV1MapsPublicOptionsAndReturnsTarget(t *testing.T) if target != "/resolved" { t.Fatalf("target = %q, want /resolved", target) } - if !reflect.DeepEqual(captured.Pack, pack) || captured.Target != "/installed" || captured.Runtime != runtime || captured.ControlMode != ControlAdmissionForceV1 || captured.Scope != InstallScopeUser || captured.Service != "demo" || !captured.Start { + if !reflect.DeepEqual(captured.Pack, pack) || captured.Target != "/installed" || !reflect.DeepEqual(captured.Runtime, runtime) || captured.ControlMode != ControlAdmissionForceV1 || captured.Scope != InstallScopeUser || captured.Service != "demo" || !captured.Start { t.Fatalf("direct install options = %#v", captured) } } diff --git a/internal/dockerdeploy/provider_install_run.go b/internal/dockerdeploy/provider_install_run.go index 27e82d9e..ab147011 100644 --- a/internal/dockerdeploy/provider_install_run.go +++ b/internal/dockerdeploy/provider_install_run.go @@ -38,16 +38,17 @@ type ProviderInstallResultV1 struct { } type providerInstallOptionsV1 struct { - Scope InstallScope - Service string - PortOverrides []PortOverride - Replace []string - Clean bool - Start bool - SystemUser string - SystemGroup string - SystemUID int - SystemGID int + Scope InstallScope + Service string + PortOverrides []PortOverride + Replace []string + Clean bool + Start bool + SystemUser string + SystemGroup string + SystemUID int + SystemGID int + SystemSupplementaryGIDs []int } type providerInstallPlanningV1 struct { diff --git a/internal/dockerdeploy/provider_uninstall_public_test.go b/internal/dockerdeploy/provider_uninstall_public_test.go index 45a69803..e86ed6fb 100644 --- a/internal/dockerdeploy/provider_uninstall_public_test.go +++ b/internal/dockerdeploy/provider_uninstall_public_test.go @@ -5,6 +5,7 @@ import ( "context" "errors" "path/filepath" + "reflect" "testing" "time" @@ -132,7 +133,7 @@ func TestUninstallProviderV1MapsPublicOptions(t *testing.T) { if err != nil { t.Fatal(err) } - if captured.DeploymentDir != "/opt/demo" || captured.Runtime != runtime || captured.Service != "demo-service" || + if captured.DeploymentDir != "/opt/demo" || !reflect.DeepEqual(captured.Runtime, runtime) || captured.Service != "demo-service" || !captured.RemoveDir || captured.ControlMode != ControlAdmissionDrainV1 || captured.RunOptions.Progress != &progress || captured.RunOptions.DockerPreflightTimeout != timeout { t.Fatalf("provider uninstall input = %#v", captured) @@ -164,7 +165,7 @@ func TestUninstallProviderV1RecoversMissingSystemDeploymentByService(t *testing. if err != nil { t.Fatal(err) } - if recovered.Runtime != runtime || recovered.RequestedDir != "" || recovered.Service != "demo" || !recovered.RemoveDir || recovered.ControlMode != ControlAdmissionForceV1 { + if !reflect.DeepEqual(recovered.Runtime, runtime) || recovered.RequestedDir != "" || recovered.Service != "demo" || !recovered.RemoveDir || recovered.ControlMode != ControlAdmissionForceV1 { t.Fatalf("recovery input = %#v", recovered) } } diff --git a/internal/dockerdeploy/testdata/resolved_compose.yaml b/internal/dockerdeploy/testdata/resolved_compose.yaml index 087a94e0..1a60d57c 100644 --- a/internal/dockerdeploy/testdata/resolved_compose.yaml +++ b/internal/dockerdeploy/testdata/resolved_compose.yaml @@ -5,6 +5,12 @@ services: pull_policy: never container_name: demo-staging-abcd user: "501:20" + group_add: [] + cap_drop: + - ALL + security_opt: + - no-new-privileges:true + - seccomp=builtin command: [/opt/reploy/python/bin/demo, serve] volumes: - type: bind @@ -18,7 +24,7 @@ services: HOME: /mnt/reploy-home TMPDIR: /mnt/reploy-home tmpfs: - - /mnt/reploy-home:rw,noexec,nosuid,nodev,size=64m,mode=1777 + - /mnt/reploy-home:rw,noexec,nosuid,nodev,size=64m,mode=0700,uid=501,gid=20 networks: default: name: demo-staging-abcd diff --git a/internal/probe/main.go b/internal/probe/main.go index 505a54fa..38d81993 100644 --- a/internal/probe/main.go +++ b/internal/probe/main.go @@ -11,11 +11,11 @@ import ( ) func Main(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { - return mainWithActions(args, stdin, stdout, stderr, waitForHoldSignal, copyFixedVolumeTree, runFixedTransient) + return mainWithActions(args, stdin, stdout, stderr, waitForHoldSignal, copyFixedVolumeTree) } func mainWithHold(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer, hold func() error) int { - return mainWithActions(args, stdin, stdout, stderr, hold, copyFixedVolumeTree, runFixedTransient) + return mainWithActions(args, stdin, stdout, stderr, hold, copyFixedVolumeTree) } func mainWithActions( @@ -25,7 +25,6 @@ func mainWithActions( stderr io.Writer, hold func() error, copyVolumeTree func() error, - runTransient func([]string) error, ) int { if len(args) == 1 && args[0] == "hold" { if err := hold(); err != nil { @@ -41,15 +40,8 @@ func mainWithActions( } return 0 } - if len(args) >= 1 && args[0] == "run-transient" { - if err := runTransient(args[1:]); err != nil { - _, _ = fmt.Fprintf(stderr, "reploy-probe: run transient command: %v\n", err) - return 1 - } - return 0 - } if len(args) != 0 { - _, _ = fmt.Fprintln(stderr, "reploy-probe accepts no arguments for one canonical stdin request, fixed hold mode, fixed copy-volume-tree mode, or fixed run-transient mode") + _, _ = fmt.Fprintln(stderr, "reploy-probe accepts no arguments for one canonical stdin request, fixed hold mode, or fixed copy-volume-tree mode") return 2 } content, err := io.ReadAll(stdin) diff --git a/internal/probe/protocol_test.go b/internal/probe/protocol_test.go index 4072fc32..82c71be5 100644 --- a/internal/probe/protocol_test.go +++ b/internal/probe/protocol_test.go @@ -163,7 +163,6 @@ func TestMainCopyVolumeTreeIsFixedLifecycleOnly(t *testing.T) { []string{"copy-volume-tree"}, strings.NewReader("ignored"), &stdout, &stderr, func() error { t.Fatal("copy mode reached hold"); return nil }, func() error { copies++; return nil }, - func([]string) error { t.Fatal("copy mode reached transient runner"); return nil }, ) if code != 0 || copies != 1 || stdout.Len() != 0 || stderr.Len() != 0 { t.Fatalf("copy code=%d copies=%d stdout=%q stderr=%q", code, copies, stdout.String(), stderr.String()) @@ -171,39 +170,8 @@ func TestMainCopyVolumeTreeIsFixedLifecycleOnly(t *testing.T) { code = mainWithActions( []string{"copy-volume-tree", "anything"}, strings.NewReader(""), &stdout, &stderr, func() error { return nil }, func() error { t.Fatal("invalid copy arguments reached copier"); return nil }, - func([]string) error { t.Fatal("invalid copy arguments reached transient runner"); return nil }, ) if code != 2 || !strings.Contains(stderr.String(), "fixed copy-volume-tree mode") { t.Fatalf("invalid copy code=%d stderr=%q", code, stderr.String()) } } - -func TestMainRunTransientForwardsExactArguments(t *testing.T) { - var stdout bytes.Buffer - var stderr bytes.Buffer - var got []string - code := mainWithActions( - []string{"run-transient", "501", "20", "/opt/demo", "--flag", "value"}, - strings.NewReader("ignored"), &stdout, &stderr, - func() error { t.Fatal("transient mode reached hold"); return nil }, - func() error { t.Fatal("transient mode reached copy"); return nil }, - func(args []string) error { got = append([]string{}, args...); return nil }, - ) - want := []string{"501", "20", "/opt/demo", "--flag", "value"} - if code != 0 || !reflect.DeepEqual(got, want) || stdout.Len() != 0 || stderr.Len() != 0 { - t.Fatalf("code=%d args=%#v stdout=%q stderr=%q", code, got, stdout.String(), stderr.String()) - } -} - -func TestRunFixedTransientRejectsInvalidIdentityAndCommand(t *testing.T) { - for _, args := range [][]string{ - {}, - {"01", "20", "/opt/demo"}, - {"501", "-1", "/opt/demo"}, - {"501", "20", "demo"}, - } { - if err := runFixedTransient(args); err == nil { - t.Fatalf("arguments accepted: %#v", args) - } - } -} diff --git a/internal/probe/run_transient.go b/internal/probe/run_transient.go deleted file mode 100644 index d842d415..00000000 --- a/internal/probe/run_transient.go +++ /dev/null @@ -1,38 +0,0 @@ -package probe - -import ( - "fmt" - "path" - "strconv" -) - -const TransientHome = "/mnt/reploy-home" - -func runFixedTransient(args []string) error { - if len(args) < 3 { - return fmt.Errorf("expected UID, GID, and an absolute command") - } - uid, err := parseTransientIdentity("UID", args[0]) - if err != nil { - return err - } - gid, err := parseTransientIdentity("GID", args[1]) - if err != nil { - return err - } - command := args[2:] - if !path.IsAbs(command[0]) { - return fmt.Errorf("command must be absolute") - } - return runTransientProcess(TransientHome, uid, gid, command) -} - -func parseTransientIdentity(label string, value string) (int, error) { - // Reploy ships a 32-bit arm/v7 helper, so the signed-int range is the - // portable ownership contract across every embedded helper variant. - parsed, err := strconv.ParseUint(value, 10, 31) - if err != nil || strconv.FormatUint(parsed, 10) != value { - return 0, fmt.Errorf("%s must be a canonical non-negative integer", label) - } - return int(parsed), nil -} diff --git a/internal/probe/run_transient_linux.go b/internal/probe/run_transient_linux.go deleted file mode 100644 index 5d68718a..00000000 --- a/internal/probe/run_transient_linux.go +++ /dev/null @@ -1,38 +0,0 @@ -//go:build linux - -package probe - -import ( - "fmt" - "os" - "syscall" -) - -func runTransientProcess(home string, uid int, gid int, command []string) error { - info, err := os.Lstat(home) - if err != nil { - return fmt.Errorf("inspect transient home: %w", err) - } - if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { - return fmt.Errorf("transient home must be a real directory") - } - if err := os.Chown(home, uid, gid); err != nil { - return fmt.Errorf("own transient home for %d:%d: %w", uid, gid, err) - } - if err := os.Chmod(home, 0o700); err != nil { - return fmt.Errorf("protect transient home: %w", err) - } - if err := syscall.Setgroups([]int{}); err != nil { - return fmt.Errorf("clear supplementary groups: %w", err) - } - if err := syscall.Setgid(gid); err != nil { - return fmt.Errorf("select transient GID %d: %w", gid, err) - } - if err := syscall.Setuid(uid); err != nil { - return fmt.Errorf("select transient UID %d: %w", uid, err) - } - if err := syscall.Exec(command[0], command, os.Environ()); err != nil { - return fmt.Errorf("execute transient command %s: %w", command[0], err) - } - return nil -} diff --git a/internal/probe/run_transient_other.go b/internal/probe/run_transient_other.go deleted file mode 100644 index 32f76f3a..00000000 --- a/internal/probe/run_transient_other.go +++ /dev/null @@ -1,9 +0,0 @@ -//go:build !linux - -package probe - -import "fmt" - -func runTransientProcess(string, int, int, []string) error { - return fmt.Errorf("transient command execution requires Linux") -}