Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changes/unreleased/+canonical-application-sandbox.yaml
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion .changes/unreleased/+linux-user-scope-install.yaml
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/BLUEPRINT_ENVIRONMENT_IMPLEMENTATION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions docs/BLUEPRINT_ENVIRONMENT_MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions docs/CONTROLLED_SESSION_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/CROSS_PLATFORM_INSTALL_LOCATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
4 changes: 2 additions & 2 deletions internal/blueprint/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -244,10 +244,10 @@ type InstallTarget struct {
}

type SystemInstall struct {
RunAs RunAs
Account SystemAccount

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve or migrate the resolved-v1 account field

Existing deployments become unreadable after upgrading: every previously encoded blueprint-resolved-v1 document contains a RunAs JSON member (even when empty), while DecodeResolvedDocumentV1 uses DisallowUnknownFields and the schema identifier remains unchanged. Consequently, staging, install, runtime, and recovery operations fail while decoding any pre-change state-v1; preserve the old wire name or add an explicit state migration/schema transition before renaming this field.

Useful? React with 👍 / 👎.

}

type RunAs struct {
type SystemAccount struct {
User string
Group string
OnMissing string
Expand Down
2 changes: 1 addition & 1 deletion internal/blueprint/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)},
}
Expand Down
4 changes: 2 additions & 2 deletions internal/blueprint/syntax.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
23 changes: 23 additions & 0 deletions internal/blueprint/syntax_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
42 changes: 42 additions & 0 deletions internal/dockerdeploy/application_sandbox_plan.go
Original file line number Diff line number Diff line change
@@ -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
}
92 changes: 92 additions & 0 deletions internal/dockerdeploy/application_sandbox_plan_test.go
Original file line number Diff line number Diff line change
@@ -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
}
24 changes: 13 additions & 11 deletions internal/dockerdeploy/command_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions internal/dockerdeploy/command_execution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"}) ||
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion internal/dockerdeploy/current_app_command_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading
Loading