From 850716aea3e4c7d6a587496b4d45d40b49b4a234 Mon Sep 17 00:00:00 2001 From: Omry Yadan Date: Tue, 4 Aug 2026 12:26:53 +0800 Subject: [PATCH] Unify application sandbox planning Route persistent workloads and transient application commands through one validated sandbox plan. Rename the system-install identity field to account and cover renderer parity and schema migration. --- .../+canonical-application-sandbox.yaml | 2 + .../unreleased/+linux-user-scope-install.yaml | 2 +- ...UEPRINT_ENVIRONMENT_IMPLEMENTATION_PLAN.md | 4 +- docs/BLUEPRINT_ENVIRONMENT_MODEL.md | 10 +- docs/CONTROLLED_SESSION_DESIGN.md | 5 +- docs/CROSS_PLATFORM_INSTALL_LOCATIONS.md | 2 +- internal/blueprint/model.go | 4 +- internal/blueprint/resolve.go | 2 +- internal/blueprint/syntax.go | 4 +- internal/blueprint/syntax_test.go | 23 +++++ .../dockerdeploy/application_sandbox_plan.go | 42 +++++++++ .../application_sandbox_plan_test.go | 92 +++++++++++++++++++ internal/dockerdeploy/command_execution.go | 24 ++--- .../command_execution_integration_test.go | 2 +- .../dockerdeploy/command_execution_test.go | 12 +-- .../dockerdeploy/current_app_command_run.go | 2 +- .../current_app_command_run_test.go | 4 +- .../dockerdeploy/current_build_reuse_test.go | 6 +- .../dockerdeploy/current_build_verify_test.go | 4 +- .../current_runtime_files_test.go | 2 +- internal/dockerdeploy/current_runtime_plan.go | 6 +- .../dockerdeploy/current_runtime_plan_test.go | 6 +- .../dockerdeploy/current_shell_run_test.go | 4 +- internal/dockerdeploy/doctor.go | 2 +- internal/dockerdeploy/doctor_test.go | 8 +- internal/dockerdeploy/execution_plan.go | 14 +-- internal/dockerdeploy/execution_plan_test.go | 6 +- internal/dockerdeploy/execution_render.go | 14 +-- .../dockerdeploy/execution_render_test.go | 2 +- .../dockerdeploy/live_run_container_test.go | 2 +- ...e_workload_environment_integration_test.go | 4 +- .../private_workload_environment_test.go | 2 +- .../provider_build_completion_test.go | 6 +- .../provider_build_prepare_test.go | 2 +- .../dockerdeploy/provider_build_run_test.go | 2 +- .../dockerdeploy/provider_install_account.go | 38 ++++---- .../provider_install_account_test.go | 14 +-- .../provider_install_plan_test.go | 2 +- internal/dockerdeploy/provider_install_run.go | 4 +- .../dockerdeploy/provider_install_run_test.go | 14 +-- internal/dockerdeploy/runtime_plan.go | 3 + internal/dockerdeploy/runtime_plan_test.go | 8 +- .../state_v1_test_helpers_test.go | 7 ++ website/docs/blueprint-structure.md | 2 +- 44 files changed, 298 insertions(+), 122 deletions(-) create mode 100644 .changes/unreleased/+canonical-application-sandbox.yaml create mode 100644 internal/dockerdeploy/application_sandbox_plan.go create mode 100644 internal/dockerdeploy/application_sandbox_plan_test.go diff --git a/.changes/unreleased/+canonical-application-sandbox.yaml b/.changes/unreleased/+canonical-application-sandbox.yaml new file mode 100644 index 00000000..975103d9 --- /dev/null +++ b/.changes/unreleased/+canonical-application-sandbox.yaml @@ -0,0 +1,2 @@ +kind: Changed +body: Route persistent and transient application containers through one canonical sandbox plan, and rename the system-install identity field from `run_as` to `account`. diff --git a/.changes/unreleased/+linux-user-scope-install.yaml b/.changes/unreleased/+linux-user-scope-install.yaml index eb72337c..837cc563 100644 --- a/.changes/unreleased/+linux-user-scope-install.yaml +++ b/.changes/unreleased/+linux-user-scope-install.yaml @@ -1,2 +1,2 @@ kind: Added -body: Support Linux `reploy install --scope user` as an invoking-user Docker-managed install and add `install.system.run_as` as the system-scope app account field. +body: Support Linux `reploy install --scope user` as an invoking-user Docker-managed install and add `install.system.account` as the system-scope app account field. diff --git a/docs/BLUEPRINT_ENVIRONMENT_IMPLEMENTATION_PLAN.md b/docs/BLUEPRINT_ENVIRONMENT_IMPLEMENTATION_PLAN.md index 0ab17552..b0753cf8 100644 --- a/docs/BLUEPRINT_ENVIRONMENT_IMPLEMENTATION_PLAN.md +++ b/docs/BLUEPRINT_ENVIRONMENT_IMPLEMENTATION_PLAN.md @@ -175,7 +175,7 @@ Implement: environment packages, applications with application-scoped options and executable profiles, mounts, commands, `allow_concurrent`, optional workload, `workload.runtime`, install, and Docker runtime nodes. -- Install target defaults, semantic host variables, `system.run_as`, success +- Install target defaults, semantic host variables, `system.account`, success lines, and current platform/scope validation. - Strict unknown-field rejection and explicit rejection of legacy top-level shapes after cutover. @@ -442,7 +442,7 @@ Ownership: recorded Reploy-managed non-root Linux UID/GID inside the Desktop VM rather than the macOS or Windows account's numeric identity. - User-scope operations warn when overriding image `USER` or ignoring - `system.run_as`. + `system.account`. - Installed system scope uses the resolved service account. - Only writable paths and Reploy temporary home are writable. - During build, validate every compiled mount destination and runtime-exposed diff --git a/docs/BLUEPRINT_ENVIRONMENT_MODEL.md b/docs/BLUEPRINT_ENVIRONMENT_MODEL.md index eec789d0..34c5abf1 100644 --- a/docs/BLUEPRINT_ENVIRONMENT_MODEL.md +++ b/docs/BLUEPRINT_ENVIRONMENT_MODEL.md @@ -991,7 +991,7 @@ environment: # Omit target defaults to use Reploy's host/backend/scope-aware defaults. target: {} system: - run_as: + account: user: arbiter group: arbiter on_missing: create @@ -1391,7 +1391,7 @@ Docker Desktop it explains that the identity exists only inside the Linux container/VM. The warning also states that the image's configured user is overridden, the image must tolerate the selected non-root identity, and persistent writes are available only through declared writable paths. If -system `run_as` configuration is present, Reploy reports that it does not apply +system `account` configuration is present, Reploy reports that it does not apply to current-user scope. The materialized image is a private Docker-backend resource, not another @@ -2165,18 +2165,18 @@ blueprint namespace. - `system` is a machine install and requires a backend with system lifecycle semantics plus an appropriate privilege path. - Linux system scope uses the systemd backend and may apply - `environment.install.system.run_as`. + `environment.install.system.account`. - Linux user scope uses user-owned Docker lifecycle and never creates, chowns to, or runs as the configured system account. - macOS and Windows currently support only user scope through Docker Desktop or a compatible user runtime. System scope fails clearly rather than silently degrading to user scope. -`system.run_as` is ownership and container-process policy for a system install, +`system.account` is ownership and container-process policy for a system install, not another install scope. A native-Linux current-user install runs workload and transient containers as the invoking numeric UID/GID; Docker Desktop instead uses the Reploy-managed non-root container identity defined above. If -`system.run_as` is present, Reploy reports that it is inapplicable to user scope +`system.account` is present, Reploy reports that it is inapplicable to user scope along with the non-root image compatibility warning defined above. ### Install Target Defaults diff --git a/docs/CONTROLLED_SESSION_DESIGN.md b/docs/CONTROLLED_SESSION_DESIGN.md index 838944c2..15c4e9c6 100644 --- a/docs/CONTROLLED_SESSION_DESIGN.md +++ b/docs/CONTROLLED_SESSION_DESIGN.md @@ -67,9 +67,8 @@ Reploy does not configure a second container-local username. Staged and installed user-scope containers run as the invoking host user's numeric UID, GID, and supplementary GIDs. Installed system-scope containers run as the host account explicitly selected by `environment.install.system.account`, using -that account's numeric identity inside the container. This design renames the -existing `environment.install.system.run_as` field because the setting selects -the installation's host account; it is not a second container-user setting. +that account's numeric identity inside the container. The setting selects the +installation's host account; it is not a second container-user setting. If the effective runtime user is root, Reploy emits a precise warning that the application can interfere with more of its container. Root does not implicitly diff --git a/docs/CROSS_PLATFORM_INSTALL_LOCATIONS.md b/docs/CROSS_PLATFORM_INSTALL_LOCATIONS.md index 107a7d4a..6714650f 100644 --- a/docs/CROSS_PLATFORM_INSTALL_LOCATIONS.md +++ b/docs/CROSS_PLATFORM_INSTALL_LOCATIONS.md @@ -12,6 +12,6 @@ This design has been folded into The environment model is now authoritative for explicit install scope, host/backend/scope-aware target defaults, blueprint overrides, semantic host -variables, validation, and system `run_as` behavior. It also updates the schema +variables, validation, and system `account` behavior. It also updates the schema from top-level `install` and `app.id` to `environment.install` and `environment.id`. diff --git a/internal/blueprint/model.go b/internal/blueprint/model.go index f58ca041..018a0e91 100644 --- a/internal/blueprint/model.go +++ b/internal/blueprint/model.go @@ -244,10 +244,10 @@ type InstallTarget struct { } type SystemInstall struct { - RunAs RunAs + Account SystemAccount } -type RunAs struct { +type SystemAccount struct { User string Group string OnMissing string diff --git a/internal/blueprint/resolve.go b/internal/blueprint/resolve.go index 345c0b79..5efd823b 100644 --- a/internal/blueprint/resolve.go +++ b/internal/blueprint/resolve.go @@ -674,7 +674,7 @@ func resolveOrder(values []string) ([]ArgumentSegment, error) { func resolveInstallSyntax(item InstallSyntax, variables map[string]any) Install { return Install{ Target: InstallTarget{DefaultPath: item.Target.DefaultPath, DefaultPaths: cloneMap(item.Target.DefaultPaths)}, - System: SystemInstall{RunAs: RunAs{User: item.System.RunAs.User, Group: item.System.RunAs.Group, OnMissing: item.System.RunAs.OnMissing}}, + System: SystemInstall{Account: SystemAccount{User: item.System.Account.User, Group: item.System.Account.Group, OnMissing: item.System.Account.OnMissing}}, AfterInstall: resolveSteps(item.AfterInstall), Success: InstallSuccess{Lines: append([]string(nil), item.Success.Lines...)}, } diff --git a/internal/blueprint/syntax.go b/internal/blueprint/syntax.go index f640e02a..202c91b6 100644 --- a/internal/blueprint/syntax.go +++ b/internal/blueprint/syntax.go @@ -167,10 +167,10 @@ type InstallTargetSyntax struct { } type SystemInstallSyntax struct { - RunAs RunAsSyntax `yaml:"run_as"` + Account SystemAccountSyntax `yaml:"account"` } -type RunAsSyntax struct { +type SystemAccountSyntax struct { User string `yaml:"user"` Group string `yaml:"group"` OnMissing string `yaml:"on_missing"` diff --git a/internal/blueprint/syntax_test.go b/internal/blueprint/syntax_test.go index 922aa144..68dcc8c5 100644 --- a/internal/blueprint/syntax_test.go +++ b/internal/blueprint/syntax_test.go @@ -64,6 +64,29 @@ func TestDecodeAcceptsEnvironmentSchema(t *testing.T) { } } +func TestDecodeUsesSystemAccountTerminology(t *testing.T) { + value := strings.Replace(minimalBlueprint, " base:\n", " install:\n system:\n account:\n user: demo\n group: demo\n on_missing: create\n base:\n", 1) + source, err := Decode([]byte(value)) + if err != nil { + t.Fatal(err) + } + if source.Environment.Install.System.Account != (SystemAccountSyntax{User: "demo", Group: "demo", OnMissing: "create"}) { + t.Fatalf("system account = %#v", source.Environment.Install.System.Account) + } + document, err := Resolve(source) + if err != nil { + t.Fatal(err) + } + if document.Environment.Install.System.Account != (SystemAccount{User: "demo", Group: "demo", OnMissing: "create"}) { + t.Fatalf("resolved system account = %#v", document.Environment.Install.System.Account) + } + + legacy := strings.Replace(value, " account:\n", " run_as:\n", 1) + if _, err := Decode([]byte(legacy)); err == nil || !strings.Contains(err.Error(), "field run_as not found") { + t.Fatalf("legacy system run_as error = %v", err) + } +} + func TestDecodeRejectsRemovedWorkspaceNode(t *testing.T) { value := strings.Replace(minimalBlueprint, " base:\n", " workspace:\n root: ..\n packages:\n python:\n demo-server: server\n base:\n", 1) _, err := Decode([]byte(value)) diff --git a/internal/dockerdeploy/application_sandbox_plan.go b/internal/dockerdeploy/application_sandbox_plan.go new file mode 100644 index 00000000..b359b2ae --- /dev/null +++ b/internal/dockerdeploy/application_sandbox_plan.go @@ -0,0 +1,42 @@ +package dockerdeploy + +import ( + "fmt" + "path" + "strconv" +) + +// 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 +// renderer-specific flags. +type ApplicationSandboxPlanV1 struct { + RuntimeUser RuntimeUserPlan + ReadOnlyRoot bool + TemporaryHome string +} + +func newApplicationSandboxPlanV1(runtimeUser RuntimeUserPlan) ApplicationSandboxPlanV1 { + return ApplicationSandboxPlanV1{ + RuntimeUser: runtimeUser, + ReadOnlyRoot: true, + TemporaryHome: environmentTemporaryHome, + } +} + +func ValidateApplicationSandboxPlanV1(plan ApplicationSandboxPlanV1) error { + if plan.RuntimeUser.UID < 0 || plan.RuntimeUser.GID < 0 { + return fmt.Errorf("application sandbox requires a non-negative numeric UID and GID") + } + wantUser := strconv.Itoa(plan.RuntimeUser.UID) + ":" + strconv.Itoa(plan.RuntimeUser.GID) + if plan.RuntimeUser.DockerUser != wantUser { + return fmt.Errorf("application sandbox Docker user must match its numeric UID and GID") + } + 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) + } + return nil +} diff --git a/internal/dockerdeploy/application_sandbox_plan_test.go b/internal/dockerdeploy/application_sandbox_plan_test.go new file mode 100644 index 00000000..7c0d32d9 --- /dev/null +++ b/internal/dockerdeploy/application_sandbox_plan_test.go @@ -0,0 +1,92 @@ +package dockerdeploy + +import ( + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "gopkg.in/yaml.v3" +) + +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(), + Phase: blueprint.PhaseStaged, + Image: "reploy/demo:staging", + ContainerName: "demo-staging-abcd", + NetworkName: "demo-staging-abcd", + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{ + UID: 501, GID: 20, DockerUser: "501:20", + }), + } + + persistent, err := RenderDockerInputs(plan, "demo") + if err != nil { + t.Fatal(err) + } + var compose composePlanDocument + if err := yaml.Unmarshal(persistent.Compose, &compose); err != nil { + t.Fatal(err) + } + service := compose.Services["environment"] + 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 service.Environment["HOME"] != plan.Sandbox.TemporaryHome || service.Environment["TMPDIR"] != plan.Sandbox.TemporaryHome { + t.Fatalf("persistent sandbox environment = %#v", service.Environment) + } + if !containsString(service.Tmpfs, temporaryHomeMountForPlan(plan)) { + t.Fatalf("persistent sandbox temporary home = %#v", service.Tmpfs) + } + + transient, err := TransientCommandSpec( + plan, + ResolvedEnvironmentCommand{Argv: []string{"/bin/true"}}, + workspace, + nil, + false, + false, + ) + if err != nil { + t.Fatal(err) + } + if !containsInOrder(transient.Args, []string{"--read-only", "--mount", transientHomeMountForPlan(plan)}) { + t.Fatalf("transient sandbox read-only root/home = %#v", transient.Args) + } + if !containsInOrder(transient.Args, []string{ + "--env", "HOME=" + plan.Sandbox.TemporaryHome, + "--env", "TMPDIR=" + plan.Sandbox.TemporaryHome, + }) { + t.Fatalf("transient sandbox environment = %#v", transient.Args) + } + if !containsInOrder(transient.Args, []string{ + "--entrypoint", ProbeContainerExecutable, + plan.Image, "run-transient", "501", "20", "/bin/true", + }) { + t.Fatalf("transient sandbox runtime identity = %#v", transient.Args) + } + + invalid := plan + invalid.Sandbox.ReadOnlyRoot = false + 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") { + t.Fatalf("transient invalid sandbox error = %v", err) + } +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/internal/dockerdeploy/command_execution.go b/internal/dockerdeploy/command_execution.go index f8aaed1d..20129990 100644 --- a/internal/dockerdeploy/command_execution.go +++ b/internal/dockerdeploy/command_execution.go @@ -213,17 +213,14 @@ func transientContainerCommandSpecV1(operation string, container string, plan Do if strings.TrimSpace(container) == "" { return CommandSpec{}, fmt.Errorf("transient container name is required") } + 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) } - if plan.RuntimeUser.UID < 0 || plan.RuntimeUser.GID < 0 { - return CommandSpec{}, fmt.Errorf("transient runtime user requires non-negative UID and GID") - } - runtimeUID := strconv.Itoa(plan.RuntimeUser.UID) - runtimeGID := strconv.Itoa(plan.RuntimeUser.GID) - if plan.RuntimeUser.DockerUser != runtimeUID+":"+runtimeGID { - return CommandSpec{}, fmt.Errorf("transient runtime user does not match its numeric UID and GID") - } + 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) @@ -241,10 +238,15 @@ func transientContainerCommandSpecV1(operation string, container string, plan Do args := []string{ operation, "--pull", "never", "--rm", "--name", container, "--user", "0:0", - "--read-only", "--mount", homeMount, - "--mount", helperMount, - "--env", "HOME=" + home, "--env", "TMPDIR=" + home, } + if plan.Sandbox.ReadOnlyRoot { + args = append(args, "--read-only") + } + args = append(args, + "--mount", homeMount, + "--mount", helperMount, + "--env", "HOME="+home, "--env", "TMPDIR="+home, + ) if interactive { args = append(args, "--interactive") } diff --git a/internal/dockerdeploy/command_execution_integration_test.go b/internal/dockerdeploy/command_execution_integration_test.go index a5921869..a29fcbc3 100644 --- a/internal/dockerdeploy/command_execution_integration_test.go +++ b/internal/dockerdeploy/command_execution_integration_test.go @@ -29,7 +29,7 @@ func TestTransientCommandDockerIntegrationInitializesPrivateHomeAndDropsPrivileg workspace := testPreparedProbeWorkspace(t, platform, helperDir) plan := DockerExecutionPlan{ DeploymentDir: t.TempDir(), Image: "debian:bookworm-slim", ContainerName: "reploy-transient-home-integration", - RuntimeUser: RuntimeUserPlan{UID: 12345, GID: 23456, DockerUser: "12345:23456"}, + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 12345, GID: 23456, DockerUser: "12345:23456"}), } command := ResolvedEnvironmentCommand{Argv: []string{ "/bin/sh", "-eu", "-c", diff --git a/internal/dockerdeploy/command_execution_test.go b/internal/dockerdeploy/command_execution_test.go index 8002803e..1915787d 100644 --- a/internal/dockerdeploy/command_execution_test.go +++ b/internal/dockerdeploy/command_execution_test.go @@ -84,7 +84,7 @@ func TestTransientAndShellCommandsUseDockerExecArgv(t *testing.T) { workspace := testPreparedProbeWorkspace(t, platform, probeDir) mountDir := t.TempDir() outputDir := t.TempDir() - plan := DockerExecutionPlan{DeploymentDir: t.TempDir(), Image: "reploy/demo:staging", ContainerName: "demo", RuntimeUser: 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, 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) if err != nil { @@ -100,7 +100,7 @@ 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.RuntimeUser.DockerUser) { + 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"}) || @@ -140,7 +140,7 @@ func TestTransientCommandSpecQuotesCommaContainingMountFields(t *testing.T) { workspace := testPreparedProbeWorkspace(t, platform, probeDir) plan := DockerExecutionPlan{ DeploymentDir: t.TempDir(), Image: "reploy/demo:staging", ContainerName: "demo", - RuntimeUser: RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}, + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}), Mounts: []MountExecutionPlan{{ Mode: blueprint.MountManagedBind, Source: mountDir, Target: "/conf,preview", ReadOnly: true, @@ -179,7 +179,7 @@ func TestTransientCommandSpecMasksDeploymentPrivatePaths(t *testing.T) { workspace := testPreparedProbeWorkspace(t, platform, t.TempDir()) plan := DockerExecutionPlan{ DeploymentDir: deploymentDir, Image: "reploy/demo:staging", ContainerName: "demo", - RuntimeUser: RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}, + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}), Mounts: []MountExecutionPlan{{ Name: "deployment", Mode: blueprint.MountBind, Source: deploymentDir, SourceKind: deploy.RuntimeMountSourceDirectory, Target: "/deployment", @@ -213,8 +213,8 @@ func TestPlanTransientContainerExecutionV1SeparatesCreateStartAndCleanup(t *test workspace := testPreparedProbeWorkspace(t, platform, t.TempDir()) plan := DockerExecutionPlan{ DeploymentDir: t.TempDir(), Image: "reploy/demo:staging", ContainerName: "demo-staging-abcd", - RuntimeUser: RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}, - Mounts: []MountExecutionPlan{{Mode: blueprint.MountManagedBind, Source: t.TempDir(), Target: "/conf", ReadOnly: true}}, + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}), + Mounts: []MountExecutionPlan{{Mode: blueprint.MountManagedBind, Source: t.TempDir(), Target: "/conf", ReadOnly: true}}, } output := &transientOutputMount{ HostDirectory: t.TempDir(), Variable: runtimeOutputFileVariable, diff --git a/internal/dockerdeploy/current_app_command_run.go b/internal/dockerdeploy/current_app_command_run.go index 5c2ee531..145f424f 100644 --- a/internal/dockerdeploy/current_app_command_run.go +++ b/internal/dockerdeploy/current_app_command_run.go @@ -149,7 +149,7 @@ func runCurrentAppCommandV1(ctx context.Context, input CurrentAppCommandRunInput if err != nil { return err } - output, err := backend.prepareOutput(input.OutputDir, input.OutputFile, planned.Docker.RuntimeUser) + output, err := backend.prepareOutput(input.OutputDir, input.OutputFile, planned.Docker.Sandbox.RuntimeUser) if err != nil { return err } diff --git a/internal/dockerdeploy/current_app_command_run_test.go b/internal/dockerdeploy/current_app_command_run_test.go index c84f2424..652cc029 100644 --- a/internal/dockerdeploy/current_app_command_run_test.go +++ b/internal/dockerdeploy/current_app_command_run_test.go @@ -230,8 +230,8 @@ func currentAppCommandRunTestBackend( }, prepareOutput: func(_ string, _ string, runtimeUser RuntimeUserPlan) (*oneShotOutputSession, error) { *order = append(*order, "prepare output") - if !reflect.DeepEqual(runtimeUser, planned.Docker.RuntimeUser) { - t.Fatalf("output runtime user = %#v, want %#v", runtimeUser, planned.Docker.RuntimeUser) + if !reflect.DeepEqual(runtimeUser, planned.Docker.Sandbox.RuntimeUser) { + t.Fatalf("output runtime user = %#v, want %#v", runtimeUser, planned.Docker.Sandbox.RuntimeUser) } return &oneShotOutputSession{mount: &transientOutputMount{ HostDirectory: dir, Variable: runtimeOutputDirectoryVariable, ContainerPath: runtimeOutputRoot, diff --git a/internal/dockerdeploy/current_build_reuse_test.go b/internal/dockerdeploy/current_build_reuse_test.go index 2e4e873a..c6de1ef0 100644 --- a/internal/dockerdeploy/current_build_reuse_test.go +++ b/internal/dockerdeploy/current_build_reuse_test.go @@ -83,7 +83,9 @@ func TestCurrentBuildMatchesInvalidatesEverySemanticBoundary(t *testing.T) { input.Base.ImmutableReference = string(input.Base.ConfigDigest) }}, {name: "runtime policy", mutate: func(_ *CurrentBuild, input *CurrentBuildReuseInput) { - input.DockerPlan.TemporaryHome = "/tmp/alternate-home" + input.DockerPlan.Mounts = []MountExecutionPlan{{ + Name: "alternate", Mode: blueprint.MountVolume, Target: "/mnt/alternate", + }} }}, } { t.Run(test.name, func(t *testing.T) { @@ -165,7 +167,7 @@ func currentBuildReuseFixture(t *testing.T) (CurrentBuild, CurrentBuildReuseInpu t.Fatal(err) } lock.ResolvedRequestDigest = requestDigest - dockerPlan := DockerExecutionPlan{} + dockerPlan := DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(1000, 1000)} plans, err := RuntimePlansV1(document, dockerPlan) if err != nil { t.Fatal(err) diff --git a/internal/dockerdeploy/current_build_verify_test.go b/internal/dockerdeploy/current_build_verify_test.go index 4db36d1e..be1be286 100644 --- a/internal/dockerdeploy/current_build_verify_test.go +++ b/internal/dockerdeploy/current_build_verify_test.go @@ -364,7 +364,7 @@ func TestVerifyLockedRuntimeV1ResolvesEveryCommandAndTrigger(t *testing.T) { lock := fixture.lock lock.Catalog = []providers.RealizedOutput{output} document := commandTestDocument() - runtime := CurrentRuntimePlanV1{Document: document, Docker: DockerExecutionPlan{}} + runtime := CurrentRuntimePlanV1{Document: document, Docker: DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(1000, 1000)}} plans, err := RuntimePlansV1(document, runtime.Docker) if err != nil { t.Fatal(err) @@ -394,7 +394,7 @@ func baseOnlyCurrentBuildVerificationFixtureV1(t *testing.T) currentBuildVerific if err != nil { t.Fatal(err) } - runtime := CurrentRuntimePlanV1{Document: document, Docker: DockerExecutionPlan{}} + runtime := CurrentRuntimePlanV1{Document: document, Docker: DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(1000, 1000)}} plans, err := RuntimePlansV1(runtime.Document, runtime.Docker) if err != nil { t.Fatal(err) diff --git a/internal/dockerdeploy/current_runtime_files_test.go b/internal/dockerdeploy/current_runtime_files_test.go index 91db9827..6c9c05af 100644 --- a/internal/dockerdeploy/current_runtime_files_test.go +++ b/internal/dockerdeploy/current_runtime_files_test.go @@ -150,7 +150,7 @@ func currentRuntimeFilePlanV1() CurrentRuntimePlanV1 { Docker: DockerExecutionPlan{ EnvironmentID: "demo", DeploymentDir: "/deployment", Phase: blueprint.PhaseStaged, Image: "reploy/env/demo-deadbeef:g-current", ContainerName: "demo-staging", NetworkName: "demo-staging", - RuntimeUser: RuntimeUserPlan{DockerUser: "1000:1000"}, TemporaryHome: environmentTemporaryHome, + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 1000, GID: 1000, DockerUser: "1000:1000"}), }, } } diff --git a/internal/dockerdeploy/current_runtime_plan.go b/internal/dockerdeploy/current_runtime_plan.go index de92e61c..1dfa640d 100644 --- a/internal/dockerdeploy/current_runtime_plan.go +++ b/internal/dockerdeploy/current_runtime_plan.go @@ -87,7 +87,7 @@ func planCurrentRuntimeV1(input CurrentRuntimePlanInputV1, backend currentRuntim if backend.resolveSystemOwner == nil { return CurrentRuntimePlanV1{}, fmt.Errorf("plan current system runtime requires an account resolver") } - values, err := providerInstallAccountValuesV1(document.Environment.Install.System.RunAs) + values, err := providerInstallAccountValuesV1(document.Environment.Install.System.Account) if err != nil { return CurrentRuntimePlanV1{}, err } @@ -95,8 +95,8 @@ func planCurrentRuntimeV1(input CurrentRuntimePlanInputV1, backend currentRuntim if err != nil { return CurrentRuntimePlanV1{}, fmt.Errorf("resolve installed runtime account: %w", err) } - context.SystemUser = document.Environment.Install.System.RunAs.User - context.SystemGroup = document.Environment.Install.System.RunAs.Group + context.SystemUser = document.Environment.Install.System.Account.User + context.SystemGroup = document.Environment.Install.System.Account.Group context.UID = owner.UID context.GID = owner.GID } diff --git a/internal/dockerdeploy/current_runtime_plan_test.go b/internal/dockerdeploy/current_runtime_plan_test.go index e098bd9b..7e8d1e0e 100644 --- a/internal/dockerdeploy/current_runtime_plan_test.go +++ b/internal/dockerdeploy/current_runtime_plan_test.go @@ -28,8 +28,8 @@ func TestPlanCurrentRuntimeV1ReconstructsStagedPlanWithoutSystemLookup(t *testin if lookups != 0 || result.Docker.Phase != blueprint.PhaseStaged || result.Docker.Scope != nil { t.Fatalf("staged plan = %#v, lookups=%d", result.Docker, lookups) } - if result.Docker.Image != current.Generation.Reference || result.Docker.RuntimeUser.DockerUser != "1000:1001" { - t.Fatalf("staged image/user = %q/%q", result.Docker.Image, result.Docker.RuntimeUser.DockerUser) + if result.Docker.Image != current.Generation.Reference || result.Docker.Sandbox.RuntimeUser.DockerUser != "1000:1001" { + t.Fatalf("staged image/user = %q/%q", result.Docker.Image, result.Docker.Sandbox.RuntimeUser.DockerUser) } } @@ -100,7 +100,7 @@ func TestPlanCurrentRuntimeV1ReportsSystemAccountLookupFailure(t *testing.T) { current, input := runtimeCurrentBuildFixture(t) dir := t.TempDir() document := input.Document - document.Environment.Install.System.RunAs = blueprint.RunAs{User: "service", Group: "service", OnMissing: "fail"} + document.Environment.Install.System.Account = blueprint.SystemAccount{User: "service", Group: "service", OnMissing: "fail"} var err error current.State.Blueprint, err = blueprint.EncodeResolvedDocumentV1(document) if err != nil { diff --git a/internal/dockerdeploy/current_shell_run_test.go b/internal/dockerdeploy/current_shell_run_test.go index 56558b13..a33f7b04 100644 --- a/internal/dockerdeploy/current_shell_run_test.go +++ b/internal/dockerdeploy/current_shell_run_test.go @@ -82,8 +82,8 @@ func TestRunCurrentShellV1ReadOnlyChangesOnlyTransientMounts(t *testing.T) { t.Fatalf("read-only shell execution saw writable mount %#v", mount) } } - if plan.TemporaryHome != original.TemporaryHome { - t.Fatalf("temporary home changed from %q to %q", original.TemporaryHome, plan.TemporaryHome) + if plan.Sandbox.TemporaryHome != original.Sandbox.TemporaryHome { + t.Fatalf("temporary home changed from %q to %q", original.Sandbox.TemporaryHome, plan.Sandbox.TemporaryHome) } return TransientContainerExecutionV1{Container: "demo-" + runID}, nil } diff --git a/internal/dockerdeploy/doctor.go b/internal/dockerdeploy/doctor.go index 1846ef22..df9f9349 100644 --- a/internal/dockerdeploy/doctor.go +++ b/internal/dockerdeploy/doctor.go @@ -223,7 +223,7 @@ func providerPreinstallFindings(dir string, scope InstallScope, state deploy.Sta if err != nil { return append(findings, DoctorFinding{Status: "fail", Message: fmt.Sprintf("cannot decode system install account: %v", err)}) } - account, err := doctorInspectAccount(parsedScope, document.Environment.Install.System.RunAs) + account, err := doctorInspectAccount(parsedScope, document.Environment.Install.System.Account) if err != nil { return append(findings, DoctorFinding{Status: "fail", Message: fmt.Sprintf("system install account is not ready: %v", err)}) } diff --git a/internal/dockerdeploy/doctor_test.go b/internal/dockerdeploy/doctor_test.go index cdf84895..d4885d8c 100644 --- a/internal/dockerdeploy/doctor_test.go +++ b/internal/dockerdeploy/doctor_test.go @@ -238,7 +238,7 @@ func TestDoctorPreinstallChecksSystemScopePrivilegesAndAccount(t *testing.T) { if err != nil { t.Fatal(err) } - document.Environment.Install.System.RunAs = blueprint.RunAs{User: "demo", Group: "demo", OnMissing: "create"} + document.Environment.Install.System.Account = blueprint.SystemAccount{User: "demo", Group: "demo", OnMissing: "create"} state.Blueprint, err = blueprint.EncodeResolvedDocumentV1(document) if err != nil { t.Fatal(err) @@ -268,9 +268,9 @@ func TestDoctorPreinstallChecksSystemScopePrivilegesAndAccount(t *testing.T) { return dockerRuntimeInfo{OperatingSystem: "Linux"}, nil } doctorGeteuid = func() int { return 0 } - doctorInspectAccount = func(scope InstallScope, runAs blueprint.RunAs) (providerInstallAccountInspectionV1, error) { - if scope != InstallScopeSystem || runAs.User != "demo" || runAs.Group != "demo" { - t.Fatalf("account input = %q/%#v", scope, runAs) + doctorInspectAccount = func(scope InstallScope, account blueprint.SystemAccount) (providerInstallAccountInspectionV1, error) { + if scope != InstallScopeSystem || account.User != "demo" || account.Group != "demo" { + t.Fatalf("account input = %q/%#v", scope, account) } return providerInstallAccountInspectionV1{User: "demo", Group: "demo", WillCreate: true}, nil } diff --git a/internal/dockerdeploy/execution_plan.go b/internal/dockerdeploy/execution_plan.go index 83da038d..854b2dea 100644 --- a/internal/dockerdeploy/execution_plan.go +++ b/internal/dockerdeploy/execution_plan.go @@ -44,8 +44,7 @@ type DockerExecutionPlan struct { PrivateEnvironment bool Workload *WorkloadExecutionPlan Mounts []MountExecutionPlan - RuntimeUser RuntimeUserPlan - TemporaryHome string + Sandbox ApplicationSandboxPlanV1 } const environmentTemporaryHome = "/mnt/reploy-home" @@ -123,7 +122,6 @@ func PlanDockerExecution(document blueprint.Document, context DockerPlanContext) plan := DockerExecutionPlan{ EnvironmentID: document.Environment.ID, DeploymentDir: identityPath, Phase: context.Phase, Scope: context.Scope, Image: context.GeneratedImage, ContainerName: containerName, NetworkName: containerName, - TemporaryHome: environmentTemporaryHome, } if document.Environment.Workload != nil { plan.Workload = &WorkloadExecutionPlan{Command: document.Environment.Workload.Command, Endpoints: map[string]EndpointExecutionPlan{}} @@ -138,10 +136,14 @@ func PlanDockerExecution(document blueprint.Document, context DockerPlanContext) if err := planDockerEndpoints(document, context, &plan); err != nil { return DockerExecutionPlan{}, err } - plan.RuntimeUser, err = planRuntimeUser(document, context) + runtimeUser, err := planRuntimeUser(document, context) if err != nil { return DockerExecutionPlan{}, err } + plan.Sandbox = newApplicationSandboxPlanV1(runtimeUser) + if err := ValidateApplicationSandboxPlanV1(plan.Sandbox); err != nil { + return DockerExecutionPlan{}, err + } return plan, nil } @@ -322,8 +324,8 @@ func planRuntimeUser(document blueprint.Document, context DockerPlanContext) (Ru fmt.Sprintf("current-user install overrides the image user with UID/GID %d:%d", context.UID, context.GID), "the image must tolerate an arbitrary non-root identity and may write persistently only to declared writable paths", ) - if document.Environment.Install.System.RunAs.User != "" || document.Environment.Install.System.RunAs.Group != "" { - plan.Warnings = append(plan.Warnings, "environment.install.system.run_as does not apply to current-user scope") + if document.Environment.Install.System.Account.User != "" || document.Environment.Install.System.Account.Group != "" { + plan.Warnings = append(plan.Warnings, "environment.install.system.account does not apply to current-user scope") } } return plan, nil diff --git a/internal/dockerdeploy/execution_plan_test.go b/internal/dockerdeploy/execution_plan_test.go index 4f22d617..c624a4c6 100644 --- a/internal/dockerdeploy/execution_plan_test.go +++ b/internal/dockerdeploy/execution_plan_test.go @@ -25,8 +25,8 @@ func TestPlanDockerExecutionBaseIdentity(t *testing.T) { if plan.EnvironmentID != "demo" || plan.Image != "reploy/demo:staging" || plan.Phase != blueprint.PhaseStaged { t.Fatalf("plan = %#v", plan) } - if plan.Scope != nil || plan.RuntimeUser.UID != 501 { - t.Fatalf("scope/user = %#v / %#v", plan.Scope, plan.RuntimeUser) + if plan.Scope != nil || plan.Sandbox.RuntimeUser.UID != 501 { + t.Fatalf("scope/user = %#v / %#v", plan.Scope, plan.Sandbox.RuntimeUser) } stagingHash, err := pathIdentityHash(stagingDir) if err != nil { @@ -132,7 +132,7 @@ func TestNormalizeProbeHostUsesLoopbackForWildcards(t *testing.T) { func TestPlanRuntimeUserScopePolicy(t *testing.T) { scope := blueprint.InstallScopeUser - document := blueprint.Document{Environment: blueprint.Environment{Install: blueprint.Install{System: blueprint.SystemInstall{RunAs: blueprint.RunAs{User: "service", Group: "service"}}}}} + document := blueprint.Document{Environment: blueprint.Environment{Install: blueprint.Install{System: blueprint.SystemInstall{Account: blueprint.SystemAccount{User: "service", Group: "service"}}}}} plan, err := planRuntimeUser(document, DockerPlanContext{Phase: blueprint.PhaseInstalled, Scope: &scope, Host: blueprint.HostMacOS, UID: 501, GID: 20}) if err != nil { t.Fatal(err) diff --git a/internal/dockerdeploy/execution_render.go b/internal/dockerdeploy/execution_render.go index 47498223..34aa1f7f 100644 --- a/internal/dockerdeploy/execution_render.go +++ b/internal/dockerdeploy/execution_render.go @@ -96,9 +96,12 @@ func RenderDockerInputs(plan DockerExecutionPlan, controlScript string) (DockerR if controlScript == "" { return DockerRenderedInputs{}, fmt.Errorf("control script is required") } + if err := ValidateApplicationSandboxPlanV1(plan.Sandbox); err != nil { + return DockerRenderedInputs{}, fmt.Errorf("render application sandbox: %w", err) + } service := composePlanService{ - Image: plan.Image, PullPolicy: "never", ContainerName: plan.ContainerName, User: plan.RuntimeUser.DockerUser, Restart: plan.Restart, - ReadOnly: true, Environment: temporaryEnvironmentForPlan(plan), Tmpfs: []string{temporaryHomeMountForPlan(plan)}, + 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)}, } if plan.Workload != nil { service.Command = append([]string(nil), plan.Workload.Argv...) @@ -173,7 +176,7 @@ func RenderDockerInputs(plan DockerExecutionPlan, controlScript string) (DockerR "REPLOY_PHASE": string(plan.Phase), "REPLOY_IMAGE": plan.Image, "REPLOY_CONTAINER_NAME": plan.ContainerName, - "REPLOY_DOCKER_USER": plan.RuntimeUser.DockerUser, + "REPLOY_DOCKER_USER": plan.Sandbox.RuntimeUser.DockerUser, } if scope != "" { environment["REPLOY_SCOPE"] = scope @@ -202,10 +205,7 @@ func RenderDockerInputs(plan DockerExecutionPlan, controlScript string) (DockerR } func temporaryHomeForPlan(plan DockerExecutionPlan) string { - if strings.TrimSpace(plan.TemporaryHome) == "" { - return environmentTemporaryHome - } - return plan.TemporaryHome + return plan.Sandbox.TemporaryHome } func temporaryHomeMountForPlan(plan DockerExecutionPlan) string { diff --git a/internal/dockerdeploy/execution_render_test.go b/internal/dockerdeploy/execution_render_test.go index d5f878c0..4a5f8a09 100644 --- a/internal/dockerdeploy/execution_render_test.go +++ b/internal/dockerdeploy/execution_render_test.go @@ -11,7 +11,7 @@ import ( func TestRenderDockerInputsFromResolvedPlan(t *testing.T) { plan := DockerExecutionPlan{ EnvironmentID: "demo", DeploymentDir: "/deployment", Phase: blueprint.PhaseStaged, Image: "reploy/demo:staging", - ContainerName: "demo-staging-abcd", NetworkName: "demo-staging-abcd", RuntimeUser: RuntimeUserPlan{DockerUser: "501:20"}, + ContainerName: "demo-staging-abcd", NetworkName: "demo-staging-abcd", Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 501, GID: 20, DockerUser: "501:20"}), Mounts: []MountExecutionPlan{{Name: "config", Mode: blueprint.MountManagedBind, Source: "/tmp/demo/conf", Target: "/config", ReadOnly: true}}, Workload: &WorkloadExecutionPlan{Command: "server", Argv: []string{"/opt/reploy/python/bin/demo", "serve"}, Endpoints: map[string]EndpointExecutionPlan{ "http": {Scheme: "http", PublishAddress: "127.0.0.1", PublishedPort: 18080, ContainerPort: 8080}, diff --git a/internal/dockerdeploy/live_run_container_test.go b/internal/dockerdeploy/live_run_container_test.go index f4e1577b..ad5f6e76 100644 --- a/internal/dockerdeploy/live_run_container_test.go +++ b/internal/dockerdeploy/live_run_container_test.go @@ -28,7 +28,7 @@ func admittedTransientFixtureV1(t *testing.T, dir string) (*deploy.OperationLock } workspace := testPreparedProbeWorkspace(t, platform, t.TempDir()) execution, err := PlanTransientContainerExecutionV1( - DockerExecutionPlan{DeploymentDir: dir, ContainerName: "demo", Image: "demo:image", RuntimeUser: RuntimeUserPlan{UID: 1000, GID: 1000, DockerUser: "1000:1000"}}, + 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, ) if err != nil { diff --git a/internal/dockerdeploy/private_workload_environment_integration_test.go b/internal/dockerdeploy/private_workload_environment_integration_test.go index 0dadfee6..512fead8 100644 --- a/internal/dockerdeploy/private_workload_environment_integration_test.go +++ b/internal/dockerdeploy/private_workload_environment_integration_test.go @@ -71,7 +71,7 @@ printf 'private-mask-pass\n'`, expectedTokenDigest) plan := DockerExecutionPlan{ EnvironmentID: "private-mask", DeploymentDir: deploymentDir, Phase: blueprint.PhaseStaged, Image: image, ContainerName: container, NetworkName: unique, - RuntimeUser: RuntimeUserPlan{DockerUser: "12345:23456"}, + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 12345, GID: 23456, DockerUser: "12345:23456"}), PrivateEnvironment: true, Workload: &WorkloadExecutionPlan{Argv: []string{ "/bin/sh", "-eu", "-c", @@ -180,7 +180,7 @@ func TestPrivateRuntimeMasksDockerIntegrationProtectTransientContainer(t *testin unique := fmt.Sprintf("reploy-transient-private-mask-%d-%d", os.Getpid(), time.Now().UnixNano()) plan := DockerExecutionPlan{ DeploymentDir: deploymentDir, Image: image, ContainerName: unique, - RuntimeUser: RuntimeUserPlan{UID: 12345, GID: 23456, DockerUser: "12345:23456"}, + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 12345, GID: 23456, DockerUser: "12345:23456"}), Mounts: []MountExecutionPlan{{ Name: "deployment", Mode: blueprint.MountBind, Source: deploymentDir, SourceKind: deploy.RuntimeMountSourceDirectory, Target: "/deployment", ReadOnly: true, diff --git a/internal/dockerdeploy/private_workload_environment_test.go b/internal/dockerdeploy/private_workload_environment_test.go index 42ce0297..58342f2d 100644 --- a/internal/dockerdeploy/private_workload_environment_test.go +++ b/internal/dockerdeploy/private_workload_environment_test.go @@ -210,7 +210,7 @@ func TestRenderDockerInputsUsesSecretFreePrivateLauncher(t *testing.T) { plan := DockerExecutionPlan{ EnvironmentID: "demo", DeploymentDir: deploymentDir, Phase: blueprint.PhaseStaged, Image: "sha256:image", ContainerName: "demo", NetworkName: "demo", - RuntimeUser: RuntimeUserPlan{DockerUser: "1000:1000"}, + Sandbox: newApplicationSandboxPlanV1(RuntimeUserPlan{UID: 1000, GID: 1000, DockerUser: "1000:1000"}), PrivateEnvironment: true, Workload: &WorkloadExecutionPlan{Argv: []string{"/opt/demo", "serve"}}, Mounts: []MountExecutionPlan{{ diff --git a/internal/dockerdeploy/provider_build_completion_test.go b/internal/dockerdeploy/provider_build_completion_test.go index 84b5a660..5375ad1d 100644 --- a/internal/dockerdeploy/provider_build_completion_test.go +++ b/internal/dockerdeploy/provider_build_completion_test.go @@ -284,7 +284,9 @@ func TestCompleteProviderBuildRejectsValidationPlanDriftBeforeBackendWork(t *tes func TestCompleteProviderBuildRejectsRuntimePlanDriftBeforeBackendWork(t *testing.T) { input, operation, store := providerBuildCompletionFixture(t) defer operation.Unlock() - input.DockerPlan.TemporaryHome = "/mnt/changed-home" + input.DockerPlan.Mounts = []MountExecutionPlan{{ + Name: "changed", Mode: blueprint.MountVolume, Target: "/mnt/changed", + }} calls := 0 _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, RunOptions) (FinalizedBuildValidationResult, error) { @@ -438,7 +440,7 @@ func providerBuildCompletionFixture(t *testing.T) (ProviderBuildCompletionInput, if err := document.Environment.RebuildProviderContributions(); err != nil { t.Fatal(err) } - dockerPlan := DockerExecutionPlan{} + dockerPlan := DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(1000, 1000)} plans, err := RuntimePlansV1(document, dockerPlan) if err != nil { t.Fatal(err) diff --git a/internal/dockerdeploy/provider_build_prepare_test.go b/internal/dockerdeploy/provider_build_prepare_test.go index 70db3d05..8e79292a 100644 --- a/internal/dockerdeploy/provider_build_prepare_test.go +++ b/internal/dockerdeploy/provider_build_prepare_test.go @@ -66,7 +66,7 @@ func providerBuildPreparationFixture(t *testing.T) ( input := LockedProviderBuildPreparationInputV1{ Operation: operation, Store: store, Environment: "current-test", DeploymentDir: dir, PackageOverrides: packageOverrides, - Sources: []providers.ResolvedSourceInput{}, DockerPlan: DockerExecutionPlan{}, + Sources: []providers.ResolvedSourceInput{}, DockerPlan: DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(1000, 1000)}, } return input, loaded, current, selected, prepared } diff --git a/internal/dockerdeploy/provider_build_run_test.go b/internal/dockerdeploy/provider_build_run_test.go index bcf7fb21..df37cc34 100644 --- a/internal/dockerdeploy/provider_build_run_test.go +++ b/internal/dockerdeploy/provider_build_run_test.go @@ -55,7 +55,7 @@ func TestRunProviderBuildV1HoldsOneLockAcrossPreparationAndExecution(t *testing. if input.Environment != document.Environment.ID || input.DeploymentDir != dir || !input.NoCache || input.Store.Root() != filepath.Join(dir, ".reploy", "provider-store") || len(input.Sources) != 0 || input.BaseImage != baseOverride { t.Fatalf("preparation input = %#v", input) } - if input.DockerPlan.EnvironmentID != "demo" || input.DockerPlan.Phase != blueprint.PhaseStaged || input.DockerPlan.Image != providerBuildPlanImage || input.DockerPlan.Scope != nil || input.DockerPlan.RuntimeUser.UID != 1001 || input.DockerPlan.RuntimeUser.GID != 1002 { + if input.DockerPlan.EnvironmentID != "demo" || input.DockerPlan.Phase != blueprint.PhaseStaged || input.DockerPlan.Image != providerBuildPlanImage || input.DockerPlan.Scope != nil || input.DockerPlan.Sandbox.RuntimeUser.UID != 1001 || input.DockerPlan.Sandbox.RuntimeUser.GID != 1002 { t.Fatalf("Docker plan = %#v", input.DockerPlan) } return LockedProviderBuildPreparationV1{Operation: input.Operation, Store: input.Store}, nil diff --git a/internal/dockerdeploy/provider_install_account.go b/internal/dockerdeploy/provider_install_account.go index e6fd4d48..73138575 100644 --- a/internal/dockerdeploy/provider_install_account.go +++ b/internal/dockerdeploy/provider_install_account.go @@ -33,8 +33,8 @@ type providerInstallAccountInspectionBackendV1 struct { creationReadiness func(map[string]string, error) (string, error) } -func inspectProviderInstallAccountV1(scope InstallScope, runAs blueprint.RunAs) (providerInstallAccountInspectionV1, error) { - return inspectProviderInstallAccountWithV1(scope, runAs, providerInstallAccountInspectionBackendV1{ +func inspectProviderInstallAccountV1(scope InstallScope, account blueprint.SystemAccount) (providerInstallAccountInspectionV1, error) { + return inspectProviderInstallAccountWithV1(scope, account, providerInstallAccountInspectionBackendV1{ resolve: resolveInstallOwner, creationReadiness: installOwnerCreationSpecForResolveError, }) @@ -42,7 +42,7 @@ func inspectProviderInstallAccountV1(scope InstallScope, runAs blueprint.RunAs) func inspectProviderInstallAccountWithV1( scope InstallScope, - runAs blueprint.RunAs, + account blueprint.SystemAccount, backend providerInstallAccountInspectionBackendV1, ) (providerInstallAccountInspectionV1, error) { parsedScope, err := ParseInstallScope(string(scope)) @@ -55,12 +55,12 @@ func inspectProviderInstallAccountWithV1( if backend.resolve == nil || backend.creationReadiness == nil { return providerInstallAccountInspectionV1{}, fmt.Errorf("inspect provider install account requires a complete backend") } - values, err := providerInstallAccountValuesV1(runAs) + values, err := providerInstallAccountValuesV1(account) if err != nil { return providerInstallAccountInspectionV1{}, err } inspection := providerInstallAccountInspectionV1{ - User: strings.TrimSpace(runAs.User), Group: strings.TrimSpace(runAs.Group), + User: strings.TrimSpace(account.User), Group: strings.TrimSpace(account.Group), } owner, resolveErr := backend.resolve(values) if resolveErr == nil { @@ -78,12 +78,12 @@ func inspectProviderInstallAccountWithV1( func prepareProviderInstallAccountV1( ctx context.Context, - runAs blueprint.RunAs, + account blueprint.SystemAccount, sourceStore providerstore.Store, sourceBuild CurrentBuild, input providerInstallRunInputV1, ) (providerInstallRunInputV1, error) { - return prepareProviderInstallAccountWithV1(ctx, runAs, sourceStore, sourceBuild, input, providerInstallAccountBackendV1{ + return prepareProviderInstallAccountWithV1(ctx, account, sourceStore, sourceBuild, input, providerInstallAccountBackendV1{ resolve: resolveInstallOwner, creationReadiness: installOwnerCreationSpecForResolveError, bulkDiskRequirements: providerInstallAccountBulkDiskRequirementsV1, @@ -94,7 +94,7 @@ func prepareProviderInstallAccountV1( func prepareProviderInstallAccountWithV1( ctx context.Context, - runAs blueprint.RunAs, + account blueprint.SystemAccount, sourceStore providerstore.Store, sourceBuild CurrentBuild, input providerInstallRunInputV1, @@ -121,13 +121,13 @@ func prepareProviderInstallAccountWithV1( return providerInstallRunInputV1{}, fmt.Errorf("prepare provider install account requires a complete backend") } - values, err := providerInstallAccountValuesV1(runAs) + values, err := providerInstallAccountValuesV1(account) if err != nil { return providerInstallRunInputV1{}, err } owner, resolveErr := backend.resolve(values) if resolveErr == nil { - return providerInstallInputWithAccountV1(input, runAs, owner), nil + return providerInstallInputWithAccountV1(input, account, owner), nil } if _, err := backend.creationReadiness(values, resolveErr); err != nil { return providerInstallRunInputV1{}, fmt.Errorf("resolve system install account: %w", err) @@ -149,24 +149,24 @@ func prepareProviderInstallAccountWithV1( if err != nil { return providerInstallRunInputV1{}, fmt.Errorf("resolve system install account after creation: %w", err) } - return providerInstallInputWithAccountV1(input, runAs, owner), nil + return providerInstallInputWithAccountV1(input, account, owner), nil } -func providerInstallAccountValuesV1(runAs blueprint.RunAs) (map[string]string, error) { - userName := strings.TrimSpace(runAs.User) - groupName := strings.TrimSpace(runAs.Group) +func providerInstallAccountValuesV1(account blueprint.SystemAccount) (map[string]string, error) { + userName := strings.TrimSpace(account.User) + groupName := strings.TrimSpace(account.Group) if userName == "" || groupName == "" { - return nil, fmt.Errorf("environment.install.system.run_as must name both user and group for a system install") + return nil, fmt.Errorf("environment.install.system.account must name both user and group for a system install") } return map[string]string{ reployInstallOwnerEnv: userName + ":" + groupName, - reployInstallOwnerOnMissing: strings.TrimSpace(runAs.OnMissing), + reployInstallOwnerOnMissing: strings.TrimSpace(account.OnMissing), }, nil } -func providerInstallInputWithAccountV1(input providerInstallRunInputV1, runAs blueprint.RunAs, owner resolvedInstallOwner) providerInstallRunInputV1 { - input.Install.SystemUser = strings.TrimSpace(runAs.User) - input.Install.SystemGroup = strings.TrimSpace(runAs.Group) +func providerInstallInputWithAccountV1(input providerInstallRunInputV1, account blueprint.SystemAccount, owner resolvedInstallOwner) providerInstallRunInputV1 { + input.Install.SystemUser = strings.TrimSpace(account.User) + input.Install.SystemGroup = strings.TrimSpace(account.Group) input.Install.SystemUID = owner.UID input.Install.SystemGID = owner.GID return input diff --git a/internal/dockerdeploy/provider_install_account_test.go b/internal/dockerdeploy/provider_install_account_test.go index b310252e..1950095a 100644 --- a/internal/dockerdeploy/provider_install_account_test.go +++ b/internal/dockerdeploy/provider_install_account_test.go @@ -23,7 +23,7 @@ func TestPrepareProviderInstallAccountChecksBulkDiskBeforeCreatingMissingAccount } got, err := prepareProviderInstallAccountWithV1( t.Context(), - blueprint.RunAs{User: "service", Group: "service", OnMissing: "create"}, + blueprint.SystemAccount{User: "service", Group: "service", OnMissing: "create"}, providerstore.Store{}, CurrentBuild{}, input, @@ -79,7 +79,7 @@ func TestInspectProviderInstallAccountReportsMissingCreateWithoutIDs(t *testing. missing := errors.New("unknown user") inspection, err := inspectProviderInstallAccountWithV1( InstallScopeSystem, - blueprint.RunAs{User: "service", Group: "service", OnMissing: "create"}, + blueprint.SystemAccount{User: "service", Group: "service", OnMissing: "create"}, providerInstallAccountInspectionBackendV1{ resolve: func(map[string]string) (resolvedInstallOwner, error) { return resolvedInstallOwner{}, missing @@ -103,7 +103,7 @@ func TestInspectProviderInstallAccountReportsMissingCreateWithoutIDs(t *testing. func TestInspectProviderInstallAccountReportsExistingNumericIdentity(t *testing.T) { inspection, err := inspectProviderInstallAccountWithV1( InstallScopeSystem, - blueprint.RunAs{User: "service", Group: "service", OnMissing: "create"}, + blueprint.SystemAccount{User: "service", Group: "service", OnMissing: "create"}, providerInstallAccountInspectionBackendV1{ resolve: func(map[string]string) (resolvedInstallOwner, error) { return resolvedInstallOwner{UID: 991, GID: 992}, nil @@ -126,7 +126,7 @@ func TestInspectProviderInstallAccountRejectsMissingFailPolicy(t *testing.T) { want := errors.New("unknown user") _, err := inspectProviderInstallAccountWithV1( InstallScopeSystem, - blueprint.RunAs{User: "service", Group: "service", OnMissing: "fail"}, + blueprint.SystemAccount{User: "service", Group: "service", OnMissing: "fail"}, providerInstallAccountInspectionBackendV1{ resolve: func(map[string]string) (resolvedInstallOwner, error) { return resolvedInstallOwner{}, want @@ -146,7 +146,7 @@ func TestPrepareProviderInstallAccountDoesNotCreateWhenBulkDiskPreflightFails(t created := false _, err := prepareProviderInstallAccountWithV1( t.Context(), - blueprint.RunAs{User: "service", Group: "service", OnMissing: "create"}, + blueprint.SystemAccount{User: "service", Group: "service", OnMissing: "create"}, providerstore.Store{}, CurrentBuild{}, providerInstallRunInputV1{DestinationDeploymentDir: t.TempDir(), Install: providerInstallOptionsV1{Scope: InstallScopeSystem}}, @@ -180,7 +180,7 @@ func TestPrepareProviderInstallAccountReusesExistingAccountWithoutCreationPrefli } got, err := prepareProviderInstallAccountWithV1( t.Context(), - blueprint.RunAs{User: "service", Group: "service", OnMissing: "create"}, + blueprint.SystemAccount{User: "service", Group: "service", OnMissing: "create"}, providerstore.Store{}, CurrentBuild{}, input, @@ -220,7 +220,7 @@ func TestPrepareProviderInstallAccountIgnoresSystemAccountForUserScope(t *testin Scope: InstallScopeUser, SystemUser: "stale", SystemGroup: "stale", SystemUID: 991, SystemGID: 992, }, } - got, err := prepareProviderInstallAccountWithV1(t.Context(), blueprint.RunAs{}, providerstore.Store{}, CurrentBuild{}, input, providerInstallAccountBackendV1{}) + got, err := prepareProviderInstallAccountWithV1(t.Context(), blueprint.SystemAccount{}, providerstore.Store{}, CurrentBuild{}, input, providerInstallAccountBackendV1{}) if err != nil { t.Fatal(err) } diff --git a/internal/dockerdeploy/provider_install_plan_test.go b/internal/dockerdeploy/provider_install_plan_test.go index db8a0a95..613c71dc 100644 --- a/internal/dockerdeploy/provider_install_plan_test.go +++ b/internal/dockerdeploy/provider_install_plan_test.go @@ -94,7 +94,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.RuntimeUser.DockerUser != "991:992" { + if plan.Backend != installBackendLinuxSystemd || plan.Docker.Image != references.Generation || plan.Docker.Sandbox.RuntimeUser.DockerUser != "991:992" { 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_run.go b/internal/dockerdeploy/provider_install_run.go index 18adef03..27e82d9e 100644 --- a/internal/dockerdeploy/provider_install_run.go +++ b/internal/dockerdeploy/provider_install_run.go @@ -89,7 +89,7 @@ type providerInstallRunBackend struct { newStore func(string) (providerstore.Store, error) recoverDestination func(context.Context, *deploy.OperationLock, providerstore.Store, string, string) (bool, error) buildSource func(context.Context, LockedProviderBuildRunInputV1) (LockedProviderBuildExecutionResultV1, error) - prepareAccount func(context.Context, blueprint.RunAs, providerstore.Store, CurrentBuild, providerInstallRunInputV1) (providerInstallRunInputV1, error) + prepareAccount func(context.Context, blueprint.SystemAccount, providerstore.Store, CurrentBuild, providerInstallRunInputV1) (providerInstallRunInputV1, error) newReferences func(string, string) (EnvironmentImageReferences, error) planInstallation func(context.Context, providerInstallPlanningV1) (providerInstallationPlanV1, error) inspectHostTools func(context.Context, installBackend) (providerInstallHostToolsV1, error) @@ -240,7 +240,7 @@ func runProviderInstallV1( return deploy.StateV1{}, err } } - input, err = backend.prepareAccount(ctx, document.Environment.Install.System.RunAs, sourceStore, sourceBuild, input) + input, err = backend.prepareAccount(ctx, document.Environment.Install.System.Account, sourceStore, sourceBuild, input) if err != nil { return deploy.StateV1{}, fmt.Errorf("prepare provider installation account: %w", err) } diff --git a/internal/dockerdeploy/provider_install_run_test.go b/internal/dockerdeploy/provider_install_run_test.go index 4dad5246..4eb3bf45 100644 --- a/internal/dockerdeploy/provider_install_run_test.go +++ b/internal/dockerdeploy/provider_install_run_test.go @@ -73,7 +73,7 @@ func TestRunProviderInstallV1HoldsSourceBeforeDestinationAndReleasesInReverse(t } return build, nil }, - prepareAccount: func(_ context.Context, runAs blueprint.RunAs, store providerstore.Store, gotBuild CurrentBuild, input providerInstallRunInputV1) (providerInstallRunInputV1, error) { + prepareAccount: func(_ context.Context, account blueprint.SystemAccount, store providerstore.Store, gotBuild CurrentBuild, input providerInstallRunInputV1) (providerInstallRunInputV1, error) { order = append(order, "prepare-install-account") if err := locks[sourceDir].RequireHeld(); err != nil { t.Fatal(err) @@ -81,8 +81,8 @@ func TestRunProviderInstallV1HoldsSourceBeforeDestinationAndReleasesInReverse(t if err := locks[destinationDir].RequireHeld(); err != nil { t.Fatal("destination was not validated before account preparation") } - if runAs != (blueprint.RunAs{}) || store.Root() != filepath.Join(sourceDir, ".reploy", providerstore.StoreDirName) || !reflect.DeepEqual(gotBuild.State, build.State) { - t.Fatalf("account preparation input = %#v / %s / %#v", runAs, store.Root(), gotBuild) + if account != (blueprint.SystemAccount{}) || store.Root() != filepath.Join(sourceDir, ".reploy", providerstore.StoreDirName) || !reflect.DeepEqual(gotBuild.State, build.State) { + t.Fatalf("account preparation input = %#v / %s / %#v", account, store.Root(), gotBuild) } input.Install.SystemUser = "service" input.Install.SystemGroup = "service" @@ -807,7 +807,7 @@ func TestRunProviderInstallV1RejectsStagingDestinationBeforePreparation(t *testi buildSource: func(context.Context, LockedProviderBuildRunInputV1) (LockedProviderBuildExecutionResultV1, error) { return build, nil }, - prepareAccount: func(context.Context, blueprint.RunAs, providerstore.Store, CurrentBuild, providerInstallRunInputV1) (providerInstallRunInputV1, error) { + prepareAccount: func(context.Context, blueprint.SystemAccount, providerstore.Store, CurrentBuild, providerInstallRunInputV1) (providerInstallRunInputV1, error) { t.Fatal("prepared an account for a staging destination") return providerInstallRunInputV1{}, nil }, @@ -887,7 +887,7 @@ func TestRunProviderInstallV1RejectsServiceRenameBeforeDestinationPreparation(t buildSource: func(context.Context, LockedProviderBuildRunInputV1) (LockedProviderBuildExecutionResultV1, error) { return build, nil }, - prepareAccount: func(context.Context, blueprint.RunAs, providerstore.Store, CurrentBuild, providerInstallRunInputV1) (providerInstallRunInputV1, error) { + prepareAccount: func(context.Context, blueprint.SystemAccount, providerstore.Store, CurrentBuild, providerInstallRunInputV1) (providerInstallRunInputV1, error) { t.Fatal("prepared an account before rejecting a service rename") return providerInstallRunInputV1{}, nil }, @@ -963,7 +963,7 @@ func providerInstallRunPlanFixture(destinationDir string, references Environment ControlScript: "democtl", Docker: DockerExecutionPlan{ Phase: blueprint.PhaseInstalled, Scope: &scope, Image: references.Generation, - ContainerName: "demo", NetworkName: "demo", + ContainerName: "demo", NetworkName: "demo", Sandbox: testApplicationSandboxPlanV1(1000, 1000), }, Rendered: DockerRenderedInputs{Compose: []byte("services: {}\n"), Environment: map[string]string{"REPLOY_IMAGE": references.Generation}}, Backend: installBackendLinuxSystemd, @@ -972,7 +972,7 @@ func providerInstallRunPlanFixture(destinationDir string, references Environment func providerInstallRunPrepareAccountFixture( _ context.Context, - _ blueprint.RunAs, + _ blueprint.SystemAccount, _ providerstore.Store, _ CurrentBuild, input providerInstallRunInputV1, diff --git a/internal/dockerdeploy/runtime_plan.go b/internal/dockerdeploy/runtime_plan.go index 097e638d..aa8d054f 100644 --- a/internal/dockerdeploy/runtime_plan.go +++ b/internal/dockerdeploy/runtime_plan.go @@ -23,6 +23,9 @@ func RuntimePlansV1(document blueprint.Document, dockerPlan DockerExecutionPlan) if (document.Environment.Workload == nil) != (dockerPlan.Workload == nil) { return nil, fmt.Errorf("runtime workload does not match the resolved Docker plan") } + if err := ValidateApplicationSandboxPlanV1(dockerPlan.Sandbox); err != nil { + return nil, fmt.Errorf("runtime application sandbox: %w", err) + } baseMounts, err := runtimeMountsV1(dockerPlan) if err != nil { return nil, err diff --git a/internal/dockerdeploy/runtime_plan_test.go b/internal/dockerdeploy/runtime_plan_test.go index 46df3297..31654b39 100644 --- a/internal/dockerdeploy/runtime_plan_test.go +++ b/internal/dockerdeploy/runtime_plan_test.go @@ -13,7 +13,7 @@ import ( func TestRuntimePlansV1CoversWorkloadShellCommandsAndOutputVariant(t *testing.T) { document := runtimePlanDocument() - plan := DockerExecutionPlan{Workload: &WorkloadExecutionPlan{}, Mounts: []MountExecutionPlan{ + plan := DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(1000, 1000), Workload: &WorkloadExecutionPlan{}, Mounts: []MountExecutionPlan{ {Name: "data", Mode: blueprint.MountVolume, Target: "/mnt/data", ReadOnly: false}, {Name: "config", Mode: blueprint.MountManagedBind, Source: filepath.Join(t.TempDir(), "config"), Target: "/mnt/config", ReadOnly: true}, }} @@ -56,7 +56,7 @@ func TestRuntimePlansV1CoversWorkloadShellCommandsAndOutputVariant(t *testing.T) func TestRuntimePlansV1DetectsExternalBindSourceKind(t *testing.T) { file := filepath.Join(t.TempDir(), "config.yaml") - plans, err := RuntimePlansV1(runtimePlanDocument(), DockerExecutionPlan{Workload: &WorkloadExecutionPlan{}, Mounts: []MountExecutionPlan{{ + plans, err := RuntimePlansV1(runtimePlanDocument(), DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(1000, 1000), Workload: &WorkloadExecutionPlan{}, Mounts: []MountExecutionPlan{{ Name: "config", Mode: blueprint.MountBind, Source: file, SourceKind: deploy.RuntimeMountSourceFile, Target: "/mnt/config", ReadOnly: true, }}}) @@ -68,7 +68,7 @@ func TestRuntimePlansV1DetectsExternalBindSourceKind(t *testing.T) { t.Fatalf("bind source kind = %q", shell.Mounts[0].SourceKind) } - _, err = RuntimePlansV1(runtimePlanDocument(), DockerExecutionPlan{Workload: &WorkloadExecutionPlan{}, Mounts: []MountExecutionPlan{{ + _, err = RuntimePlansV1(runtimePlanDocument(), DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(1000, 1000), Workload: &WorkloadExecutionPlan{}, Mounts: []MountExecutionPlan{{ Name: "missing-kind", Mode: blueprint.MountBind, Source: filepath.Join(t.TempDir(), "source"), Target: "/mnt/missing", }}}) if err == nil || !strings.Contains(err.Error(), "source kind") { @@ -81,7 +81,7 @@ func TestRuntimePlansV1RejectsUnknownCommandExecutable(t *testing.T) { command := document.Environment.Commands["check"] command.Executable = "application.missing" document.Environment.Commands["check"] = command - _, err := RuntimePlansV1(document, DockerExecutionPlan{Workload: &WorkloadExecutionPlan{}}) + _, err := RuntimePlansV1(document, DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(1000, 1000), Workload: &WorkloadExecutionPlan{}}) if err == nil || !strings.Contains(err.Error(), "unknown executable") { t.Fatalf("unknown executable error = %v", err) } diff --git a/internal/dockerdeploy/state_v1_test_helpers_test.go b/internal/dockerdeploy/state_v1_test_helpers_test.go index c585d395..f631aa11 100644 --- a/internal/dockerdeploy/state_v1_test_helpers_test.go +++ b/internal/dockerdeploy/state_v1_test_helpers_test.go @@ -1,12 +1,19 @@ package dockerdeploy import ( + "strconv" "testing" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/canonical" ) +func testApplicationSandboxPlanV1(uid int, gid int) ApplicationSandboxPlanV1 { + return newApplicationSandboxPlanV1(RuntimeUserPlan{ + UID: uid, GID: gid, DockerUser: strconv.Itoa(uid) + ":" + strconv.Itoa(gid), + }) +} + func testResolvedBlueprintV1(t *testing.T, document blueprint.Document) blueprint.ResolvedDocumentV1 { t.Helper() payload, err := blueprint.EncodeResolvedDocumentV1(document) diff --git a/website/docs/blueprint-structure.md b/website/docs/blueprint-structure.md index b6c30a43..7f5eda2a 100644 --- a/website/docs/blueprint-structure.md +++ b/website/docs/blueprint-structure.md @@ -157,7 +157,7 @@ environment: target: default_path: "{{ reploy.install_root }}/{{ environment.id }}" system: - run_as: + account: user: example group: example on_missing: create