diff --git a/.changes/unreleased/+container-local-runtime-user.yaml b/.changes/unreleased/+container-local-runtime-user.yaml new file mode 100644 index 00000000..c78844b2 --- /dev/null +++ b/.changes/unreleased/+container-local-runtime-user.yaml @@ -0,0 +1,2 @@ +kind: Added +body: Add a blueprint-configurable container-local runtime account, with stable native Windows identity mapping and identity-specific final runtime layers for staging and installation. diff --git a/docs/APT_PROVIDER.md b/docs/APT_PROVIDER.md index a5acba47..59cfb0f0 100644 --- a/docs/APT_PROVIDER.md +++ b/docs/APT_PROVIDER.md @@ -195,8 +195,10 @@ environment. Container root in Docker Desktop remains root only inside the Linux container and Desktop VM, not macOS root or Windows Administrator. Native-Linux user-scope -containers use the invoking UID/GID. Docker Desktop user-scope containers use a -stable Reploy-managed non-root Linux identity recorded in deployment state. +containers use the invoking UID/GID. macOS user-scope containers use the +invoking Unix UID/GID, while native Windows maps the invoking SID +deterministically to a stable nonzero Linux UID/GID. The final runtime layer +supplies the blueprint's container-local account name for that numeric identity. Reploy validates portable output access and mount destinations while building, then checks host mount-source existence and policy before runtime; Docker and the workload report identity-dependent mount permission failures. Linux system @@ -2420,10 +2422,11 @@ not public `type: apt` components. neutralized entrypoint/command/healthcheck behavior, explicit working directories and users, runtime-only base environment defaults, provider environment isolation, informational exposed ports, and `SIGTERM` shutdown. -- Runtime-identity tests proving native-Linux user scope uses the invoking - UID/GID, Docker Desktop uses a recorded Reploy-managed non-root Linux identity, - system scope uses its service account, base `USER` is ignored, container root - never implies Desktop host root/Administrator, and every declared mount is +- Runtime-identity tests proving Unix user scope uses the invoking UID/GID, + native Windows maps its SID to a stable nonzero Linux identity, the runtime + layer supplies the configured local account, system scope uses its service + account, base `USER` is ignored, container root never implies Desktop host + root/Administrator, and every declared mount is usable by the selected identity. Output-access cases cover portable `a+rX`-equivalent Python roots; rejection of base/APT exports that rely on owner, group, or ACL access; inaccessible parent and link-target directories; diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 33df94ac..1439f289 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -70,15 +70,13 @@ 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. +- [ ] `P1` Complete native Docker Desktop identity evidence. + The Linux-container contract now creates a real local account, uses the + invoking Unix UID/GID, and maps native Windows SIDs to stable nonzero + numeric identities. Add native macOS and Windows Docker Desktop evidence + across staged and installed current-user workloads and transient + commands, including account-name resolution, stable Windows mapping, and + explicit confirmation that no accidental `0:0` identity is selected. - [ ] `P2` Define cancellation at the admission boundary. Specify the authoritative outcome when a waiting caller is promoted at diff --git a/docs/BLUEPRINT_ENVIRONMENT_IMPLEMENTATION_PLAN.md b/docs/BLUEPRINT_ENVIRONMENT_IMPLEMENTATION_PLAN.md index b0753cf8..57e7e60e 100644 --- a/docs/BLUEPRINT_ENVIRONMENT_IMPLEMENTATION_PLAN.md +++ b/docs/BLUEPRINT_ENVIRONMENT_IMPLEMENTATION_PLAN.md @@ -436,11 +436,12 @@ Ownership: - Staging has no install scope and uses the backend's current-user container identity policy. -- On native Linux, staged and installed user containers use the invoking user's +- On Unix hosts, staged and installed user containers use the invoking user's numeric UID/GID. -- On Docker Desktop, staged and installed user containers use a stable, - recorded Reploy-managed non-root Linux UID/GID inside the Desktop VM rather - than the macOS or Windows account's numeric identity. +- On native Windows, staged and installed user containers map the invoking SID + deterministically to a stable nonzero Linux UID/GID. +- The final Linux runtime layer supplies the blueprint's container-local + account name for the effective numeric identity. - User-scope operations warn when overriding image `USER` or ignoring `system.account`. - Installed system scope uses the resolved service account. diff --git a/docs/BLUEPRINT_ENVIRONMENT_MODEL.md b/docs/BLUEPRINT_ENVIRONMENT_MODEL.md index 4bc80919..26e40507 100644 --- a/docs/BLUEPRINT_ENVIRONMENT_MODEL.md +++ b/docs/BLUEPRINT_ENVIRONMENT_MODEL.md @@ -1,6 +1,6 @@ --- status: Active -updated: 2026-07-27 +updated: 2026-08-02 summary: Normative blueprint environment, workload, application, provider contribution, lifecycle, and Docker rendering model. supersedes: docs/CROSS_PLATFORM_INSTALL_LOCATIONS.md --- @@ -90,6 +90,8 @@ environment: packages: {} # Environment-owned package contributions. applications: {} # Application-owned packages, options, and executables. allow_concurrent: auto # App-command and shell overlap policy. + runtime: + user: reploy # Container-local account name; defaults to reploy. terminal: {} # Terminal/color integration. install: {} # Installation target, identity, and success output. mounts: {} # Runtime filesystem contracts. @@ -97,7 +99,14 @@ environment: workload: {} # Optional persistent primary workload. ``` -Optional empty nodes are omitted in an actual blueprint. Backend-specific +Optional empty nodes are omitted in an actual blueprint. `runtime.user` names +the ordinary non-root account inside the target environment; it does not select +a host account or grant root. It must be a portable lowercase Unix account name: +one to 32 bytes, beginning with a lowercase ASCII letter or underscore, followed +only by lowercase ASCII letters, digits, underscores, or hyphens. `root` is +reserved and cannot be selected through this field. If the base image already +defines the same account name with a different numeric ID, runtime-layer +construction fails rather than rewriting that unrelated account. Backend-specific runtime choices remain under the top-level `docker` node. ## Internal Execution Phases @@ -249,9 +258,9 @@ workload type. Every command invocation is one-shot by default and is expected to exit with a status. In Docker, Reploy runs it in a transient container created from the same -materialized environment image, as the configured non-root runtime user, with the same managed -paths and application configuration as the workload container. The transient -container is removed when the command exits. Selecting a command as +materialized environment image, as the effective runtime user, with the same +managed paths and application configuration as the workload container. The +transient container is removed when the command exits. Selecting a command as `environment.workload.command` is the only operation that promotes it to the persistent container entrypoint. @@ -1344,17 +1353,39 @@ in the resulting layer. APT/dpkg, RPM/DNF, and Alpine/APK installation may run as root in their build steps. Python, Go, and Rust layers use the permissions required to populate their final image paths. -Runtime ownership comes from the backend and install scope, never from the base -image's configured `USER`. Reploy supplies an explicit user for every container: +Runtime authority comes from the backend and install scope, never from the base +image's configured `USER`. Reploy supplies an explicit numeric identity for +every container: - provider materialization uses the provider-declared build identity, including container root where system-package installation requires it; -- a native-Linux current-user install uses the invoking user's numeric UID/GID; -- a Docker Desktop current-user install uses a stable Reploy-managed non-root - Linux UID/GID inside the Desktop VM, recorded in deployment state; this is a - container identity, not the macOS or Windows account running Docker Desktop; +- a native-Linux or macOS current-user install uses the invoking user's numeric + UID/GID; +- a native-Windows current-user install maps the invoking Windows SID + deterministically to a stable nonzero Linux UID/GID; this is a container + identity, not a Windows account inside the image; - a Linux system install uses the resolved service account. +For the current Linux-container backend, Reploy also materializes a real local +account in the final runtime layer. Its name is +`environment.runtime.user`, defaulting to `reploy`, while its UID/GID are the +effective numeric authority above. The account and its numeric identity are +locked build inputs, so changing either makes reuse stale. The blueprint name +is deliberately independent of a Windows domain account or Unix host account. +An effective UID of zero uses the existing local name `root`; a blueprint +cannot request root merely by naming it. + +This is a portable blueprint contract with target-specific realization. The +current backend writes Linux account databases. A future native-Windows or +other target backend may realize the same local identity through different OS +mechanisms rather than emulating `/etc/passwd`. + +When installation selects a different numeric authority from staging, Reploy +reuses the validated provider graph and rebuilds only the final +account/verifier runtime layer and its validation labels. The installed lock +records that identity-specific final image; the staged generation remains +bound to the staging user. + Docker Desktop mediates explicitly shared host files through the Desktop user. The container identity still controls permissions inside the container, named volumes, and the container-visible mode of mounted paths. Reploy does not assume @@ -1383,14 +1414,13 @@ no runtime-access record. Docker container creation and the workload report permission failures that depend on the actual runtime identity or mount implementation. -Before a current-user install, Reploy reports the selected policy and numeric -container UID/GID. On native Linux it identifies the invoking host user; on -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 `account` configuration is present, Reploy reports that it does not apply -to current-user scope. +Before a current-user install, Reploy reports the selected policy, local account +name, and numeric container UID/GID. On Unix hosts it identifies the invoking +host identity; on native Windows it explains that the numeric mapping exists +only inside the Linux container/VM. The warning also states that the image's +configured user is overridden and persistent writes are available only through +declared writable paths. If 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 environment-schema object. A provider node has a semantic bundle identity, an @@ -1881,7 +1911,8 @@ checks against the exact immutable image: 1. Its destination is a normalized absolute path other than `/` and does not overlap `/dev`, `/proc`, `/sys`, `/run/secrets`, or Docker-managed - `/etc/hostname`, `/etc/hosts`, or `/etc/resolv.conf`. + `/etc/hostname`, `/etc/hosts`, or `/etc/resolv.conf`, or Reploy-generated + `/etc/passwd` or `/etc/group` account databases. 2. The destination is absent or an empty real directory. Existing files, symlinks, non-directories, mountpoints, and non-empty directories fail. The backend validates existing ancestors without following symlinks and needs @@ -2170,12 +2201,12 @@ blueprint namespace. a compatible user runtime. System scope fails clearly rather than silently degrading to user scope. -`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.account` is present, Reploy reports that it is inapplicable to user scope -along with the non-root image compatibility warning defined above. +`system.account` is host ownership and container-process authority for a system +install, not another install scope and not the container-local account name. A +current-user install uses the invoking Unix numeric identity or the stable +native-Windows SID mapping defined above. If `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 1168fdd0..e093bac3 100644 --- a/docs/CONTROLLED_SESSION_DESIGN.md +++ b/docs/CONTROLLED_SESSION_DESIGN.md @@ -66,12 +66,15 @@ root, network, capability, or filesystem grants required by their provider contract. Those are explicit construction authorities, not implicit exceptions or authorities inherited by application containers. -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. The setting selects the -installation's host account; it is not a second container-user setting. +Every application image receives a container-local account. Its blueprint name +is `environment.runtime.user`, defaulting to `reploy`; it is not a host account +selector or an authority grant. Staged and installed user-scope containers use +the invoking Unix user's numeric identity, while native Windows maps the +invoking SID to a stable nonzero Linux UID/GID. Installed system-scope +containers use the host account selected by +`environment.install.system.account`. The current Linux-container backend +materializes the local account through Linux account databases; other target-OS +backends may realize the same contract differently. If the effective runtime user is root, Reploy emits a precise warning that the application can interfere with more of its container. Root does not implicitly @@ -602,19 +605,29 @@ identity: - installed system-scope execution uses the host account explicitly selected by `environment.install.system.account`, resolved to its numeric identity. -Using the invoking identity for user-scope execution preserves ordinary host -file ownership and avoids predictable permission failures. The container -image's configured `USER` is not the runtime authority, and the image does not -need a matching named account. Reploy passes the effective numeric `UID:GID` -and the host account's supplementary GIDs, then supplies its ordinary transient -writable home. A non-root account with a root primary or supplementary group is -rejected rather than importing privileged host group membership into the -container. +Using the invoking Unix identity for user-scope execution preserves ordinary +host file ownership and avoids predictable permission failures. Native Windows +instead derives a stable nonzero Linux UID/GID from the invoking SID. The +container image's configured `USER` is not the runtime authority. Reploy passes +the effective numeric `UID:GID` and applicable supplementary GIDs, supplies its +ordinary transient writable home, and adds a real local account named by +`environment.runtime.user` (default `reploy`) to the final runtime layer. The +name and numeric identity are locked build inputs. A non-root account with a +root primary or supplementary group is rejected rather than importing +privileged group membership into the container. A controlled-session client inherits this identity and cannot override it. A different system-scope identity is an installation configuration decision, not a session capability. +The local account is an OS-neutral blueprint concept with target-specific +realization. The initial Linux-container backend writes `/etc/passwd` and +`/etc/group`; runtime mounts may not overlap those generated account-database +paths. A future native target backend may use its own account mechanism. +If an installation selects a different numeric account, Reploy preserves the +provider layers and rebuilds the final runtime-account layer for the installed +generation rather than changing the staged generation. + ### Root Runtime Identity Root applies when the effective runtime UID is `0`: because staged or @@ -1050,7 +1063,8 @@ commands directly as the final identity, drops all capabilities, enables prohibits privileged mode, host namespaces, and host devices in the common plan. Live Docker tests inspect both runtime paths. Trusted production startup verification is also implemented: Reploy packages the platform-specific probe -in a final runtime layer, records that layer outside the provider graph, and +in a final runtime layer, creates the locked container-local account there, +records that layer outside the provider graph, and uses its fixed verify-and-exec contract as the outermost process for persistent workloads, transient commands, shells, and lifecycle commands. The verifier fails closed unless `/proc/self/status` reports seccomp filtering, @@ -1167,11 +1181,11 @@ analysis. Privileged application containers remain outside this design. - Host Reploy remains in both the PTY and lifecycle paths; the containers do not receive a direct control connection to one another. - Staged and installed user-scope application containers retain practical host - ownership by using the invoking host identity, which may be unnamed inside - the image. + ownership through their effective numeric identity and receive a predictable + container-local account name. - Installed system-scope application containers use the configured host - service account's numeric identity without requiring a corresponding - container-local username. + service account's numeric identity under the blueprint's container-local + account name. - Root application containers are possible but visibly weaker. - Root application containers never receive host input or shared-state binds; local source requires the separately designed disposable-copy capability. diff --git a/internal/blueprint/model.go b/internal/blueprint/model.go index 018a0e91..b9a9248d 100644 --- a/internal/blueprint/model.go +++ b/internal/blueprint/model.go @@ -32,6 +32,7 @@ type Environment struct { // provider internals migrate to first-class contribution identities. Components map[string]Component `json:"-"` AllowConcurrent ConcurrentRunPolicy + Runtime EnvironmentRuntime Terminal Terminal Install Install Mounts map[string]EnvironmentMount @@ -39,6 +40,10 @@ type Environment struct { Workload *Workload } +type EnvironmentRuntime struct { + User string +} + type EnvironmentPackages struct { OS []APTPackageRequest } diff --git a/internal/blueprint/resolve.go b/internal/blueprint/resolve.go index 5efd823b..2df9526b 100644 --- a/internal/blueprint/resolve.go +++ b/internal/blueprint/resolve.go @@ -13,6 +13,8 @@ var builtInControlOperations = map[string]bool{ "enable": true, "disable": true, } +const DefaultRuntimeUser = "reploy" + func Resolve(source Syntax) (Document, error) { compatibility, err := ParseCompatibility(source.Blueprint.Compatibility.Platforms) if err != nil { @@ -34,6 +36,10 @@ func Resolve(source Syntax) (Document, error) { if err != nil { return Document{}, err } + runtimeUser, err := resolveRuntimeUser(source.Environment.Runtime.User) + if err != nil { + return Document{}, err + } extended, err := resolveExtends(source) if err != nil { return Document{}, err @@ -53,6 +59,7 @@ func Resolve(source Syntax) (Document, error) { Applications: map[string]Application{}, Components: map[string]Component{}, AllowConcurrent: allowConcurrent, + Runtime: EnvironmentRuntime{User: runtimeUser}, Terminal: Terminal{ColorEnv: strings.TrimSpace(source.Environment.Terminal.ColorEnv)}, Install: resolveInstallSyntax(source.Environment.Install, variables), Mounts: map[string]EnvironmentMount{}, @@ -80,6 +87,34 @@ func Resolve(source Syntax) (Document, error) { return document, nil } +func resolveRuntimeUser(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return DefaultRuntimeUser, nil + } + if err := ValidateRuntimeUserName(value); err != nil { + return "", fmt.Errorf("environment.runtime.user %w", err) + } + if value == "root" { + return "", fmt.Errorf("environment.runtime.user names the non-root local account and must not be root") + } + return value, nil +} + +func ValidateRuntimeUserName(value string) error { + if value == "" || len(value) > 32 { + return fmt.Errorf("must be a nonempty portable Unix user name no longer than 32 bytes") + } + for index, character := range value { + if character >= 'a' && character <= 'z' || character == '_' && index == 0 || + index > 0 && (character >= '0' && character <= '9' || character == '_' || character == '-') { + continue + } + return fmt.Errorf("must be a portable lowercase Unix user name") + } + return nil +} + func resolveConcurrentRunPolicy(value string) (ConcurrentRunPolicy, error) { policy := ConcurrentRunPolicy(strings.TrimSpace(value)) if policy == "" { @@ -401,7 +436,7 @@ func ValidateRuntimeMountDestination(target string) error { if target == "/" { return fmt.Errorf("must not be the container filesystem root") } - for _, reserved := range []string{"/dev", "/proc", "/sys", "/run/secrets", "/etc/hostname", "/etc/hosts", "/etc/resolv.conf"} { + for _, reserved := range []string{"/dev", "/proc", "/sys", "/run/secrets", "/etc/hostname", "/etc/hosts", "/etc/resolv.conf", "/etc/passwd", "/etc/group"} { if runtimeMountPathsOverlap(target, reserved) { return fmt.Errorf("overlaps reserved container path %q", reserved) } diff --git a/internal/blueprint/resolve_test.go b/internal/blueprint/resolve_test.go index d5b703f2..5deffb58 100644 --- a/internal/blueprint/resolve_test.go +++ b/internal/blueprint/resolve_test.go @@ -23,6 +23,9 @@ func TestResolveProducesTypedEnvironment(t *testing.T) { if document.Environment.AllowConcurrent != ConcurrentRunAuto { t.Fatalf("allow concurrent = %q", document.Environment.AllowConcurrent) } + if document.Environment.Runtime.User != DefaultRuntimeUser { + t.Fatalf("runtime user = %q", document.Environment.Runtime.User) + } if got := document.Blueprint.Compatibility.Platforms; !reflect.DeepEqual(got, []Platform{ {OS: "linux", Architecture: "amd64", Canonical: "linux/amd64"}, {OS: "linux", Architecture: "arm64", Canonical: "linux/arm64"}, @@ -48,6 +51,36 @@ func TestResolveProducesTypedEnvironment(t *testing.T) { } } +func TestResolveRuntimeUser(t *testing.T) { + for _, test := range []struct { + name string + value string + want string + ok bool + }{ + {name: "default", want: DefaultRuntimeUser, ok: true}, + {name: "explicit", value: "omegaflow", want: "omegaflow", ok: true}, + {name: "underscore", value: "_agent-2", want: "_agent-2", ok: true}, + {name: "root", value: "root"}, + {name: "uppercase", value: "OmegaFlow"}, + {name: "leading digit", value: "2agent"}, + {name: "space", value: "agent user"}, + } { + t.Run(test.name, func(t *testing.T) { + got, err := resolveRuntimeUser(test.value) + if test.ok { + if err != nil || got != test.want { + t.Fatalf("runtime user = %q, %v; want %q", got, err, test.want) + } + return + } + if err == nil || !strings.Contains(err.Error(), "environment.runtime.user") { + t.Fatalf("runtime user error = %v", err) + } + }) + } +} + func TestResolveAcceptsBaseOnlyEnvironment(t *testing.T) { value := strings.Replace( minimalBlueprint, @@ -240,6 +273,8 @@ func TestResolveRejectsUnsafeEnvironmentMountTargets(t *testing.T) { {name: "device subtree", target: "/dev/shm", want: `reserved container path "/dev"`}, {name: "resolver parent", target: "/etc", want: `reserved container path "/etc/hostname"`}, {name: "Docker resolver file", target: "/etc/resolv.conf", want: `reserved container path "/etc/resolv.conf"`}, + {name: "account database", target: "/etc/passwd", want: `reserved container path "/etc/passwd"`}, + {name: "group database", target: "/etc/group", want: `reserved container path "/etc/group"`}, {name: "secrets", target: "/run/secrets/app", want: `reserved container path "/run/secrets"`}, {name: "provider root", target: "/opt/reploy/providers", want: `reserved container path "/opt/reploy"`}, {name: "temporary home parent", target: "/mnt", want: `reserved container path "/mnt/reploy-home"`}, diff --git a/internal/blueprint/syntax.go b/internal/blueprint/syntax.go index 202c91b6..0aa1f943 100644 --- a/internal/blueprint/syntax.go +++ b/internal/blueprint/syntax.go @@ -35,6 +35,7 @@ type EnvironmentSyntax struct { Packages EnvironmentPackagesSyntax `yaml:"packages"` Applications map[string]ApplicationSyntax `yaml:"applications"` AllowConcurrent string `yaml:"allow_concurrent"` + Runtime EnvironmentRuntimeSyntax `yaml:"runtime"` Terminal TerminalSyntax `yaml:"terminal"` Install InstallSyntax `yaml:"install"` Mounts map[string]MountSyntax `yaml:"mounts"` @@ -42,6 +43,10 @@ type EnvironmentSyntax struct { Workload *WorkloadSyntax `yaml:"workload"` } +type EnvironmentRuntimeSyntax struct { + User string `yaml:"user"` +} + type TerminalSyntax struct { ColorEnv string `yaml:"color_env"` } diff --git a/internal/cli/build_command.go b/internal/cli/build_command.go index e3be88e9..e124be06 100644 --- a/internal/cli/build_command.go +++ b/internal/cli/build_command.go @@ -147,6 +147,9 @@ func runDockerBuild(args []string, stdout io.Writer, stderr io.Writer, globalOpt if result.VerificationFailure != "" { presenter.Warn(buildVerificationWarning(result.VerificationFailure)) } + for _, warning := range result.Warnings { + presenter.Warn(warning) + } summary, err := summarizeProviderBuild(result) if err != nil { _ = presenter.Failure("reploy build error: summarize completed build: " + err.Error()) @@ -327,6 +330,7 @@ func interactiveBuildRunner( return overrideui.ValidationResult{}, fmt.Errorf("summarize completed build: %w", err) } warnings := buildWarnings(childOutput.String(), true) + warnings = append(warnings, build.Warnings...) if build.VerificationFailure != "" { warnings = append(warnings, buildVerificationWarning(build.VerificationFailure)) } diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 7fc9c8ca..398b8637 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -1695,6 +1695,9 @@ func runDockerInstall(args []string, stdout io.Writer, stderr io.Writer, globalO _ = presenter.Failure("reploy install error: " + installFailureDiagnostic(err, presenter.CapturedChildOutput())) return 1 } + for _, warning := range result.Warnings { + presenter.Warn(warning) + } successLines, successErr := dockerInstallSuccessLines(result.TargetDir, globalOptions.DockerTimeout) if successErr != nil { presenter.Warn("could not render blueprint completion details: " + successErr.Error()) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 58cb8f98..da8ffdc2 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -503,7 +503,9 @@ func TestInteractiveBuildUsesOneBuildTransaction(t *testing.T) { fmt.Fprintln(input.RunOptions.Stderr, `- UndefinedVar: Usage of undefined variable '$MISSING'`) fmt.Fprintln(input.RunOptions.Stderr, `- SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "TOKEN")`) time.Sleep(10 * time.Millisecond) - return cliTestProviderBuildResult(t, stageDir, false), nil + result := cliTestProviderBuildResult(t, stageDir, false) + result.Warnings = []string{"runtime identity warning"} + return result, nil } code, stdout, stderr := runCLI("build", "--dir", stageDir, "--no-cache") @@ -528,6 +530,7 @@ func TestInteractiveBuildUsesOneBuildTransaction(t *testing.T) { warningPrinted < 0 || uiReleased > warningPrinted || strings.Count(stderr, "reploy warning: Docker check UndefinedVar:") != 1 || + !strings.Contains(stderr, "reploy warning: runtime identity warning") || strings.Contains(stderr, "SecretsUsedInArgOrEnv") || strings.Contains(stderr, `"TOKEN"`) { t.Fatalf("interactive build warning output = %q", stderr) @@ -836,7 +839,9 @@ func TestBuildCommandReportsExactReuse(t *testing.T) { stageDir := filepath.Join(t.TempDir(), "provider-stage") writeCLITestStagedState(t, stageDir, "demo") dockerProviderBuild = func(context.Context, dockerdeploy.ProviderBuildRunInputV1) (dockerdeploy.LockedProviderBuildExecutionResultV1, error) { - return cliTestProviderBuildResult(t, stageDir, true), nil + result := cliTestProviderBuildResult(t, stageDir, true) + result.Warnings = []string{"runtime identity warning"} + return result, nil } code, stdout, stderr := runCLI("build", "--dir", stageDir) @@ -849,6 +854,9 @@ func TestBuildCommandReportsExactReuse(t *testing.T) { if !strings.Contains(stderr, "building environment: done") { t.Fatalf("stderr missing successful completion:\n%s", stderr) } + if !strings.Contains(stderr, "reploy warning: runtime identity warning") { + t.Fatalf("stderr missing runtime identity warning:\n%s", stderr) + } } func TestBuildCommandReportsRuntimeConfigurationUpdate(t *testing.T) { @@ -3787,7 +3795,7 @@ func TestStagedInstallUsesReployProgressAndDeployedResult(t *testing.T) { fmt.Fprintln(options.Progress, "running before start hook: app config check") return dockerdeploy.ProviderInstallResultV1{ Environment: "demo", TargetDir: "/opt/demo", ControlScript: "demo", - Service: "demo", Started: true, + Service: "demo", Started: true, Warnings: []string{"runtime identity warning"}, }, nil } oldInstallSuccessLines := dockerInstallSuccessLines @@ -3804,6 +3812,9 @@ func TestStagedInstallUsesReployProgressAndDeployedResult(t *testing.T) { if !strings.Contains(stdout, "[DEPLOYED : demo] installed successfully") { t.Fatalf("stdout missing deployed result:\n%s", stdout) } + if !strings.Contains(stderr, "reploy warning: runtime identity warning") { + t.Fatalf("stderr missing runtime identity warning:\n%s", stderr) + } } func TestDockerInstallControlOptionsParse(t *testing.T) { diff --git a/internal/deploy/build_lock_test.go b/internal/deploy/build_lock_test.go index b8ced3bf..89f0d593 100644 --- a/internal/deploy/build_lock_test.go +++ b/internal/deploy/build_lock_test.go @@ -30,7 +30,10 @@ func validBuildLock(t *testing.T) BuildLockV1 { verifier := ApplicationStartupVerifierContractV1() verifier.Artifact = buildLockTestDigest("6") verifier.Size = "123" - transaction, err := ApplicationRuntimeLayerTransactionDigestV1(verifier, upstream, platform) + account := ApplicationLocalAccountV1{ + Schema: ApplicationLocalAccountSchemaV1, Name: "reploy", UID: "1000", GID: "1000", Home: "/mnt/reploy-home", + } + transaction, err := ApplicationRuntimeLayerTransactionDigestV1(verifier, account, upstream, platform) if err != nil { t.Fatal(err) } @@ -43,7 +46,7 @@ func validBuildLock(t *testing.T) BuildLockV1 { Graph: ProviderGraphLockV1{Nodes: []providers.NodeID{"base"}, Edges: []providers.ProviderEdgeV1{}}, Nodes: []NodeLockV1{}, Catalog: []providers.RealizedOutput{}, RuntimePolicy: validRuntimePolicy(), RuntimeLayer: ApplicationRuntimeLayerV1{ - Schema: ApplicationRuntimeLayerSchemaV1, Verifier: verifier, TransactionDigest: transaction, + Schema: ApplicationRuntimeLayerSchemaV1, Verifier: verifier, Account: account, TransactionDigest: transaction, Upstream: upstream, Result: result, }, ValidationRecord: providerstore.StoreObjectRef{Kind: providerstore.ValidationRecordKind, Digest: buildLockTestDigest("4")}, @@ -98,7 +101,7 @@ func addValidAPTNode(t *testing.T, lock *BuildLockV1) { Outputs: []providers.RealizedOutput{}, }} lock.RuntimeLayer.Upstream = result - lock.RuntimeLayer.TransactionDigest, err = ApplicationRuntimeLayerTransactionDigestV1(lock.RuntimeLayer.Verifier, result, lock.Platform) + lock.RuntimeLayer.TransactionDigest, err = ApplicationRuntimeLayerTransactionDigestV1(lock.RuntimeLayer.Verifier, lock.RuntimeLayer.Account, result, lock.Platform) if err != nil { t.Fatal(err) } @@ -197,6 +200,8 @@ func TestBuildLockV1RejectsInvalidNestedIdentity(t *testing.T) { {name: "nil catalog", mutate: func(value *BuildLockV1) { value.Catalog = nil }, want: "catalog"}, {name: "runtime policy", mutate: func(value *BuildLockV1) { value.RuntimePolicy.Schema = "bad" }, want: "runtime policy"}, {name: "runtime verifier contract", mutate: func(value *BuildLockV1) { value.RuntimeLayer.Verifier.Path = "/bin/true" }, want: "startup verifier"}, + {name: "runtime local account", mutate: func(value *BuildLockV1) { value.RuntimeLayer.Account.Name = "root" }, want: "local non-root account"}, + {name: "runtime local root group", mutate: func(value *BuildLockV1) { value.RuntimeLayer.Account.GID = "0" }, want: "must not use GID 0"}, {name: "runtime layer transaction", mutate: func(value *BuildLockV1) { value.RuntimeLayer.TransactionDigest = buildLockTestDigest("f") }, want: "transaction digest"}, {name: "runtime layer upstream", mutate: func(value *BuildLockV1) { value.RuntimeLayer.Upstream.ConfigDigest = buildLockTestDigest("f") }, want: "final graph prefix"}, {name: "validation kind", mutate: func(value *BuildLockV1) { value.ValidationRecord.Kind = providerstore.BlobKind }, want: "validation-record"}, diff --git a/internal/deploy/deployment_state_v1.go b/internal/deploy/deployment_state_v1.go index e1624435..9ce7d448 100644 --- a/internal/deploy/deployment_state_v1.go +++ b/internal/deploy/deployment_state_v1.go @@ -303,9 +303,8 @@ func validateInstalledDestinationGeneration(source EnvironmentGenerationState, d if destination.Reference == source.Reference { return fmt.Errorf("installed destination generation requires a new destination-local reference") } - destination.Reference = source.Reference - if !reflect.DeepEqual(destination, source) { - return fmt.Errorf("installed destination generation must match the source build except for its reference") + if destination.Platform != source.Platform || destination.RuntimePolicyDigest != source.RuntimePolicyDigest { + return fmt.Errorf("installed destination generation must preserve the source platform and runtime policy") } return nil } diff --git a/internal/deploy/deployment_state_v1_test.go b/internal/deploy/deployment_state_v1_test.go index 88bbe118..3bc9e7dc 100644 --- a/internal/deploy/deployment_state_v1_test.go +++ b/internal/deploy/deployment_state_v1_test.go @@ -275,7 +275,7 @@ func TestOperationLockInstalledCommitRejectsDeploymentLocalSourceState(t *testin } } -func TestOperationLockInstalledCommitRejectsChangedDestinationGeneration(t *testing.T) { +func TestOperationLockInstalledCommitAllowsIdentityAdaptedDestinationGeneration(t *testing.T) { sourceDir := t.TempDir() source := readOverlayTestState(t, writeOverlayTestState(t, sourceDir)) destinationDir := t.TempDir() @@ -288,12 +288,22 @@ func TestOperationLockInstalledCommitRejectsChangedDestinationGeneration(t *test destinationGeneration.Reference = "reploy/env/overlay-test:g-destination" destinationGeneration.ImageDigest = canonical.Digest("sha256:" + strings.Repeat("b", 64)) - _, _, err = destinationLock.CommitInstalledStateV1(nil, source, destinationGeneration, installationStateV1Fixture(destinationDir)) - if err == nil || !strings.Contains(err.Error(), "except for its reference") { - t.Fatalf("changed destination generation error = %v", err) + installed, changed, err := destinationLock.CommitInstalledStateV1(nil, source, destinationGeneration, installationStateV1Fixture(destinationDir)) + if err != nil || !changed || installed.Current == nil || installed.Current.ImageDigest != destinationGeneration.ImageDigest { + t.Fatalf("identity-adapted destination = %#v, changed=%v, error=%v", installed.Current, changed, err) } - if _, found, err := destinationLock.ReadStateV1(); err != nil || found { - t.Fatalf("destination state after changed generation found=%v error=%v", found, err) + + otherDir := t.TempDir() + otherLock, err := AcquireOperationLock(t.Context(), otherDir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = otherLock.Unlock() }) + destinationGeneration.Platform.Architecture = "arm64" + destinationGeneration.Platform.Canonical = "linux/arm64" + _, _, err = otherLock.CommitInstalledStateV1(nil, source, destinationGeneration, installationStateV1Fixture(otherDir)) + if err == nil || !strings.Contains(err.Error(), "preserve the source platform") { + t.Fatalf("changed destination platform error = %v", err) } } diff --git a/internal/deploy/runtime_verifier.go b/internal/deploy/runtime_verifier.go index 422bf78a..3e25b532 100644 --- a/internal/deploy/runtime_verifier.go +++ b/internal/deploy/runtime_verifier.go @@ -14,6 +14,7 @@ const ( ApplicationStartupVerifierRecipeV1 = "linux-proc-status-verify-exec-v1" ApplicationStartupVerifierPathV1 = "/reploy-probe" ApplicationRuntimeLayerSchemaV1 = "application-runtime-layer-v1" + ApplicationLocalAccountSchemaV1 = "application-local-account-v1" ) type ApplicationStartupVerifierV1 struct { @@ -27,11 +28,56 @@ type ApplicationStartupVerifierV1 struct { type ApplicationRuntimeLayerV1 struct { Schema string `json:"schema"` Verifier ApplicationStartupVerifierV1 `json:"verifier"` + Account ApplicationLocalAccountV1 `json:"account"` TransactionDigest canonical.Digest `json:"transaction_digest"` Upstream providers.RealizedImageV1 `json:"upstream"` Result providers.RealizedImageV1 `json:"result"` } +type ApplicationLocalAccountV1 struct { + Schema string `json:"schema"` + Name string `json:"name"` + UID string `json:"uid"` + GID string `json:"gid"` + Home string `json:"home"` +} + +func ValidateApplicationLocalAccountV1(account ApplicationLocalAccountV1) error { + if account.Schema != ApplicationLocalAccountSchemaV1 { + return fmt.Errorf("application local account schema must be %q", ApplicationLocalAccountSchemaV1) + } + if err := blueprint.ValidateRuntimeUserName(account.Name); err != nil { + return fmt.Errorf("application local account name %w", err) + } + if account.Home == "" { + return fmt.Errorf("application local account home is required") + } + uid, err := strconv.ParseUint(account.UID, 10, 32) + if err != nil || strconv.FormatUint(uid, 10) != account.UID { + return fmt.Errorf("application local account UID must be a canonical non-negative integer") + } + gid, err := strconv.ParseUint(account.GID, 10, 32) + if err != nil || strconv.FormatUint(gid, 10) != account.GID { + return fmt.Errorf("application local account GID must be a canonical non-negative integer") + } + if uid == 0 && account.Name != "root" { + return fmt.Errorf("application local root account must be named root") + } + if uid != 0 && account.Name == "root" { + return fmt.Errorf("application local non-root account must not be named root") + } + if uid == 0 && gid != 0 { + return fmt.Errorf("application local root account must use GID 0") + } + if uid != 0 && gid == 0 { + return fmt.Errorf("application local non-root account must not use GID 0") + } + if account.Home != "/mnt/reploy-home" { + return fmt.Errorf("application local account home must be /mnt/reploy-home") + } + return nil +} + func ApplicationStartupVerifierContractV1() ApplicationStartupVerifierV1 { return ApplicationStartupVerifierV1{ Schema: ApplicationStartupVerifierSchemaV1, RecipeVersion: ApplicationStartupVerifierRecipeV1, @@ -62,6 +108,7 @@ func ValidateApplicationStartupVerifierV1(verifier ApplicationStartupVerifierV1, func ApplicationRuntimeLayerTransactionDigestV1( verifier ApplicationStartupVerifierV1, + account ApplicationLocalAccountV1, upstream providers.RealizedImageV1, platform blueprint.Platform, ) (canonical.Digest, error) { @@ -71,21 +118,25 @@ func ApplicationRuntimeLayerTransactionDigestV1( if err := upstream.Validate(); err != nil { return "", fmt.Errorf("application runtime layer upstream: %w", err) } + if err := ValidateApplicationLocalAccountV1(account); err != nil { + return "", err + } if err := platform.Validate(); err != nil { return "", fmt.Errorf("application runtime layer platform: %w", err) } return canonical.Sum("application-runtime-layer", ApplicationRuntimeLayerSchemaV1, struct { Verifier ApplicationStartupVerifierV1 `json:"verifier"` + Account ApplicationLocalAccountV1 `json:"account"` Upstream providers.RealizedImageV1 `json:"upstream"` Platform blueprint.Platform `json:"platform"` - }{Verifier: verifier, Upstream: upstream, Platform: platform}) + }{Verifier: verifier, Account: account, Upstream: upstream, Platform: platform}) } func ValidateApplicationRuntimeLayerV1(layer ApplicationRuntimeLayerV1, platform blueprint.Platform) error { if layer.Schema != ApplicationRuntimeLayerSchemaV1 { return fmt.Errorf("application runtime layer schema must be %q", ApplicationRuntimeLayerSchemaV1) } - want, err := ApplicationRuntimeLayerTransactionDigestV1(layer.Verifier, layer.Upstream, platform) + want, err := ApplicationRuntimeLayerTransactionDigestV1(layer.Verifier, layer.Account, layer.Upstream, platform) if err != nil { return err } @@ -96,7 +147,7 @@ func ValidateApplicationRuntimeLayerV1(layer ApplicationRuntimeLayerV1, platform return fmt.Errorf("application runtime layer result: %w", err) } if layer.Result.RootFSSubject == layer.Upstream.RootFSSubject { - return fmt.Errorf("application runtime layer result must add the verifier filesystem layer") + return fmt.Errorf("application runtime layer result must add the local-account and verifier filesystem content") } return nil } diff --git a/internal/dockerdeploy/application_runtime_layer.go b/internal/dockerdeploy/application_runtime_layer.go index 4f90e0d4..aba61615 100644 --- a/internal/dockerdeploy/application_runtime_layer.go +++ b/internal/dockerdeploy/application_runtime_layer.go @@ -3,6 +3,7 @@ package dockerdeploy import ( "bytes" "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -20,6 +21,7 @@ import ( type ApplicationRuntimeLayerBuildRequest struct { Source InspectedImageCandidate Verifier deploy.ApplicationStartupVerifierV1 + Account deploy.ApplicationLocalAccountV1 Platform blueprint.Platform } @@ -69,13 +71,30 @@ func ApplicationRuntimeLayerDockerfile(request ApplicationRuntimeLayerBuildReque return nil, fmt.Errorf("render application runtime source user: %w", err) } } + installAccount, err := json.Marshal([]string{ + request.Verifier.Path, "install-local-account", + request.Account.Name, request.Account.UID, request.Account.GID, request.Account.Home, + }) + if err != nil { + return nil, fmt.Errorf("render application local account command: %w", err) + } var output bytes.Buffer fmt.Fprintf(&output, "# syntax=%s\n", MaterializationDockerfileSyntax) output.WriteString("ARG REPLOY_BASE_IMAGE=scratch\n") + output.WriteString("FROM ${REPLOY_BASE_IMAGE} AS reploy-runtime-account\n") + output.WriteString("USER 0:0\n") + fmt.Fprintf( + &output, + "RUN --mount=type=bind,source=%s,target=/reploy-build-probe,readonly [\"/reploy-build-probe\", \"install-runtime-verifier\", %s]\n", + probearchive.ExtractedFileName, + strconv.Quote(request.Verifier.Path), + ) + fmt.Fprintf(&output, "RUN %s\n", installAccount) output.WriteString("FROM ${REPLOY_BASE_IMAGE}\n") if originalUser != "" { output.WriteString("USER 0:0\n") } + output.WriteString("COPY --from=reploy-runtime-account /etc/passwd /etc/group /etc/\n") fmt.Fprintf( &output, "RUN --mount=type=bind,source=%s,target=/reploy-build-probe,readonly [\"/reploy-build-probe\", \"install-runtime-verifier\", %s]\n", @@ -218,8 +237,8 @@ func ValidateInspectedApplicationRuntimeLayerCandidate( } wantPrefix := request.Source.Descriptor.RootFSDiffIDs got := candidate.Descriptor.RootFSDiffIDs - if len(got) != len(wantPrefix)+1 || !reflect.DeepEqual(got[:len(wantPrefix)], wantPrefix) { - return fmt.Errorf("application runtime layer must add exactly one filesystem layer to its source") + if len(got) != len(wantPrefix)+2 || !reflect.DeepEqual(got[:len(wantPrefix)], wantPrefix) { + return fmt.Errorf("application runtime layer must add exactly two filesystem layers to its source") } return nil } @@ -231,6 +250,9 @@ func validateApplicationRuntimeLayerBuildRequest(request ApplicationRuntimeLayer if err := deploy.ValidateApplicationStartupVerifierV1(request.Verifier, true); err != nil { return err } + if err := deploy.ValidateApplicationLocalAccountV1(request.Account); err != nil { + return err + } if err := request.Platform.Validate(); err != nil { return fmt.Errorf("application runtime layer platform: %w", err) } diff --git a/internal/dockerdeploy/application_runtime_layer_test.go b/internal/dockerdeploy/application_runtime_layer_test.go index 16c886ca..a09908fe 100644 --- a/internal/dockerdeploy/application_runtime_layer_test.go +++ b/internal/dockerdeploy/application_runtime_layer_test.go @@ -16,14 +16,19 @@ func applicationRuntimeLayerTestRequest(t *testing.T) ApplicationRuntimeLayerBui verifier.Artifact = rendererDigest("a") verifier.Size = "123" return ApplicationRuntimeLayerBuildRequest{ - Source: finalization.Source, Verifier: verifier, Platform: finalization.Platform, + Source: finalization.Source, Verifier: verifier, + Account: testApplicationLocalAccountV1(), + Platform: finalization.Platform, } } func applicationRuntimeLayerTestCandidate(t *testing.T, request ApplicationRuntimeLayerBuildRequest) InspectedImageCandidate { t.Helper() candidate := request.Source - candidate.Descriptor.RootFSDiffIDs = append(append([]canonical.Digest{}, candidate.Descriptor.RootFSDiffIDs...), rendererDigest("b")) + candidate.Descriptor.RootFSDiffIDs = append( + append([]canonical.Digest{}, candidate.Descriptor.RootFSDiffIDs...), + rendererDigest("b"), rendererDigest("d"), + ) candidate.Descriptor.AuthorReference = string(rendererDigest("c")) candidate.Descriptor.ImmutableReference = string(rendererDigest("c")) candidate.Descriptor.ConfigDigest = rendererDigest("c") @@ -46,14 +51,18 @@ func TestApplicationRuntimeLayerDockerfileAddsOnlyFixedVerifier(t *testing.T) { dockerfile := string(content) for _, want := range []string{ "# syntax=" + MaterializationDockerfileSyntax, + "FROM ${REPLOY_BASE_IMAGE} AS reploy-runtime-account", + "USER 0:0", + `RUN ["/reploy-probe","install-local-account","reploy","1000","1000","/mnt/reploy-home"]`, "FROM ${REPLOY_BASE_IMAGE}", `RUN --mount=type=bind,source=reploy-probe,target=/reploy-build-probe,readonly ["/reploy-build-probe", "install-runtime-verifier", "/reploy-probe"]`, + "COPY --from=reploy-runtime-account /etc/passwd /etc/group /etc/", } { if !strings.Contains(dockerfile, want) { t.Fatalf("Dockerfile missing %q:\n%s", want, dockerfile) } } - for _, forbidden := range []string{"COPY ", "ADD ", "USER ", "ENTRYPOINT ", "CMD "} { + for _, forbidden := range []string{"ADD ", "ENTRYPOINT ", "CMD "} { if strings.Contains(dockerfile, forbidden) { t.Fatalf("Dockerfile contains %q:\n%s", forbidden, dockerfile) } @@ -68,7 +77,7 @@ func TestApplicationRuntimeLayerDockerfileRestoresInheritedUser(t *testing.T) { t.Fatal(err) } dockerfile := string(content) - if !strings.Contains(dockerfile, "USER 0:0\nRUN ") || !strings.HasSuffix(dockerfile, "USER \"12345:23456\"\n") { + if !strings.Contains(dockerfile, "USER 0:0\nCOPY --from=reploy-runtime-account") || !strings.HasSuffix(dockerfile, "USER \"12345:23456\"\n") { t.Fatalf("Dockerfile does not switch to root and restore the inherited user:\n%s", dockerfile) } } @@ -90,7 +99,7 @@ func TestValidateInspectedApplicationRuntimeLayerCandidatePreservesConfigAndAdds {name: "no layer", mutate: func(value *InspectedImageCandidate) { value.Descriptor.RootFSDiffIDs = append([]canonical.Digest{}, request.Source.Descriptor.RootFSDiffIDs...) value.Image.RootFSSubject = request.Source.Image.RootFSSubject - }, want: "exactly one"}, + }, want: "exactly two"}, {name: "changed prefix", mutate: func(value *InspectedImageCandidate) { value.Descriptor.RootFSDiffIDs[0] = rendererDigest("d") rootFS, err := deploy.RootFSSubject(value.Descriptor.RootFSDiffIDs) @@ -98,7 +107,7 @@ func TestValidateInspectedApplicationRuntimeLayerCandidatePreservesConfigAndAdds t.Fatal(err) } value.Image.RootFSSubject = rootFS - }, want: "exactly one"}, + }, want: "exactly two"}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/internal/dockerdeploy/application_sandbox_plan.go b/internal/dockerdeploy/application_sandbox_plan.go index 8a48c727..c9ec2f15 100644 --- a/internal/dockerdeploy/application_sandbox_plan.go +++ b/internal/dockerdeploy/application_sandbox_plan.go @@ -33,6 +33,9 @@ type ApplicationSandboxPlanV1 struct { } func newApplicationSandboxPlanV1(runtimeUser RuntimeUserPlan) ApplicationSandboxPlanV1 { + if runtimeUser.LocalUser == "" { + runtimeUser.LocalUser = runtimeLocalUserNameV1("", runtimeUser.UID) + } return ApplicationSandboxPlanV1{ RuntimeUser: runtimeUser, ReadOnlyRoot: true, @@ -56,6 +59,15 @@ func ValidateApplicationSandboxPlanV1(plan ApplicationSandboxPlanV1) error { if plan.RuntimeUser.DockerUser != wantUser { return fmt.Errorf("application sandbox Docker user must match its numeric UID and GID") } + if plan.RuntimeUser.LocalUser == "" { + return fmt.Errorf("application sandbox requires a container-local user name") + } + if plan.RuntimeUser.UID == 0 && plan.RuntimeUser.LocalUser != "root" { + return fmt.Errorf("root application sandbox identity must use the local user name root") + } + if plan.RuntimeUser.UID != 0 && plan.RuntimeUser.LocalUser == "root" { + return fmt.Errorf("non-root application sandbox identity must not use the local user name root") + } wantGroups, err := normalizeSupplementaryGIDsV1(plan.RuntimeUser.GID, plan.RuntimeUser.SupplementaryGIDs) if err != nil { return fmt.Errorf("application sandbox supplementary groups: %w", err) @@ -98,6 +110,23 @@ func ValidateApplicationSandboxPlanV1(plan ApplicationSandboxPlanV1) error { return nil } +func applicationLocalAccountV1(plan ApplicationSandboxPlanV1) (deploy.ApplicationLocalAccountV1, error) { + if err := ValidateApplicationSandboxPlanV1(plan); err != nil { + return deploy.ApplicationLocalAccountV1{}, err + } + account := deploy.ApplicationLocalAccountV1{ + Schema: deploy.ApplicationLocalAccountSchemaV1, + Name: plan.RuntimeUser.LocalUser, + UID: strconv.Itoa(plan.RuntimeUser.UID), + GID: strconv.Itoa(plan.RuntimeUser.GID), + Home: plan.TemporaryHome, + } + if err := deploy.ValidateApplicationLocalAccountV1(account); err != nil { + return deploy.ApplicationLocalAccountV1{}, err + } + return account, nil +} + func normalizeSupplementaryGIDsV1(primary int, values []int) ([]int, error) { result := append([]int(nil), values...) for _, gid := range result { diff --git a/internal/dockerdeploy/application_startup_verifier_integration_test.go b/internal/dockerdeploy/application_startup_verifier_integration_test.go index 9fd93f38..e6e28087 100644 --- a/internal/dockerdeploy/application_startup_verifier_integration_test.go +++ b/internal/dockerdeploy/application_startup_verifier_integration_test.go @@ -36,7 +36,7 @@ func TestApplicationStartupVerifierDockerIntegration(t *testing.T) { UID: 12345, GID: 23456, DockerUser: "12345:23456", }), Workload: &WorkloadExecutionPlan{Argv: []string{ - "/bin/sh", "-eu", "-c", `test "$1" = 'literal $(not-shell)'; printf 'persistent-verifier-pass\n'`, "reploy-test", "literal $(not-shell)", + "/bin/sh", "-eu", "-c", `test "$(id -un)" = reploy; test "$1" = 'literal $(not-shell)'; printf 'persistent-verifier-pass\n'`, "reploy-test", "literal $(not-shell)", }}, } @@ -64,7 +64,7 @@ func TestApplicationStartupVerifierDockerIntegration(t *testing.T) { transientPlan := plan transientPlan.ContainerName = uniqueDockerIntegrationName("reploy-verifier-transient") command := ResolvedEnvironmentCommand{Argv: []string{ - "/bin/sh", "-eu", "-c", `test "$1" = 'literal $(not-shell)'; printf 'transient-verifier-pass\n'`, "reploy-test", "literal $(not-shell)", + "/bin/sh", "-eu", "-c", `test "$(id -un)" = reploy; test "$1" = 'literal $(not-shell)'; printf 'transient-verifier-pass\n'`, "reploy-test", "literal $(not-shell)", }} spec, err := TransientCommandSpec(transientPlan, command, nil, false, false) if err != nil { @@ -250,8 +250,12 @@ func buildApplicationStartupVerifierIntegrationImageFromBase( if err != nil { t.Fatal(err) } + account := deploy.ApplicationLocalAccountV1{ + Schema: deploy.ApplicationLocalAccountSchemaV1, Name: "reploy", + UID: "12345", GID: "23456", Home: environmentTemporaryHome, + } built, err := BuildApplicationRuntimeLayerCandidate(store, ApplicationRuntimeLayerBuildRequest{ - Source: source, Verifier: verifier, Platform: platform, + Source: source, Verifier: verifier, Account: account, Platform: platform, }, RunOptions{Context: ctx}) if err != nil { t.Fatal(err) @@ -262,7 +266,7 @@ func buildApplicationStartupVerifierIntegrationImageFromBase( } }) if _, err := InspectApplicationRuntimeLayerCandidate(ctx, built, ApplicationRuntimeLayerBuildRequest{ - Source: source, Verifier: verifier, Platform: platform, + Source: source, Verifier: verifier, Account: account, Platform: platform, }); err != nil { t.Fatal(err) } diff --git a/internal/dockerdeploy/build_publication_test.go b/internal/dockerdeploy/build_publication_test.go index 7a930ba8..00fcacb7 100644 --- a/internal/dockerdeploy/build_publication_test.go +++ b/internal/dockerdeploy/build_publication_test.go @@ -398,7 +398,7 @@ func publicationLockFixture(t *testing.T, dir string, imageChar string, configCh t.Fatal(err) } upstream := providers.RealizedImageV1{Digest: base.ConfigDigest, ConfigDigest: base.ConfigDigest, RootFSSubject: rootFSSubject} - runtimeDiffIDs := append(append([]canonical.Digest{}, base.RootFSDiffIDs...), rendererDigest("e")) + runtimeDiffIDs := append(append([]canonical.Digest{}, base.RootFSDiffIDs...), rendererDigest("e"), rendererDigest("f")) runtimeRootFS, err := deploy.RootFSSubject(runtimeDiffIDs) if err != nil { t.Fatal(err) diff --git a/internal/dockerdeploy/current_build_reuse.go b/internal/dockerdeploy/current_build_reuse.go index 39362af7..cf9f21a6 100644 --- a/internal/dockerdeploy/current_build_reuse.go +++ b/internal/dockerdeploy/current_build_reuse.go @@ -69,6 +69,10 @@ func CurrentBuildMatches(current CurrentBuild, input CurrentBuildReuseInput) (bo if err := deploy.ValidateApplicationStartupVerifierV1(input.StartupVerifier, true); err != nil { return false, fmt.Errorf("current build reuse startup verifier: %w", err) } + account, err := applicationLocalAccountV1(input.DockerPlan.Sandbox) + if err != nil { + return false, fmt.Errorf("current build reuse local account: %w", err) + } baseReference, err := resolvedRequestBaseReference(input.ResolvedRequest) if err != nil { return false, err @@ -102,7 +106,8 @@ func CurrentBuildMatches(current CurrentBuild, input CurrentBuildReuseInput) (bo current.Lock.Platform != input.ResolvedRequest.Platform || !reflect.DeepEqual(current.Lock.Base, input.Base) || lockedPolicyDigest != policyDigest || - !reflect.DeepEqual(current.Lock.RuntimeLayer.Verifier, input.StartupVerifier) { + !reflect.DeepEqual(current.Lock.RuntimeLayer.Verifier, input.StartupVerifier) || + !reflect.DeepEqual(current.Lock.RuntimeLayer.Account, account) { return false, nil } return true, nil diff --git a/internal/dockerdeploy/current_build_reuse_test.go b/internal/dockerdeploy/current_build_reuse_test.go index 28e39d62..7bbab1f5 100644 --- a/internal/dockerdeploy/current_build_reuse_test.go +++ b/internal/dockerdeploy/current_build_reuse_test.go @@ -90,6 +90,9 @@ func TestCurrentBuildMatchesInvalidatesEverySemanticBoundary(t *testing.T) { {name: "startup verifier artifact", mutate: func(_ *CurrentBuild, input *CurrentBuildReuseInput) { input.StartupVerifier.Artifact = rendererDigest("a") }}, + {name: "container local account", mutate: func(_ *CurrentBuild, input *CurrentBuildReuseInput) { + input.DockerPlan.Sandbox = testApplicationSandboxPlanV1(2000, 2000) + }}, } { t.Run(test.name, func(t *testing.T) { current, input := currentBuildReuseFixture(t) diff --git a/internal/dockerdeploy/current_build_verify.go b/internal/dockerdeploy/current_build_verify.go index bd8aae41..ae0e52a6 100644 --- a/internal/dockerdeploy/current_build_verify.go +++ b/internal/dockerdeploy/current_build_verify.go @@ -326,7 +326,7 @@ func verifyLockedImagesV1( return 0, fmt.Errorf("cached application runtime layer image no longer matches its locked identity") } if err := ValidateInspectedApplicationRuntimeLayerCandidate(ApplicationRuntimeLayerBuildRequest{ - Source: source, Verifier: lock.RuntimeLayer.Verifier, Platform: lock.Platform, + Source: source, Verifier: lock.RuntimeLayer.Verifier, Account: lock.RuntimeLayer.Account, Platform: lock.Platform, }, runtimeImage); err != nil { return 0, fmt.Errorf("verify cached application runtime layer: %w", err) } diff --git a/internal/dockerdeploy/current_build_verify_test.go b/internal/dockerdeploy/current_build_verify_test.go index 0ef77215..9ea45b18 100644 --- a/internal/dockerdeploy/current_build_verify_test.go +++ b/internal/dockerdeploy/current_build_verify_test.go @@ -257,7 +257,7 @@ func TestVerifyLockedImagesV1RerunsCumulativeLayerValidation(t *testing.T) { runtimeDescriptor.AuthorReference = string(rendererDigest("c")) runtimeDescriptor.ImmutableReference = string(rendererDigest("c")) runtimeDescriptor.ConfigDigest = rendererDigest("c") - runtimeDescriptor.RootFSDiffIDs = append(append([]canonical.Digest{}, layerDescriptor.RootFSDiffIDs...), rendererDigest("d")) + runtimeDescriptor.RootFSDiffIDs = append(append([]canonical.Digest{}, layerDescriptor.RootFSDiffIDs...), rendererDigest("d"), rendererDigest("f")) runtimeImage, err := realizedImageFromDescriptor(runtimeDescriptor) if err != nil { t.Fatal(err) @@ -478,7 +478,7 @@ func baseOnlyCurrentBuildVerificationFixtureV1(t *testing.T) currentBuildVerific Image: baseImage, } runtimeDescriptor := lock.Base - runtimeDescriptor.RootFSDiffIDs = append(append([]canonical.Digest{}, lock.Base.RootFSDiffIDs...), rendererDigest("e")) + runtimeDescriptor.RootFSDiffIDs = append(append([]canonical.Digest{}, lock.Base.RootFSDiffIDs...), rendererDigest("e"), rendererDigest("f")) runtimeDescriptor.AuthorReference = string(lock.RuntimeLayer.Result.ConfigDigest) runtimeDescriptor.ImmutableReference = string(lock.RuntimeLayer.Result.ConfigDigest) runtimeDescriptor.ConfigDigest = lock.RuntimeLayer.Result.ConfigDigest diff --git a/internal/dockerdeploy/execution_plan.go b/internal/dockerdeploy/execution_plan.go index 357f00bd..78d0c696 100644 --- a/internal/dockerdeploy/execution_plan.go +++ b/internal/dockerdeploy/execution_plan.go @@ -79,6 +79,7 @@ type MountExecutionPlan struct { type RuntimeUserPlan struct { User string Group string + LocalUser string UID int GID int SupplementaryGIDs []int @@ -325,15 +326,22 @@ func planRuntimeUser(document blueprint.Document, context DockerPlanContext) (Ru User: strconv.Itoa(context.UID), Group: strconv.Itoa(context.GID), UID: context.UID, GID: context.GID, SupplementaryGIDs: supplementaryGIDs, DockerUser: strconv.Itoa(context.UID) + ":" + strconv.Itoa(context.GID), + LocalUser: runtimeLocalUserNameV1(document.Environment.Runtime.User, context.UID), } if context.Phase == blueprint.PhaseInstalled { plan.Warnings = append(plan.Warnings, - 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", + fmt.Sprintf("current-user install overrides the image user with local account %q (UID/GID %d:%d)", plan.LocalUser, context.UID, context.GID), ) + if context.UID == 0 { + plan.Warnings = append(plan.Warnings, rootRuntimeIdentityWarningV1) + } else { + plan.Warnings = append(plan.Warnings, "the image must tolerate an arbitrary non-root identity and may write persistently only to declared writable paths") + } if document.Environment.Install.System.Account.User != "" || document.Environment.Install.System.Account.Group != "" { plan.Warnings = append(plan.Warnings, "environment.install.system.account does not apply to current-user scope") } + } else if context.UID == 0 { + plan.Warnings = append(plan.Warnings, rootRuntimeIdentityWarningV1) } return plan, nil } @@ -344,11 +352,28 @@ func planRuntimeUser(document blueprint.Document, context DockerPlanContext) (Ru if context.SystemUser == "" || context.SystemGroup == "" || context.UID < 0 || context.GID < 0 { return RuntimeUserPlan{}, fmt.Errorf("system Docker plan requires resolved service account and numeric UID/GID") } - return RuntimeUserPlan{ + plan := RuntimeUserPlan{ User: context.SystemUser, Group: context.SystemGroup, UID: context.UID, GID: context.GID, SupplementaryGIDs: supplementaryGIDs, DockerUser: strconv.Itoa(context.UID) + ":" + strconv.Itoa(context.GID), - }, nil + LocalUser: runtimeLocalUserNameV1(document.Environment.Runtime.User, context.UID), + } + if context.UID == 0 { + plan.Warnings = append(plan.Warnings, rootRuntimeIdentityWarningV1) + } + return plan, nil } return RuntimeUserPlan{}, fmt.Errorf("cannot resolve Docker runtime user") } + +const rootRuntimeIdentityWarningV1 = "the application will run as root inside its container; root can bypass application-level file permissions, while Docker access, Linux capabilities, host filesystem access, and network access remain limited by the effective Reploy sandbox policy" + +func runtimeLocalUserNameV1(configured string, uid int) string { + if uid == 0 { + return "root" + } + if configured == "" { + return blueprint.DefaultRuntimeUser + } + return configured +} diff --git a/internal/dockerdeploy/execution_plan_test.go b/internal/dockerdeploy/execution_plan_test.go index c624a4c6..43d942e9 100644 --- a/internal/dockerdeploy/execution_plan_test.go +++ b/internal/dockerdeploy/execution_plan_test.go @@ -3,6 +3,7 @@ package dockerdeploy import ( "os" "path/filepath" + "strings" "testing" "github.com/omry/reploy/internal/blueprint" @@ -132,14 +133,20 @@ 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{Account: blueprint.SystemAccount{User: "service", Group: "service"}}}}} + document := blueprint.Document{Environment: blueprint.Environment{ + Runtime: blueprint.EnvironmentRuntime{User: "omegaflow"}, + 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) } - if plan.DockerUser != "501:20" || len(plan.Warnings) != 3 { + if plan.DockerUser != "501:20" || plan.LocalUser != "omegaflow" || len(plan.Warnings) != 3 { t.Fatalf("user plan = %#v", plan) } + if !strings.Contains(plan.Warnings[0], `local account "omegaflow"`) || !strings.Contains(plan.Warnings[0], "501:20") { + t.Fatalf("user identity warning = %q", plan.Warnings[0]) + } scope = blueprint.InstallScopeSystem plan, err = planRuntimeUser(document, DockerPlanContext{ Phase: blueprint.PhaseInstalled, Scope: &scope, Host: blueprint.HostLinux, @@ -148,9 +155,33 @@ func TestPlanRuntimeUserScopePolicy(t *testing.T) { if err != nil { t.Fatal(err) } - if plan.DockerUser != "991:991" || len(plan.Warnings) != 0 { + if plan.DockerUser != "991:991" || plan.LocalUser != "omegaflow" || len(plan.Warnings) != 0 { t.Fatalf("system plan = %#v", plan) } + root, err := planRuntimeUser(document, DockerPlanContext{ + Phase: blueprint.PhaseInstalled, Scope: &scope, Host: blueprint.HostLinux, + SystemUser: "root", SystemGroup: "root", UID: 0, GID: 0, + }) + if err != nil { + t.Fatal(err) + } + if root.LocalUser != "root" || root.DockerUser != "0:0" { + t.Fatalf("root plan = %#v", root) + } + if len(root.Warnings) != 1 || !strings.Contains(root.Warnings[0], "run as root inside its container") { + t.Fatalf("root warnings = %#v", root.Warnings) + } + scope = blueprint.InstallScopeUser + root, err = planRuntimeUser(document, DockerPlanContext{ + Phase: blueprint.PhaseInstalled, Scope: &scope, Host: blueprint.HostLinux, UID: 0, GID: 0, + }) + if err != nil { + t.Fatal(err) + } + if len(root.Warnings) != 3 || strings.Contains(strings.Join(root.Warnings, "\n"), "non-root identity") || + !strings.Contains(strings.Join(root.Warnings, "\n"), "run as root inside its container") { + t.Fatalf("root current-user warnings = %#v", root.Warnings) + } } func TestDockerPlanCrossPlatformUserPaths(t *testing.T) { diff --git a/internal/dockerdeploy/final_validation_pipeline.go b/internal/dockerdeploy/final_validation_pipeline.go index 73b36c1c..024ddbf5 100644 --- a/internal/dockerdeploy/final_validation_pipeline.go +++ b/internal/dockerdeploy/final_validation_pipeline.go @@ -33,10 +33,11 @@ func ValidateAndFinalizeBuild( validateProfileOwner providers.RequirementProfileOwnerValidator, runValidation FullImageValidationRunner, verifier deploy.ApplicationStartupVerifierV1, + account deploy.ApplicationLocalAccountV1, options RunOptions, ) (FinalizedBuildValidationResult, error) { return validateAndFinalizeBuild( - ctx, store, layers, final, validateProfileOwner, runValidation, verifier, options, + ctx, store, layers, final, validateProfileOwner, runValidation, verifier, account, options, BuildApplicationRuntimeLayerCandidate, InspectApplicationRuntimeLayerCandidate, RetainVerifiedApplicationRuntimeLayer, BuildFinalizedImageCandidate, InspectFinalizedImageCandidate, RemoveBuiltImageCandidate, @@ -51,6 +52,7 @@ func validateAndFinalizeBuild( validateProfileOwner providers.RequirementProfileOwnerValidator, runValidation FullImageValidationRunner, verifier deploy.ApplicationStartupVerifierV1, + account deploy.ApplicationLocalAccountV1, options RunOptions, buildRuntime applicationRuntimeLayerBuilder, inspectRuntime applicationRuntimeLayerInspector, @@ -63,7 +65,7 @@ func validateAndFinalizeBuild( return FinalizedBuildValidationResult{}, fmt.Errorf("final validation pipeline requires build and inspection backends") } runtimeRequest := ApplicationRuntimeLayerBuildRequest{ - Source: final.Image, Verifier: verifier, Platform: final.Image.Descriptor.Platform, + Source: final.Image, Verifier: verifier, Account: account, Platform: final.Image.Descriptor.Platform, } runtimeBuildCtx, endRuntimeBuild := buildprofile.Start(ctx, "Package application startup verifier") options.Context = runtimeBuildCtx @@ -98,7 +100,7 @@ func validateAndFinalizeBuild( if err != nil { return rejectRuntime(fmt.Errorf("retain application runtime layer: %w", err)) } - transaction, err := deploy.ApplicationRuntimeLayerTransactionDigestV1(verifier, final.Image.Image, final.Image.Descriptor.Platform) + transaction, err := deploy.ApplicationRuntimeLayerTransactionDigestV1(verifier, account, final.Image.Image, final.Image.Descriptor.Platform) if err != nil { return FinalizedBuildValidationResult{}, err } @@ -126,7 +128,7 @@ func validateAndFinalizeBuild( return FinalizedBuildValidationResult{ Validation: validation, RuntimeLayer: deploy.ApplicationRuntimeLayerV1{ - Schema: deploy.ApplicationRuntimeLayerSchemaV1, Verifier: verifier, + Schema: deploy.ApplicationRuntimeLayerSchemaV1, Verifier: verifier, Account: account, TransactionDigest: transaction, Upstream: final.Image.Image, Result: runtimeImage.Image, }, Image: image, Candidate: built, diff --git a/internal/dockerdeploy/final_validation_pipeline_test.go b/internal/dockerdeploy/final_validation_pipeline_test.go index 3a73dbfc..1c629a53 100644 --- a/internal/dockerdeploy/final_validation_pipeline_test.go +++ b/internal/dockerdeploy/final_validation_pipeline_test.go @@ -26,7 +26,7 @@ func TestValidateAndFinalizeBuildUsesPublishedFinalEvidence(t *testing.T) { func(context.Context, FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { validated = true return []providers.ValidationEvidence{}, []providers.ExecutableEvidence{}, nil - }, verifier, RunOptions{}, + }, verifier, testApplicationLocalAccountV1(), RunOptions{}, func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) { return runtimeCandidate, nil }, @@ -79,7 +79,7 @@ func TestValidateAndFinalizeBuildDoesNotBuildAfterValidationFailure(t *testing.T context.Background(), store, []FullImageValidationInput{}, final, acceptFullValidationProfile, func(context.Context, FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { return nil, nil, errors.New("validation failed") - }, verifier, RunOptions{}, + }, verifier, testApplicationLocalAccountV1(), RunOptions{}, func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) { return runtimeCandidate, nil }, @@ -122,7 +122,7 @@ func TestValidateAndFinalizeBuildRemovesRuntimeCandidateAfterInspectionFailure(t validated = true return nil, nil, nil }, - verifier, RunOptions{}, + verifier, testApplicationLocalAccountV1(), RunOptions{}, func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) { return runtimeCandidate, nil }, @@ -166,7 +166,7 @@ func TestValidateAndFinalizeBuildRemovesRuntimeCandidateAfterRetentionFailure(t func(context.Context, FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { return []providers.ValidationEvidence{}, []providers.ExecutableEvidence{}, nil }, - verifier, RunOptions{}, + verifier, testApplicationLocalAccountV1(), RunOptions{}, func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) { return runtimeCandidate, nil }, @@ -212,7 +212,7 @@ func TestValidateAndFinalizeBuildRemovesCandidateAfterInspectionFailure(t *testi func(context.Context, FullImageValidationInput) ([]providers.ValidationEvidence, []providers.ExecutableEvidence, error) { return []providers.ValidationEvidence{}, []providers.ExecutableEvidence{}, nil }, - verifier, RunOptions{}, + verifier, testApplicationLocalAccountV1(), RunOptions{}, func(providerstore.Store, ApplicationRuntimeLayerBuildRequest, RunOptions) (BuiltImageCandidate, error) { return runtimeCandidate, nil }, diff --git a/internal/dockerdeploy/install_runtime_identity.go b/internal/dockerdeploy/install_runtime_identity.go new file mode 100644 index 00000000..c7710354 --- /dev/null +++ b/internal/dockerdeploy/install_runtime_identity.go @@ -0,0 +1,128 @@ +package dockerdeploy + +import ( + "context" + "errors" + "fmt" + "reflect" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/providers" + "github.com/omry/reploy/internal/providers/registry" + "github.com/omry/reploy/internal/providerstore" +) + +type installedRuntimeIdentityBuildV1 struct { + Lock deploy.BuildLockV1 + Candidate BuiltImageCandidate + Adapted bool +} + +type installedRuntimeIdentityBackendV1 struct { + inspect func(context.Context, BuiltImageCandidate, blueprint.Platform) (InspectedImageCandidate, error) + finalize func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, deploy.ApplicationLocalAccountV1, RunOptions) (FinalizedBuildValidationResult, error) + remove func(context.Context, BuiltImageCandidate) error +} + +func buildInstalledRuntimeIdentityV1( + ctx context.Context, + store providerstore.Store, + source CurrentBuild, + plan DockerExecutionPlan, + options RunOptions, +) (installedRuntimeIdentityBuildV1, error) { + return buildInstalledRuntimeIdentityWithV1(ctx, store, source, plan, options, installedRuntimeIdentityBackendV1{ + inspect: InspectBuiltImageCandidate, finalize: ValidateAndFinalizeBuild, remove: RemoveBuiltImageCandidate, + }) +} + +func buildInstalledRuntimeIdentityWithV1( + ctx context.Context, + store providerstore.Store, + source CurrentBuild, + plan DockerExecutionPlan, + options RunOptions, + backend installedRuntimeIdentityBackendV1, +) (installedRuntimeIdentityBuildV1, error) { + if backend.inspect == nil || backend.finalize == nil || backend.remove == nil { + return installedRuntimeIdentityBuildV1{}, fmt.Errorf("build installed runtime identity requires a complete backend") + } + account, err := applicationLocalAccountV1(plan.Sandbox) + if err != nil { + return installedRuntimeIdentityBuildV1{}, fmt.Errorf("prepare installed application local account: %w", err) + } + if reflect.DeepEqual(account, source.Lock.RuntimeLayer.Account) { + return installedRuntimeIdentityBuildV1{Lock: source.Lock}, nil + } + upstream, err := backend.inspect( + ctx, + BuiltImageCandidate{ImageID: source.Lock.RuntimeLayer.Upstream.ConfigDigest}, + source.Lock.Platform, + ) + if err != nil { + return installedRuntimeIdentityBuildV1{}, fmt.Errorf("inspect installed runtime identity upstream: %w", err) + } + if upstream.Image != source.Lock.RuntimeLayer.Upstream { + return installedRuntimeIdentityBuildV1{}, fmt.Errorf("installed runtime identity upstream no longer matches the staged build") + } + profiles := make([]providers.RequirementProfile, 0, len(source.Lock.Nodes)) + for _, node := range source.Lock.Nodes { + profiles = append(profiles, node.RequirementProfile) + } + runner := ProviderFullImageValidationRunner{Store: store} + options.Context = ctx + finalized, err := backend.finalize( + ctx, + store, + nil, + FullImageValidationInput{ + Image: upstream, Profiles: profiles, + Outputs: append([]providers.RealizedOutput{}, source.Lock.Catalog...), + RuntimePolicy: source.Lock.RuntimePolicy, + }, + registry.ValidateRequirementProfileV1, + runner.Run, + source.Lock.RuntimeLayer.Verifier, + account, + options, + ) + if err != nil { + return installedRuntimeIdentityBuildV1{}, fmt.Errorf("build installed runtime identity: %w", err) + } + lock := source.Lock + lock.RuntimeLayer = finalized.RuntimeLayer + lock.ValidationRecord = finalized.Validation.Final.Reference + lock.FinalImage = finalized.Image.Image + if err := deploy.ValidateBuildLockV1(lock, registry.ValidateRequirementProfileV1); err != nil { + cleanupErr := backend.remove(context.WithoutCancel(ctx), finalized.Candidate) + return installedRuntimeIdentityBuildV1{}, fmt.Errorf("validate installed runtime identity build: %w", errors.Join(err, cleanupErr)) + } + return installedRuntimeIdentityBuildV1{Lock: lock, Candidate: finalized.Candidate, Adapted: true}, nil +} + +func validateInstalledRuntimeIdentityBuildV1(result installedRuntimeIdentityBuildV1, source CurrentBuild, plan DockerExecutionPlan) error { + if err := deploy.ValidateBuildLockV1(result.Lock, registry.ValidateRequirementProfileV1); err != nil { + return fmt.Errorf("validate installed runtime build: %w", err) + } + wantAccount, err := applicationLocalAccountV1(plan.Sandbox) + if err != nil { + return fmt.Errorf("validate installed runtime account: %w", err) + } + if !reflect.DeepEqual(result.Lock.RuntimeLayer.Account, wantAccount) { + return fmt.Errorf("installed runtime build does not match the planned local account") + } + if !result.Adapted { + if !reflect.DeepEqual(result.Lock, source.Lock) || result.Candidate != (BuiltImageCandidate{}) { + return fmt.Errorf("unadapted installed runtime build must reuse the exact staged build") + } + return nil + } + if reflect.DeepEqual(result.Lock.RuntimeLayer.Account, source.Lock.RuntimeLayer.Account) { + return fmt.Errorf("adapted installed runtime build must select a different local account") + } + if result.Candidate.ImageID != result.Lock.FinalImage.ConfigDigest { + return fmt.Errorf("adapted installed runtime candidate does not match its final image") + } + return nil +} diff --git a/internal/dockerdeploy/install_runtime_identity_test.go b/internal/dockerdeploy/install_runtime_identity_test.go new file mode 100644 index 00000000..e5a47c40 --- /dev/null +++ b/internal/dockerdeploy/install_runtime_identity_test.go @@ -0,0 +1,103 @@ +package dockerdeploy + +import ( + "context" + "reflect" + "strings" + "testing" + + "github.com/omry/reploy/internal/blueprint" + "github.com/omry/reploy/internal/deploy" + "github.com/omry/reploy/internal/providers" + "github.com/omry/reploy/internal/providerstore" +) + +func TestBuildInstalledRuntimeIdentityV1ReusesMatchingAccount(t *testing.T) { + current, _ := currentBuildReuseFixture(t) + store, err := providerstore.NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + result, err := buildInstalledRuntimeIdentityWithV1( + t.Context(), store, current, + DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(1000, 1000)}, + RunOptions{}, + installedRuntimeIdentityBackendV1{ + inspect: func(context.Context, BuiltImageCandidate, blueprint.Platform) (InspectedImageCandidate, error) { + t.Fatal("matching account inspected Docker") + return InspectedImageCandidate{}, nil + }, + finalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, deploy.ApplicationLocalAccountV1, RunOptions) (FinalizedBuildValidationResult, error) { + t.Fatal("matching account rebuilt the runtime layer") + return FinalizedBuildValidationResult{}, nil + }, + remove: func(context.Context, BuiltImageCandidate) error { return nil }, + }, + ) + if err != nil || result.Adapted || !reflect.DeepEqual(result.Lock, current.Lock) { + t.Fatalf("matching installed identity = %#v, %v", result, err) + } +} + +func TestValidateInstalledRuntimeIdentityBuildV1RejectsWrongPlannedAccount(t *testing.T) { + current, _ := currentBuildReuseFixture(t) + if err := validateInstalledRuntimeIdentityBuildV1( + installedRuntimeIdentityBuildV1{Lock: current.Lock}, current, + DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(2000, 3000)}, + ); err == nil || !strings.Contains(err.Error(), "planned local account") { + t.Fatalf("planned account mismatch error = %v", err) + } +} + +func TestBuildInstalledRuntimeIdentityV1RebuildsChangedAccount(t *testing.T) { + current, _ := currentBuildReuseFixture(t) + store, err := providerstore.NewStore(t.TempDir()) + if err != nil { + t.Fatal(err) + } + wantAccount, err := applicationLocalAccountV1(testApplicationSandboxPlanV1(2000, 3000)) + if err != nil { + t.Fatal(err) + } + wantCandidate := BuiltImageCandidate{ImageID: rendererDigest("e")} + result, err := buildInstalledRuntimeIdentityWithV1( + t.Context(), store, current, + DockerExecutionPlan{Sandbox: testApplicationSandboxPlanV1(2000, 3000)}, + RunOptions{}, + installedRuntimeIdentityBackendV1{ + inspect: func(_ context.Context, candidate BuiltImageCandidate, platform blueprint.Platform) (InspectedImageCandidate, error) { + if candidate.ImageID != current.Lock.RuntimeLayer.Upstream.ConfigDigest || platform != current.Lock.Platform { + t.Fatalf("upstream inspection = %#v / %#v", candidate, platform) + } + return InspectedImageCandidate{Image: current.Lock.RuntimeLayer.Upstream}, nil + }, + finalize: func(_ context.Context, gotStore providerstore.Store, layers []FullImageValidationInput, final FullImageValidationInput, validate providers.RequirementProfileOwnerValidator, run FullImageValidationRunner, verifier deploy.ApplicationStartupVerifierV1, account deploy.ApplicationLocalAccountV1, _ RunOptions) (FinalizedBuildValidationResult, error) { + if gotStore.Root() != store.Root() || len(layers) != 0 || validate == nil || run == nil || verifier != current.Lock.RuntimeLayer.Verifier || account != wantAccount { + t.Fatalf("finalization inputs were not preserved") + } + if final.Image.Image != current.Lock.RuntimeLayer.Upstream || !reflect.DeepEqual(final.Outputs, current.Lock.Catalog) || !reflect.DeepEqual(final.RuntimePolicy, current.Lock.RuntimePolicy) { + t.Fatalf("final validation input = %#v", final) + } + layer := current.Lock.RuntimeLayer + layer.Account = account + layer.TransactionDigest, err = deploy.ApplicationRuntimeLayerTransactionDigestV1(verifier, account, layer.Upstream, current.Lock.Platform) + if err != nil { + t.Fatal(err) + } + return FinalizedBuildValidationResult{ + RuntimeLayer: layer, + Validation: BuildValidationResult{Final: PublishedImageValidation{Reference: current.Lock.ValidationRecord}}, + Image: InspectedImageCandidate{Image: current.Lock.FinalImage}, + Candidate: wantCandidate, + }, nil + }, + remove: func(context.Context, BuiltImageCandidate) error { + t.Fatal("successful adapted candidate was removed early") + return nil + }, + }, + ) + if err != nil || !result.Adapted || result.Candidate != wantCandidate || result.Lock.RuntimeLayer.Account != wantAccount { + t.Fatalf("adapted installed identity = %#v, %v", result, err) + } +} diff --git a/internal/dockerdeploy/installed_build_publication.go b/internal/dockerdeploy/installed_build_publication.go index 3e078ecb..c95735c2 100644 --- a/internal/dockerdeploy/installed_build_publication.go +++ b/internal/dockerdeploy/installed_build_publication.go @@ -18,6 +18,7 @@ type InstalledBuildPublicationInputV1 struct { SourceDeploymentDir string DestinationDeploymentDir string Source CurrentBuild + Build deploy.BuildLockV1 Installation deploy.InstallationStateV1 References EnvironmentImageReferences } @@ -109,7 +110,7 @@ func publishInstalledBuildV1( if !found { return deploy.StateV1{}, fmt.Errorf("installed build source lock is missing after build selection") } - lockDigest, err := deploy.BuildLockDigestV1(input.Source.Lock, registry.ValidateRequirementProfileV1) + sourceLockDigest, err := deploy.BuildLockDigestV1(input.Source.Lock, registry.ValidateRequirementProfileV1) if err != nil { return deploy.StateV1{}, err } @@ -117,13 +118,17 @@ func publishInstalledBuildV1( if err != nil { return deploy.StateV1{}, err } - if lockedSourceDigest != lockDigest { + if lockedSourceDigest != sourceLockDigest { return deploy.StateV1{}, fmt.Errorf("installed build source lock changed after build selection") } - if lockDigest != input.Source.Generation.BuildLockDigest { + if sourceLockDigest != input.Source.Generation.BuildLockDigest { return deploy.StateV1{}, fmt.Errorf("installed build source lock digest does not match its generation") } - policyDigest, err := deploy.RuntimePolicyDigestV1(input.Source.Lock.RuntimePolicy) + lockDigest, err := deploy.BuildLockDigestV1(input.Build, registry.ValidateRequirementProfileV1) + if err != nil { + return deploy.StateV1{}, err + } + policyDigest, err := deploy.RuntimePolicyDigestV1(input.Build.RuntimePolicy) if err != nil { return deploy.StateV1{}, err } @@ -170,34 +175,39 @@ func publishInstalledBuildV1( } closure, err := backend.transferClosure( - ctx, sourceOperation, destinationOperation, sourceStore, destinationStore, input.Source.Lock, + ctx, sourceOperation, destinationOperation, sourceStore, destinationStore, input.Build, ) if err != nil { return deploy.StateV1{}, fmt.Errorf("publish installed build closure: %w", err) } candidate := input.Source.Generation candidate.Reference = references.Generation + candidate.ImageDigest = input.Build.FinalImage.Digest + candidate.RootFSSubject = input.Build.FinalImage.RootFSSubject + candidate.BuildLockDigest = lockDigest + candidate.Platform = input.Build.Platform + candidate.RuntimePolicyDigest = policyDigest pending := deploy.PendingBuildV1{ Schema: deploy.PendingBuildSchemaV1, Phase: deploy.PendingBuildPhaseValidated, Old: old, Candidate: deploy.PendingCandidateV1{ TemporaryReference: references.Temporary, GenerationReference: references.Generation, - Image: input.Source.Lock.FinalImage, BuildLockDigest: lockDigest, StoreObjects: closure, + Image: input.Build.FinalImage, BuildLockDigest: lockDigest, StoreObjects: closure, }, Cleanup: publicationCleanupItems(references, old), } if err := destinationOperation.WritePendingBuild(pending); err != nil { return deploy.StateV1{}, err } - if err := backend.createReference(ctx, input.Source.Lock.FinalImage, references, EnvironmentReferenceTemporary, input.Environment, input.DestinationDeploymentDir); err != nil { + if err := backend.createReference(ctx, input.Build.FinalImage, references, EnvironmentReferenceTemporary, input.Environment, input.DestinationDeploymentDir); err != nil { return deploy.StateV1{}, err } - if err := backend.createReference(ctx, input.Source.Lock.FinalImage, references, EnvironmentReferenceGeneration, input.Environment, input.DestinationDeploymentDir); err != nil { + if err := backend.createReference(ctx, input.Build.FinalImage, references, EnvironmentReferenceGeneration, input.Environment, input.DestinationDeploymentDir); err != nil { return deploy.StateV1{}, err } if err := destinationOperation.AdvancePendingBuildPhase(deploy.PendingBuildPhaseGenerationCreated); err != nil { return deploy.StateV1{}, err } - publishedDigest, err := destinationOperation.PublishBuildLock(input.Source.Lock, registry.ValidateRequirementProfileV1) + publishedDigest, err := destinationOperation.PublishBuildLock(input.Build, registry.ValidateRequirementProfileV1) if err != nil { return deploy.StateV1{}, err } @@ -224,13 +234,13 @@ func publishInstalledBuildV1( return deploy.StateV1{}, err } } - if err := backend.removeReference(ctx, input.Source.Lock.FinalImage, references, EnvironmentReferenceTemporary, input.Environment, input.DestinationDeploymentDir); err != nil { + if err := backend.removeReference(ctx, input.Build.FinalImage, references, EnvironmentReferenceTemporary, input.Environment, input.DestinationDeploymentDir); err != nil { return deploy.StateV1{}, err } if err := destinationOperation.RemoveOtherBuildLocks(lockDigest, registry.ValidateRequirementProfileV1); err != nil { return deploy.StateV1{}, err } - if err := destinationOperation.RemoveUnreachableBuildObjects(destinationStore, input.Source.Lock, registry.ValidateRequirementProfileV1, registry.ValidateResolvedBundlePayloadV1); err != nil { + if err := destinationOperation.RemoveUnreachableBuildObjects(destinationStore, input.Build, registry.ValidateRequirementProfileV1, registry.ValidateResolvedBundlePayloadV1); err != nil { return deploy.StateV1{}, err } if err := destinationOperation.RemovePendingBuild(); err != nil { @@ -266,6 +276,22 @@ func validateInstalledBuildSource(input InstalledBuildPublicationInputV1) error if blueprintDigest != input.Source.Lock.BlueprintDigest || input.Source.State.Platform != input.Source.Lock.Platform || !reflect.DeepEqual(input.Source.State.Overlay, input.Source.Lock.Overlay) { return fmt.Errorf("installed build source state is stale relative to its selected build lock") } + if err := deploy.ValidateBuildLockV1(input.Build, registry.ValidateRequirementProfileV1); err != nil { + return fmt.Errorf("installed build candidate: %w", err) + } + sourceShape := input.Source.Lock + buildShape := input.Build + sourceShape.RuntimeLayer = deploy.ApplicationRuntimeLayerV1{} + sourceShape.ValidationRecord = providerstore.StoreObjectRef{} + sourceShape.FinalImage = providers.RealizedImageV1{} + buildShape.RuntimeLayer = deploy.ApplicationRuntimeLayerV1{} + buildShape.ValidationRecord = providerstore.StoreObjectRef{} + buildShape.FinalImage = providers.RealizedImageV1{} + if !reflect.DeepEqual(sourceShape, buildShape) || + input.Build.RuntimeLayer.Upstream != input.Source.Lock.RuntimeLayer.Upstream || + input.Build.RuntimeLayer.Verifier != input.Source.Lock.RuntimeLayer.Verifier { + return fmt.Errorf("installed build candidate may differ from its staged source only in the application runtime identity and resulting validation") + } if err := ValidateEnvironmentGenerationReference(input.Source.Generation.Reference, input.Environment, input.SourceDeploymentDir); err != nil { return fmt.Errorf("installed build source generation reference: %w", err) } diff --git a/internal/dockerdeploy/installed_build_publication_test.go b/internal/dockerdeploy/installed_build_publication_test.go index 59ebac58..0031e38d 100644 --- a/internal/dockerdeploy/installed_build_publication_test.go +++ b/internal/dockerdeploy/installed_build_publication_test.go @@ -26,6 +26,10 @@ func TestPublishInstalledBuildTransfersAndCommitsSelectedBuild(t *testing.T) { t.Fatal(err) } references := fixedPublicationReferences(t, destinationDir, 0x71) + installLock := source.Lock + installLock.RuntimeLayer.Result.Digest = rendererDigest("e") + installLock.RuntimeLayer.Result.ConfigDigest = rendererDigest("e") + installLock.FinalImage = installLock.RuntimeLayer.Result var created []publicationReferenceCall var removed []publicationReferenceCall backend := installedBuildPublicationBackend{ @@ -43,7 +47,7 @@ func TestPublishInstalledBuildTransfersAndCommitsSelectedBuild(t *testing.T) { result, err := publishInstalledBuildV1(t.Context(), sourceOperation, destinationOperation, sourceStore, destinationStore, InstalledBuildPublicationInputV1{ Environment: "demo", SourceDeploymentDir: sourceDir, DestinationDeploymentDir: destinationDir, - Source: source, Installation: installation, References: references, + Source: source, Build: installLock, Installation: installation, References: references, }, backend) if err != nil { t.Fatal(err) @@ -52,18 +56,18 @@ func TestPublishInstalledBuildTransfersAndCommitsSelectedBuild(t *testing.T) { t.Fatalf("installed result = %#v", result) } if !reflect.DeepEqual(created, []publicationReferenceCall{ - {image: source.Lock.FinalImage, kind: EnvironmentReferenceTemporary}, - {image: source.Lock.FinalImage, kind: EnvironmentReferenceGeneration}, + {image: installLock.FinalImage, kind: EnvironmentReferenceTemporary}, + {image: installLock.FinalImage, kind: EnvironmentReferenceGeneration}, }) { t.Fatalf("created references = %#v", created) } - if !reflect.DeepEqual(removed, []publicationReferenceCall{{image: source.Lock.FinalImage, kind: EnvironmentReferenceTemporary}}) { + if !reflect.DeepEqual(removed, []publicationReferenceCall{{image: installLock.FinalImage, kind: EnvironmentReferenceTemporary}}) { t.Fatalf("removed references = %#v", removed) } - if _, err := deploy.BuildLockStoreClosure(source.Lock, destinationStore, registry.ValidateRequirementProfileV1, registry.ValidateResolvedBundlePayloadV1); err != nil { + if _, err := deploy.BuildLockStoreClosure(installLock, destinationStore, registry.ValidateRequirementProfileV1, registry.ValidateResolvedBundlePayloadV1); err != nil { t.Fatalf("destination closure = %v", err) } - if lock, found, err := destinationOperation.ReadBuildLock(result.Current.BuildLockDigest, registry.ValidateRequirementProfileV1); err != nil || !found || !reflect.DeepEqual(lock, source.Lock) { + if lock, found, err := destinationOperation.ReadBuildLock(result.Current.BuildLockDigest, registry.ValidateRequirementProfileV1); err != nil || !found || !reflect.DeepEqual(lock, installLock) { t.Fatalf("destination lock=%#v found=%v error=%v", lock, found, err) } if _, found, err := destinationOperation.ReadPendingBuild(); err != nil || found { @@ -128,7 +132,7 @@ func TestPublishInstalledBuildFailurePreservesPriorDestinationState(t *testing.T _, err = publishInstalledBuildV1(t.Context(), sourceOperation, destinationOperation, sourceStore, destinationStore, InstalledBuildPublicationInputV1{ Environment: "demo", SourceDeploymentDir: sourceDir, DestinationDeploymentDir: destinationDir, - Source: source, Installation: installedBuildPublicationInstallation(destinationDir), References: references, + Source: source, Build: source.Lock, Installation: installedBuildPublicationInstallation(destinationDir), References: references, }, backend) if !errors.Is(err, want) { t.Fatalf("error = %v, want %v", err, want) diff --git a/internal/dockerdeploy/provider_build_completion.go b/internal/dockerdeploy/provider_build_completion.go index 8c2f4b19..28858457 100644 --- a/internal/dockerdeploy/provider_build_completion.go +++ b/internal/dockerdeploy/provider_build_completion.go @@ -50,6 +50,7 @@ type providerBuildCompletionBackend struct { providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, + deploy.ApplicationLocalAccountV1, RunOptions, ) (FinalizedBuildValidationResult, error) assemble func(context.Context, providerstore.Store, BuildLockAssemblyInput) (deploy.BuildLockV1, error) @@ -116,10 +117,15 @@ func completeProviderBuild( finalizeCtx, endFinalize := buildprofile.Start(ctx, "Validate and finalize image") finalizeOptions := options finalizeOptions.Context = finalizeCtx + account, err := applicationLocalAccountV1(input.DockerPlan.Sandbox) + if err != nil { + endFinalize(err) + return ProviderBuildCompletionResult{}, fmt.Errorf("prepare application local account: %w", err) + } finalized, err := backend.validateAndFinalize( finalizeCtx, store, input.Validation.Layers, input.Validation.Final, registry.ValidateRequirementProfileV1, input.RunValidation, - input.StartupVerifier, finalizeOptions, + input.StartupVerifier, account, finalizeOptions, ) endFinalize(err) if err != nil { diff --git a/internal/dockerdeploy/provider_build_completion_test.go b/internal/dockerdeploy/provider_build_completion_test.go index 09193290..10377833 100644 --- a/internal/dockerdeploy/provider_build_completion_test.go +++ b/internal/dockerdeploy/provider_build_completion_test.go @@ -37,9 +37,13 @@ func TestCompleteProviderBuildOrdersValidationAssemblyAndPublication(t *testing. order := []string{} blueprintDigest := testResolvedBlueprintDigestV1(t, input.Document) backend := providerBuildCompletionBackend{ - validateAndFinalize: func(_ context.Context, gotStore providerstore.Store, layers []FullImageValidationInput, final FullImageValidationInput, validateOwner providers.RequirementProfileOwnerValidator, run FullImageValidationRunner, verifier deploy.ApplicationStartupVerifierV1, options RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(_ context.Context, gotStore providerstore.Store, layers []FullImageValidationInput, final FullImageValidationInput, validateOwner providers.RequirementProfileOwnerValidator, run FullImageValidationRunner, verifier deploy.ApplicationStartupVerifierV1, account deploy.ApplicationLocalAccountV1, options RunOptions) (FinalizedBuildValidationResult, error) { order = append(order, "validate") - if gotStore.Root() != store.Root() || !reflect.DeepEqual(layers, input.Validation.Layers) || !reflect.DeepEqual(final, input.Validation.Final) || validateOwner == nil || run == nil || verifier != input.StartupVerifier || options.Context == nil { + wantAccount, err := applicationLocalAccountV1(input.DockerPlan.Sandbox) + if err != nil { + t.Fatal(err) + } + if gotStore.Root() != store.Root() || !reflect.DeepEqual(layers, input.Validation.Layers) || !reflect.DeepEqual(final, input.Validation.Final) || validateOwner == nil || run == nil || verifier != input.StartupVerifier || account != wantAccount || options.Context == nil { t.Fatalf("validation arguments were not preserved") } return FinalizedBuildValidationResult{ @@ -98,7 +102,7 @@ func TestCompleteProviderBuildWarnsWhenPublishedFinalCandidateCleanupFails(t *te store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, deploy.ApplicationLocalAccountV1, RunOptions) (FinalizedBuildValidationResult, error) { return FinalizedBuildValidationResult{ Image: InspectedImageCandidate{Image: finalImage}, Candidate: BuiltImageCandidate{ImageID: finalImage.ConfigDigest}, @@ -163,7 +167,7 @@ func TestCompleteProviderBuildValidationPublishesCandidateWithoutChangingCurrent cleanupCause := errors.New("injected validation candidate cleanup failure") publishedCurrent := false result, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, deploy.ApplicationLocalAccountV1, RunOptions) (FinalizedBuildValidationResult, error) { return FinalizedBuildValidationResult{ Validation: BuildValidationResult{ Layers: []PublishedImageValidation{}, @@ -209,7 +213,7 @@ func TestCompleteProviderBuildDoesNotAssembleOrPublishAfterValidationFailure(t * assembled := false published := false _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, deploy.ApplicationLocalAccountV1, RunOptions) (FinalizedBuildValidationResult, error) { return FinalizedBuildValidationResult{}, want }, assemble: func(context.Context, providerstore.Store, BuildLockAssemblyInput) (deploy.BuildLockV1, error) { @@ -238,7 +242,7 @@ func TestCompleteProviderBuildDoesNotPublishAfterAssemblyFailure(t *testing.T) { } removed := false _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, deploy.ApplicationLocalAccountV1, RunOptions) (FinalizedBuildValidationResult, error) { return FinalizedBuildValidationResult{Candidate: candidate}, nil }, assemble: func(context.Context, providerstore.Store, BuildLockAssemblyInput) (deploy.BuildLockV1, error) { @@ -264,7 +268,7 @@ func TestCompleteProviderBuildRejectsValidationPlanDriftBeforeBackendWork(t *tes input.Validation.Layers[0].Outputs = nil calls := 0 _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, deploy.ApplicationLocalAccountV1, RunOptions) (FinalizedBuildValidationResult, error) { calls++ return FinalizedBuildValidationResult{}, nil }, @@ -291,7 +295,7 @@ func TestCompleteProviderBuildRejectsRuntimePlanDriftBeforeBackendWork(t *testin }} calls := 0 _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, deploy.ApplicationLocalAccountV1, RunOptions) (FinalizedBuildValidationResult, error) { calls++ return FinalizedBuildValidationResult{}, nil }, @@ -321,7 +325,7 @@ func TestCompleteProviderBuildRejectsDocumentRequestDriftBeforeBackendWork(t *te } calls := 0 _, err := completeProviderBuild(t.Context(), operation, store, input, providerBuildCompletionBackend{ - validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, RunOptions) (FinalizedBuildValidationResult, error) { + validateAndFinalize: func(context.Context, providerstore.Store, []FullImageValidationInput, FullImageValidationInput, providers.RequirementProfileOwnerValidator, FullImageValidationRunner, deploy.ApplicationStartupVerifierV1, deploy.ApplicationLocalAccountV1, RunOptions) (FinalizedBuildValidationResult, error) { calls++ return FinalizedBuildValidationResult{}, nil }, diff --git a/internal/dockerdeploy/provider_build_execute.go b/internal/dockerdeploy/provider_build_execute.go index 37a01277..a0b09bb3 100644 --- a/internal/dockerdeploy/provider_build_execute.go +++ b/internal/dockerdeploy/provider_build_execute.go @@ -31,6 +31,7 @@ type LockedProviderBuildExecutionResultV1 struct { Republished bool Validated bool VerificationFailure string + Warnings []string } type providerBuildExecutionBackend struct { diff --git a/internal/dockerdeploy/provider_build_run.go b/internal/dockerdeploy/provider_build_run.go index 00fa21a7..d9119c6c 100644 --- a/internal/dockerdeploy/provider_build_run.go +++ b/internal/dockerdeploy/provider_build_run.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "io" - "os" "path/filepath" - "runtime" "github.com/omry/reploy/internal/blueprint" "github.com/omry/reploy/internal/buildprofile" @@ -52,15 +50,11 @@ type StagedProviderBuildRuntimeV1 struct { } func CurrentStagedProviderBuildRuntimeV1() (StagedProviderBuildRuntimeV1, error) { - 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) - } + goos, uid, gid, groups, err := currentHostRuntimeIdentityV1() + if err != nil { + return StagedProviderBuildRuntimeV1{}, err } - return stagedProviderBuildRuntimeV1(runtime.GOOS, os.Getuid(), os.Getgid(), groups) + return stagedProviderBuildRuntimeV1(goos, uid, gid, groups) } func stagedProviderBuildRuntimeV1(goos string, uid int, gid int, groups []int) (StagedProviderBuildRuntimeV1, error) { @@ -73,11 +67,8 @@ func stagedProviderBuildRuntimeV1(goos string, uid int, gid int, groups []int) ( case "windows": host = blueprint.HostWindows groups = []int{} - if uid < 0 { - uid = 0 - } - if gid < 0 { - gid = 0 + if uid <= 0 || gid <= 0 { + return StagedProviderBuildRuntimeV1{}, fmt.Errorf("Windows Docker runtime requires a mapped non-root UID and GID") } default: return StagedProviderBuildRuntimeV1{}, fmt.Errorf("provider build is unsupported on host OS %q", goos) @@ -472,6 +463,7 @@ func runLockedProviderBuildV1( return LockedProviderBuildExecutionResultV1{}, errors.Join(err, cleanupErr) } result.VerificationFailure = verificationFailure + result.Warnings = append([]string(nil), dockerPlan.Sandbox.RuntimeUser.Warnings...) if err := ctx.Err(); err != nil { return LockedProviderBuildExecutionResultV1{}, err } diff --git a/internal/dockerdeploy/provider_build_run_test.go b/internal/dockerdeploy/provider_build_run_test.go index 49eb20fb..021e577e 100644 --- a/internal/dockerdeploy/provider_build_run_test.go +++ b/internal/dockerdeploy/provider_build_run_test.go @@ -113,12 +113,12 @@ func TestRunLockedProviderBuildV1UsesAndRetainsCallerLock(t *testing.T) { if err != nil { t.Fatal(err) } - want := LockedProviderBuildExecutionResultV1{Reused: true} + want := LockedProviderBuildExecutionResultV1{Reused: true, Warnings: []string{rootRuntimeIdentityWarningV1}} order := []string{} result, err := runLockedProviderBuildV1(t.Context(), LockedProviderBuildRunInputV1{ Operation: operation, Store: store, DeploymentDir: dir, - Runtime: StagedProviderBuildRuntimeV1{Host: blueprint.HostLinux, UID: 1001, GID: 1002}, + Runtime: StagedProviderBuildRuntimeV1{Host: blueprint.HostLinux, UID: 0, GID: 0}, NoCache: true, }, providerBuildRunBackend{ prepare: func(_ context.Context, input LockedProviderBuildPreparationInputV1) (LockedProviderBuildPreparationV1, error) { @@ -133,7 +133,7 @@ func TestRunLockedProviderBuildV1UsesAndRetainsCallerLock(t *testing.T) { if input.Preparation.Operation != operation { t.Fatalf("execution input = %#v", input) } - return want, nil + return LockedProviderBuildExecutionResultV1{Reused: true}, nil }, }) if err != nil { @@ -805,15 +805,44 @@ func TestStagedProviderBuildRuntimeV1MapsSupportedHosts(t *testing.T) { } }) } - 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("windows", -1, -1, nil); err == nil || !strings.Contains(err.Error(), "mapped non-root") { + t.Fatalf("Windows runtime identity error = %v", err) } if _, err := stagedProviderBuildRuntimeV1("plan9", 1, 2, nil); err == nil || !strings.Contains(err.Error(), "unsupported") { t.Fatalf("error = %v", err) } } +func TestWindowsSIDRuntimeIdentityV1IsStableNonRootAndSIDSpecific(t *testing.T) { + firstUID, firstGID, err := windowsSIDRuntimeIdentityV1("S-1-5-21-100-200-300-1001") + if err != nil { + t.Fatal(err) + } + repeatedUID, repeatedGID, err := windowsSIDRuntimeIdentityV1("s-1-5-21-100-200-300-1001") + if err != nil { + t.Fatal(err) + } + otherUID, otherGID, err := windowsSIDRuntimeIdentityV1("S-1-5-21-100-200-300-1002") + if err != nil { + t.Fatal(err) + } + if firstUID < windowsRuntimeIDMinimumV1 || firstUID != firstGID || firstUID != repeatedUID || firstGID != repeatedGID { + t.Fatalf("stable SID mapping = %d:%d then %d:%d", firstUID, firstGID, repeatedUID, repeatedGID) + } + canonicalUID, canonicalGID, err := windowsSIDRuntimeIdentityV1("S-01-005-021-0100-0200-0300-01001") + if err != nil || canonicalUID != firstUID || canonicalGID != firstGID { + t.Fatalf("canonical SID mapping = %d:%d, %v", canonicalUID, canonicalGID, err) + } + if otherUID != otherGID || otherUID == firstUID { + t.Fatalf("distinct SID mapping = %d:%d, first %d:%d", otherUID, otherGID, firstUID, firstGID) + } + for _, malformed := range []string{"", "not-a-sid", "S-1-5", "S-1-X-21"} { + if _, _, err := windowsSIDRuntimeIdentityV1(malformed); err == nil { + t.Fatalf("malformed SID %q unexpectedly accepted", malformed) + } + } +} + func stageProviderBuildRunState(t *testing.T, workspace bool) (string, blueprint.Document) { t.Helper() dir := t.TempDir() diff --git a/internal/dockerdeploy/provider_install_backend.go b/internal/dockerdeploy/provider_install_backend.go index 20069218..44cc13ac 100644 --- a/internal/dockerdeploy/provider_install_backend.go +++ b/internal/dockerdeploy/provider_install_backend.go @@ -20,6 +20,7 @@ func newProviderInstallRunBackendV1() providerInstallRunBackend { recoverDestination: recoverProviderInstallDestinationV1, buildSource: RunLockedProviderBuildV1, prepareAccount: prepareProviderInstallAccountV1, + buildInstallRuntime: buildInstalledRuntimeIdentityV1, newReferences: NewEnvironmentImageReferences, planInstallation: planProviderInstallationV1, inspectHostTools: inspectProviderInstallHostToolsV1, diff --git a/internal/dockerdeploy/provider_install_disk_requirements.go b/internal/dockerdeploy/provider_install_disk_requirements.go index 89f82797..d691bcf6 100644 --- a/internal/dockerdeploy/provider_install_disk_requirements.go +++ b/internal/dockerdeploy/provider_install_disk_requirements.go @@ -47,18 +47,31 @@ func providerInstallDiskRequirementsV1( } closure, closureBytes, err := deploy.InspectBuildLockStoreClosure( - publication.Source.Lock, sourceStore, + publication.Build, sourceStore, registry.ValidateRequirementProfileV1, registry.ValidateResolvedBundlePayloadV1, ) if err != nil { return nil, fmt.Errorf("install disk requirements closure: %w", err) } - lockContent, err := deploy.EncodeBuildLockV1(publication.Source.Lock, registry.ValidateRequirementProfileV1) + lockContent, err := deploy.EncodeBuildLockV1(publication.Build, registry.ValidateRequirementProfileV1) if err != nil { return nil, err } destinationGeneration := publication.Source.Generation destinationGeneration.Reference = publication.References.Generation + lockDigest, err := deploy.BuildLockDigestV1(publication.Build, registry.ValidateRequirementProfileV1) + if err != nil { + return nil, err + } + policyDigest, err := deploy.RuntimePolicyDigestV1(publication.Build.RuntimePolicy) + if err != nil { + return nil, err + } + destinationGeneration.ImageDigest = publication.Build.FinalImage.Digest + destinationGeneration.RootFSSubject = publication.Build.FinalImage.RootFSSubject + destinationGeneration.BuildLockDigest = lockDigest + destinationGeneration.Platform = publication.Build.Platform + destinationGeneration.RuntimePolicyDigest = policyDigest destinationState := publication.Source.State destinationState.Current = &destinationGeneration destinationState.Staging = nil @@ -74,7 +87,7 @@ func providerInstallDiskRequirementsV1( Candidate: deploy.PendingCandidateV1{ TemporaryReference: publication.References.Temporary, GenerationReference: publication.References.Generation, - Image: publication.Source.Lock.FinalImage, + Image: publication.Build.FinalImage, BuildLockDigest: destinationGeneration.BuildLockDigest, StoreObjects: closure, }, diff --git a/internal/dockerdeploy/provider_install_disk_requirements_test.go b/internal/dockerdeploy/provider_install_disk_requirements_test.go index 4058fbc1..1b282642 100644 --- a/internal/dockerdeploy/provider_install_disk_requirements_test.go +++ b/internal/dockerdeploy/provider_install_disk_requirements_test.go @@ -22,7 +22,7 @@ func TestProviderInstallDiskRequirementsV1CountsPublicationPeakAndCandidates(t * installation.Status = deploy.InstallationStatusConfiguring publication := InstalledBuildPublicationInputV1{ Environment: "demo", SourceDeploymentDir: sourceDir, DestinationDeploymentDir: destinationDir, - Source: source, Installation: installation, References: references, + Source: source, Build: source.Lock, Installation: installation, References: references, } candidates := []providerInstallFileCandidateV1{ {Path: filepath.Join(destinationDir, DockerEnvFileName), Content: []byte("env"), Mode: 0o600}, @@ -90,7 +90,7 @@ func TestProviderInstallDiskRequirementsV1RequiresConfiguringStateAndSortedCandi } publication := InstalledBuildPublicationInputV1{ Environment: "demo", SourceDeploymentDir: sourceDir, DestinationDeploymentDir: destinationDir, - Source: source, Installation: installedBuildPublicationInstallation(destinationDir), + Source: source, Build: source.Lock, Installation: installedBuildPublicationInstallation(destinationDir), References: fixedPublicationReferences(t, destinationDir, 0xc2), } if _, err := providerInstallDiskRequirementsV1(sourceStore, destinationStore, publication, nil, []providerInstallFileCandidateV1{}, []PathUpdateAction{}); err == nil { @@ -127,7 +127,7 @@ func TestProviderInstallDiskRequirementsV1CountsManagedBindCopy(t *testing.T) { installation.Status = deploy.InstallationStatusConfiguring publication := InstalledBuildPublicationInputV1{ Environment: "demo", SourceDeploymentDir: sourceDir, DestinationDeploymentDir: destinationDir, - Source: source, Installation: installation, References: fixedPublicationReferences(t, destinationDir, 0xc3), + Source: source, Build: source.Lock, Installation: installation, References: fixedPublicationReferences(t, destinationDir, 0xc3), } target := filepath.Join(destinationDir, "conf") requirements, err := providerInstallDiskRequirementsV1( diff --git a/internal/dockerdeploy/provider_install_lifecycle_execute.go b/internal/dockerdeploy/provider_install_lifecycle_execute.go index eafa8612..f6332ea9 100644 --- a/internal/dockerdeploy/provider_install_lifecycle_execute.go +++ b/internal/dockerdeploy/provider_install_lifecycle_execute.go @@ -58,7 +58,7 @@ func providerInstallLifecycleExecutorV1(locked lockedProviderInstallV1) Lifecycl }, locked.Plan.Docker, locked.DestinationStore, - locked.SourceBuild.Lock.Platform, + locked.InstallBuild.Platform, locked.Input.RunOptions.Stdout, locked.Input.RunOptions.Stderr, ) diff --git a/internal/dockerdeploy/provider_install_path_updates.go b/internal/dockerdeploy/provider_install_path_updates.go index f1e409ff..33542d02 100644 --- a/internal/dockerdeploy/provider_install_path_updates.go +++ b/internal/dockerdeploy/provider_install_path_updates.go @@ -276,8 +276,8 @@ func applyProviderInstallVolumeV1( } copyCommand, err := providerInstallVolumeCopyCommandV1( dockerPath, containerName, action.Source, action.Target, - locked.SourceBuild.Lock.FinalImage.ConfigDigest, - locked.SourceBuild.Lock.Platform, + locked.InstallBuild.FinalImage.ConfigDigest, + locked.InstallBuild.Platform, workspace, ) if err != nil { diff --git a/internal/dockerdeploy/provider_install_path_updates_integration_test.go b/internal/dockerdeploy/provider_install_path_updates_integration_test.go index 9864f50f..15dbbf10 100644 --- a/internal/dockerdeploy/provider_install_path_updates_integration_test.go +++ b/internal/dockerdeploy/provider_install_path_updates_integration_test.go @@ -65,6 +65,7 @@ func TestProviderInstallVolumeCopyHelperDockerIntegration(t *testing.T) { locked.HostTools.DockerPath = "docker" locked.SourceBuild.Lock.Platform = platform locked.SourceBuild.Lock.FinalImage.ConfigDigest = imageID + locked.InstallBuild = locked.SourceBuild.Lock cleaned := false if err := applyProviderInstallPathUpdatesWithV1(ctx, locked, providerInstallPathUpdateBackendV1{ volumeExists: func(ctx context.Context, name string) (bool, error) { diff --git a/internal/dockerdeploy/provider_install_path_updates_test.go b/internal/dockerdeploy/provider_install_path_updates_test.go index 5cc89c02..ba46eca7 100644 --- a/internal/dockerdeploy/provider_install_path_updates_test.go +++ b/internal/dockerdeploy/provider_install_path_updates_test.go @@ -124,6 +124,7 @@ func TestApplyProviderInstallVolumeV1ReplacesFromExistingStagingVolume(t *testin } locked.SourceBuild.Lock.Platform = platform locked.SourceBuild.Lock.FinalImage.ConfigDigest = rendererDigest("a") + locked.InstallBuild = locked.SourceBuild.Lock workspace := testPreparedProbeWorkspace(t, platform, t.TempDir()) commands := []CommandSpec{} exists := map[string]bool{"staging-data": true, "installed-data": true} @@ -146,7 +147,7 @@ func TestApplyProviderInstallVolumeV1ReplacesFromExistingStagingVolume(t *testin } copyCommand, err := providerInstallVolumeCopyCommandV1( "/usr/bin/docker", providerInstallVolumeCopyContainerNameV1("installed-data"), "staging-data", "installed-data", - locked.SourceBuild.Lock.FinalImage.ConfigDigest, platform, workspace, + locked.InstallBuild.FinalImage.ConfigDigest, platform, workspace, ) if err != nil { t.Fatal(err) @@ -196,6 +197,7 @@ func TestApplyProviderInstallVolumeV1CleansContainerAndPartialTargetAfterCopyFai } locked.SourceBuild.Lock.Platform = platform locked.SourceBuild.Lock.FinalImage.ConfigDigest = rendererDigest("b") + locked.InstallBuild = locked.SourceBuild.Lock workspace := testPreparedProbeWorkspace(t, platform, t.TempDir()) wantCopy := errors.New("copy failed") commands := []CommandSpec{} diff --git a/internal/dockerdeploy/provider_install_prepare_destination.go b/internal/dockerdeploy/provider_install_prepare_destination.go index 62120caf..10597de1 100644 --- a/internal/dockerdeploy/provider_install_prepare_destination.go +++ b/internal/dockerdeploy/provider_install_prepare_destination.go @@ -77,7 +77,7 @@ func prepareProviderInstallDestinationWithV1( configuring.Status = deploy.InstallationStatusConfiguring publication := InstalledBuildPublicationInputV1{ Environment: document.Environment.ID, SourceDeploymentDir: locked.Input.SourceDeploymentDir, - DestinationDeploymentDir: locked.Input.DestinationDeploymentDir, Source: locked.SourceBuild, + DestinationDeploymentDir: locked.Input.DestinationDeploymentDir, Source: locked.SourceBuild, Build: locked.InstallBuild, Installation: configuring, References: locked.References, } requirements, err := backend.diskRequirements(locked.SourceStore, locked.DestinationStore, publication, old, candidates, locked.Plan.PathUpdates) diff --git a/internal/dockerdeploy/provider_install_prepare_destination_test.go b/internal/dockerdeploy/provider_install_prepare_destination_test.go index b96484ee..e80b5d71 100644 --- a/internal/dockerdeploy/provider_install_prepare_destination_test.go +++ b/internal/dockerdeploy/provider_install_prepare_destination_test.go @@ -82,7 +82,7 @@ func providerInstallPrepareDestinationFixture(t *testing.T) lockedProviderInstal plan.Installation.UnitPath = "" return lockedProviderInstallV1{ SourceOperation: sourceOperation, DestinationOperation: destinationOperation, - SourceStore: sourceStore, DestinationStore: destinationStore, SourceBuild: source, + SourceStore: sourceStore, DestinationStore: destinationStore, SourceBuild: source, InstallBuild: source.Lock, Plan: plan, References: references, Input: providerInstallRunInputV1{SourceDeploymentDir: sourceDir, DestinationDeploymentDir: destinationDir}, } diff --git a/internal/dockerdeploy/provider_install_run.go b/internal/dockerdeploy/provider_install_run.go index ab147011..bfe94520 100644 --- a/internal/dockerdeploy/provider_install_run.go +++ b/internal/dockerdeploy/provider_install_run.go @@ -35,6 +35,7 @@ type ProviderInstallResultV1 struct { ImageReused bool Started bool PathUpdates []PathUpdateAction + Warnings []string } type providerInstallOptionsV1 struct { @@ -75,6 +76,7 @@ type lockedProviderInstallV1 struct { SourceStore providerstore.Store DestinationStore providerstore.Store SourceBuild CurrentBuild + InstallBuild deploy.BuildLockV1 Plan providerInstallationPlanV1 References EnvironmentImageReferences HostTools providerInstallHostToolsV1 @@ -91,6 +93,7 @@ type providerInstallRunBackend struct { recoverDestination func(context.Context, *deploy.OperationLock, providerstore.Store, string, string) (bool, error) buildSource func(context.Context, LockedProviderBuildRunInputV1) (LockedProviderBuildExecutionResultV1, error) prepareAccount func(context.Context, blueprint.SystemAccount, providerstore.Store, CurrentBuild, providerInstallRunInputV1) (providerInstallRunInputV1, error) + buildInstallRuntime func(context.Context, providerstore.Store, CurrentBuild, DockerExecutionPlan, RunOptions) (installedRuntimeIdentityBuildV1, error) newReferences func(string, string) (EnvironmentImageReferences, error) planInstallation func(context.Context, providerInstallPlanningV1) (providerInstallationPlanV1, error) inspectHostTools func(context.Context, installBackend) (providerInstallHostToolsV1, error) @@ -141,7 +144,7 @@ func runProviderInstallV1( return deploy.StateV1{}, fmt.Errorf("provider install source and destination must not overlap") } } - if backend.acquire == nil || backend.release == nil || backend.readState == nil || backend.admit == nil || backend.complete == nil || backend.newStore == nil || backend.recoverDestination == nil || backend.buildSource == nil || backend.prepareAccount == nil || backend.newReferences == nil || backend.planInstallation == nil || backend.inspectHostTools == nil || backend.preflightDestination == nil || backend.ensureDestination == nil || backend.cleanupDestination == nil || backend.prepareDestination == nil || backend.stopDestination == nil || backend.publish == nil || backend.publishFiles == nil || backend.activateDestination == nil || backend.markReady == nil || backend.startDestination == nil { + if backend.acquire == nil || backend.release == nil || backend.readState == nil || backend.admit == nil || backend.complete == nil || backend.newStore == nil || backend.recoverDestination == nil || backend.buildSource == nil || backend.prepareAccount == nil || backend.buildInstallRuntime == nil || backend.newReferences == nil || backend.planInstallation == nil || backend.inspectHostTools == nil || backend.preflightDestination == nil || backend.ensureDestination == nil || backend.cleanupDestination == nil || backend.prepareDestination == nil || backend.stopDestination == nil || backend.publish == nil || backend.publishFiles == nil || backend.activateDestination == nil || backend.markReady == nil || backend.startDestination == nil { return deploy.StateV1{}, fmt.Errorf("run provider install requires a complete backend") } input.SourceDeploymentDir = sourceDir @@ -265,6 +268,26 @@ func runProviderInstallV1( if plan.Installation.Status != deploy.InstallationStatusReady { return deploy.StateV1{}, fmt.Errorf("provider installation plan must describe a ready installation") } + installBuild, err := backend.buildInstallRuntime(ctx, sourceStore, sourceBuild, plan.Docker, input.RunOptions) + if err != nil { + return deploy.StateV1{}, err + } + destinationPublished := false + defer func() { + if !installBuild.Adapted { + return + } + if cleanupErr := RemoveBuiltImageCandidate(context.WithoutCancel(ctx), installBuild.Candidate); cleanupErr != nil { + if destinationPublished { + writeProviderBuildProgress(input.RunOptions.Progress, "warning: installation succeeded, but cleanup of a temporary runtime image is pending: %v", cleanupErr) + return + } + err = errors.Join(err, fmt.Errorf("remove temporary installed runtime image: %w", cleanupErr)) + } + }() + if err := validateInstalledRuntimeIdentityBuildV1(installBuild, sourceBuild, plan.Docker); err != nil { + return deploy.StateV1{}, err + } hostTools, err := backend.inspectHostTools(ctx, plan.Backend) if err != nil { return deploy.StateV1{}, fmt.Errorf("inspect provider installation host tools: %w", err) @@ -276,7 +299,6 @@ func runProviderInstallV1( if err != nil { return deploy.StateV1{}, err } - destinationPublished := false defer func() { if err != nil && destinationCreated && !destinationPublished && destinationCleanupSafe { err = errors.Join(err, backend.cleanupDestination(destinationDir)) @@ -286,7 +308,8 @@ func runProviderInstallV1( configuring.Status = deploy.InstallationStatusConfiguring publicationInput := InstalledBuildPublicationInputV1{ Environment: document.Environment.ID, SourceDeploymentDir: sourceDir, - DestinationDeploymentDir: destinationDir, Source: sourceBuild, Installation: configuring, References: references, + DestinationDeploymentDir: destinationDir, Source: sourceBuild, Build: installBuild.Lock, + Installation: configuring, References: references, } if err := validateInstalledBuildSource(publicationInput); err != nil { return deploy.StateV1{}, err @@ -329,16 +352,18 @@ func runProviderInstallV1( ControlScript: plan.ControlScript, Service: plan.Installation.Service, Updated: updated, Started: input.Install.Start, PathUpdates: append([]PathUpdateAction(nil), plan.PathUpdates...), + Warnings: append([]string(nil), plan.Docker.Sandbox.RuntimeUser.Warnings...), } if updated && destinationState.Current != nil { - input.result.ImageReused = destinationState.Current.ImageDigest == sourceBuild.Generation.ImageDigest + input.result.ImageReused = destinationState.Current.ImageDigest == installBuild.Lock.FinalImage.Digest } } destinationGeneration := providerInstallDestinationGenerationV1(destinationState, destinationFound, references.Generation) locked := lockedProviderInstallV1{ SourceOperation: sourceOperation, DestinationOperation: destinationOperation, SourceStore: sourceStore, DestinationStore: destinationStore, - SourceBuild: sourceBuild, Plan: plan, References: references, HostTools: hostTools, Input: input, + SourceBuild: sourceBuild, InstallBuild: installBuild.Lock, + Plan: plan, References: references, HostTools: hostTools, Input: input, } prepared, err := backend.prepareDestination(ctx, locked) if err != nil { diff --git a/internal/dockerdeploy/provider_install_run_test.go b/internal/dockerdeploy/provider_install_run_test.go index 4eb3bf45..9aaa5dfb 100644 --- a/internal/dockerdeploy/provider_install_run_test.go +++ b/internal/dockerdeploy/provider_install_run_test.go @@ -90,6 +90,17 @@ func TestRunProviderInstallV1HoldsSourceBeforeDestinationAndReleasesInReverse(t input.Install.SystemGID = 992 return input, nil }, + buildInstallRuntime: func(_ context.Context, _ providerstore.Store, source CurrentBuild, plan DockerExecutionPlan, _ RunOptions) (installedRuntimeIdentityBuildV1, error) { + order = append(order, "build-install-runtime") + account, err := applicationLocalAccountV1(plan.Sandbox) + if err != nil { + return installedRuntimeIdentityBuildV1{}, err + } + if account != source.Lock.RuntimeLayer.Account { + t.Fatalf("install account = %#v, source account = %#v", account, source.Lock.RuntimeLayer.Account) + } + return installedRuntimeIdentityBuildV1{Lock: source.Lock}, nil + }, planInstallation: func(_ context.Context, input providerInstallPlanningV1) (providerInstallationPlanV1, error) { order = append(order, "plan-installation") if err := locks[sourceDir].RequireHeld(); err != nil { @@ -109,6 +120,7 @@ func TestRunProviderInstallV1HoldsSourceBeforeDestinationAndReleasesInReverse(t Name: "data", Kind: PathPreserveManagedBind, Target: filepath.Join(destinationDir, "data"), }} + plan.Docker.Sandbox.RuntimeUser.Warnings = []string{"runtime identity warning"} return plan, nil }, inspectHostTools: func(_ context.Context, backend installBackend) (providerInstallHostToolsV1, error) { @@ -245,7 +257,7 @@ func TestRunProviderInstallV1HoldsSourceBeforeDestinationAndReleasesInReverse(t if err != nil || !reflect.DeepEqual(result, want) { t.Fatalf("result=%#v error=%v", result, err) } - wantOrder := []string{"acquire-source", "build-source", "acquire-destination", "recover-destination", "prepare-install-account", "plan-installation", "inspect-host-tools", "preflight-destination", "prepare-destination", "admit-install", "stop-destination", "publish", "publish-files", "activate-destination", "mark-ready", "start-destination", "acquire-destination", "complete-install", "release-source"} + wantOrder := []string{"acquire-source", "build-source", "acquire-destination", "recover-destination", "prepare-install-account", "plan-installation", "build-install-runtime", "inspect-host-tools", "preflight-destination", "prepare-destination", "admit-install", "stop-destination", "publish", "publish-files", "activate-destination", "mark-ready", "start-destination", "acquire-destination", "complete-install", "release-source"} if !reflect.DeepEqual(order, wantOrder) { t.Fatalf("order=%v want=%v", order, wantOrder) } @@ -261,6 +273,9 @@ func TestRunProviderInstallV1HoldsSourceBeforeDestinationAndReleasesInReverse(t }}) { t.Fatalf("install result path updates = %#v", details.PathUpdates) } + if !reflect.DeepEqual(details.Warnings, []string{"runtime identity warning"}) { + t.Fatalf("install result warnings = %#v", details.Warnings) + } for _, step := range []string{ "preparing current staged environment", "updating existing installation", @@ -308,6 +323,15 @@ func TestRunProviderInstallV1CanceledBeforeBackendWork(t *testing.T) { } } +func TestRunProviderInstallV1RequiresCompleteBackend(t *testing.T) { + _, err := runProviderInstallV1(t.Context(), providerInstallRunInputV1{ + SourceDeploymentDir: t.TempDir(), DestinationDeploymentDir: t.TempDir(), + }, providerInstallRunBackend{}) + if err == nil || !strings.Contains(err.Error(), "complete backend") { + t.Fatalf("incomplete backend error = %v", err) + } +} + func TestProviderInstallDestinationGenerationV1UsesExistingCurrentOrIncoming(t *testing.T) { state := deploy.StateV1{Current: &deploy.EnvironmentGenerationState{Reference: "g-current"}} if got := providerInstallDestinationGenerationV1(state, true, "g-incoming"); got != "g-current" { @@ -992,6 +1016,11 @@ func providerInstallRunRecoverDestinationFixture( func providerInstallAdmissionTestBackend(backend providerInstallRunBackend, order *[]string) providerInstallRunBackend { lease := new(deploy.ControlLeaseV1) + if backend.buildInstallRuntime == nil { + backend.buildInstallRuntime = func(_ context.Context, _ providerstore.Store, source CurrentBuild, _ DockerExecutionPlan, _ RunOptions) (installedRuntimeIdentityBuildV1, error) { + return installedRuntimeIdentityBuildV1{Lock: source.Lock}, nil + } + } if backend.recoverDestination == nil { backend.recoverDestination = func(context.Context, *deploy.OperationLock, providerstore.Store, string, string) (bool, error) { return false, nil diff --git a/internal/dockerdeploy/runtime_identity_unix.go b/internal/dockerdeploy/runtime_identity_unix.go new file mode 100644 index 00000000..69699687 --- /dev/null +++ b/internal/dockerdeploy/runtime_identity_unix.go @@ -0,0 +1,17 @@ +//go:build !windows + +package dockerdeploy + +import ( + "fmt" + "os" + "runtime" +) + +func currentHostRuntimeIdentityV1() (string, int, int, []int, error) { + groups, err := os.Getgroups() + if err != nil { + return "", 0, 0, nil, fmt.Errorf("resolve current supplementary groups: %w", err) + } + return runtime.GOOS, os.Getuid(), os.Getgid(), groups, nil +} diff --git a/internal/dockerdeploy/runtime_identity_windows.go b/internal/dockerdeploy/runtime_identity_windows.go new file mode 100644 index 00000000..64f88bf3 --- /dev/null +++ b/internal/dockerdeploy/runtime_identity_windows.go @@ -0,0 +1,20 @@ +//go:build windows + +package dockerdeploy + +import ( + "fmt" + "os/user" +) + +func currentHostRuntimeIdentityV1() (string, int, int, []int, error) { + current, err := user.Current() + if err != nil { + return "", 0, 0, nil, fmt.Errorf("resolve current Windows user: %w", err) + } + uid, gid, err := windowsSIDRuntimeIdentityV1(current.Uid) + if err != nil { + return "", 0, 0, nil, fmt.Errorf("map current Windows user SID: %w", err) + } + return "windows", uid, gid, []int{}, nil +} diff --git a/internal/dockerdeploy/runtime_identity_windows_mapping.go b/internal/dockerdeploy/runtime_identity_windows_mapping.go new file mode 100644 index 00000000..e9f72e99 --- /dev/null +++ b/internal/dockerdeploy/runtime_identity_windows_mapping.go @@ -0,0 +1,34 @@ +package dockerdeploy + +import ( + "crypto/sha256" + "encoding/binary" + "fmt" + "strconv" + "strings" +) + +const ( + windowsRuntimeIDMinimumV1 = 100_000 + windowsRuntimeIDSpanV1 = 1_900_000_000 +) + +func windowsSIDRuntimeIdentityV1(sid string) (int, int, error) { + sid = strings.TrimSpace(sid) + parts := strings.Split(strings.ToUpper(sid), "-") + if len(parts) < 4 || parts[0] != "S" { + return 0, 0, fmt.Errorf("Windows SID is empty or malformed") + } + canonical := make([]string, len(parts)) + canonical[0] = "S" + for index, part := range parts[1:] { + value, err := strconv.ParseUint(part, 10, 64) + if err != nil { + return 0, 0, fmt.Errorf("Windows SID is empty or malformed") + } + canonical[index+1] = strconv.FormatUint(value, 10) + } + digest := sha256.Sum256([]byte("reploy-windows-runtime-identity-v1\x00" + strings.Join(canonical, "-"))) + id := windowsRuntimeIDMinimumV1 + int(binary.BigEndian.Uint64(digest[:8])%windowsRuntimeIDSpanV1) + return id, id, nil +} diff --git a/internal/dockerdeploy/runtime_layer_test_helpers_test.go b/internal/dockerdeploy/runtime_layer_test_helpers_test.go index 8336d95e..b02277ad 100644 --- a/internal/dockerdeploy/runtime_layer_test_helpers_test.go +++ b/internal/dockerdeploy/runtime_layer_test_helpers_test.go @@ -8,6 +8,12 @@ import ( "github.com/omry/reploy/internal/providers" ) +func testApplicationLocalAccountV1() deploy.ApplicationLocalAccountV1 { + return deploy.ApplicationLocalAccountV1{ + Schema: deploy.ApplicationLocalAccountSchemaV1, Name: "reploy", UID: "1000", GID: "1000", Home: environmentTemporaryHome, + } +} + func testApplicationRuntimeLayerV1( t *testing.T, platform blueprint.Platform, @@ -18,12 +24,13 @@ func testApplicationRuntimeLayerV1( verifier := deploy.ApplicationStartupVerifierContractV1() verifier.Artifact = rendererDigest("f") verifier.Size = "123" - transaction, err := deploy.ApplicationRuntimeLayerTransactionDigestV1(verifier, upstream, platform) + account := testApplicationLocalAccountV1() + transaction, err := deploy.ApplicationRuntimeLayerTransactionDigestV1(verifier, account, upstream, platform) if err != nil { t.Fatal(err) } return deploy.ApplicationRuntimeLayerV1{ - Schema: deploy.ApplicationRuntimeLayerSchemaV1, Verifier: verifier, + Schema: deploy.ApplicationRuntimeLayerSchemaV1, Verifier: verifier, Account: account, TransactionDigest: transaction, Upstream: upstream, Result: result, } } diff --git a/internal/dockerdeploy/runtime_readiness.go b/internal/dockerdeploy/runtime_readiness.go index 5b72fa45..b4023b87 100644 --- a/internal/dockerdeploy/runtime_readiness.go +++ b/internal/dockerdeploy/runtime_readiness.go @@ -224,5 +224,9 @@ func CurrentBuildMatchesRuntimeV1(current CurrentBuild, dockerPlan DockerExecuti if err != nil { return false, err } - return policyDigest == lockedPolicyDigest, nil + account, err := applicationLocalAccountV1(dockerPlan.Sandbox) + if err != nil { + return false, fmt.Errorf("runtime local account: %w", err) + } + return policyDigest == lockedPolicyDigest && reflect.DeepEqual(account, current.Lock.RuntimeLayer.Account), nil } diff --git a/internal/dockerdeploy/runtime_readiness_test.go b/internal/dockerdeploy/runtime_readiness_test.go index 652d4161..cee4f65b 100644 --- a/internal/dockerdeploy/runtime_readiness_test.go +++ b/internal/dockerdeploy/runtime_readiness_test.go @@ -229,6 +229,12 @@ func TestCurrentBuildMatchesRuntimeV1TreatsChangedStateAsStale(t *testing.T) { if err != nil || !matched { t.Fatalf("matching runtime build = %v, %v", matched, err) } + changedIdentity := buildInput.DockerPlan + changedIdentity.Sandbox = testApplicationSandboxPlanV1(2000, 2000) + matched, err = CurrentBuildMatchesRuntimeV1(current, changedIdentity) + if err != nil || matched { + t.Fatalf("changed runtime identity = %v, %v", matched, err) + } changed := buildInput.Document changed.Environment.Base.Image = "debian:13" diff --git a/internal/probe/local_account_linux.go b/internal/probe/local_account_linux.go new file mode 100644 index 00000000..be26b718 --- /dev/null +++ b/internal/probe/local_account_linux.go @@ -0,0 +1,149 @@ +//go:build linux + +package probe + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + applicationPasswdPath = "/etc/passwd" + applicationGroupPath = "/etc/group" +) + +func installApplicationLocalAccount(name string, uid string, gid string, home string) error { + return installLocalAccountFiles(name, uid, gid, home, applicationPasswdPath, applicationGroupPath) +} + +func installLocalAccountFiles(name string, uid string, gid string, home string, passwdPath string, groupPath string) error { + if err := validateLocalAccountInput(name, uid, gid, home); err != nil { + return err + } + passwd, err := rewriteLocalAccountFile(passwdPath, name, uid, 2, name+":x:"+uid+":"+gid+"::"+home+":/sbin/nologin") + if err != nil { + return fmt.Errorf("prepare passwd entry: %w", err) + } + group, err := rewriteLocalAccountFile(groupPath, name, gid, 2, name+":x:"+gid+":") + if err != nil { + return fmt.Errorf("prepare group entry: %w", err) + } + if err := writeLocalAccountFile(passwdPath, passwd); err != nil { + return fmt.Errorf("write passwd entry: %w", err) + } + if err := writeLocalAccountFile(groupPath, group); err != nil { + return fmt.Errorf("write group entry: %w", err) + } + return nil +} + +func validateLocalAccountInput(name string, uid string, gid string, home string) error { + if name == "" || len(name) > 32 { + return fmt.Errorf("local account name is invalid") + } + for index, character := range name { + if character >= 'a' && character <= 'z' || character == '_' && index == 0 || + index > 0 && (character >= '0' && character <= '9' || character == '_' || character == '-') { + continue + } + return fmt.Errorf("local account name is invalid") + } + parsedUID, err := strconv.ParseUint(uid, 10, 32) + if err != nil || strconv.FormatUint(parsedUID, 10) != uid { + return fmt.Errorf("local account UID is invalid") + } + if parsedUID == 0 && name != "root" || parsedUID != 0 && name == "root" { + return fmt.Errorf("local account root name and UID disagree") + } + parsedGID, err := strconv.ParseUint(gid, 10, 32) + if err != nil || strconv.FormatUint(parsedGID, 10) != gid { + return fmt.Errorf("local account GID is invalid") + } + if parsedUID == 0 && parsedGID != 0 || parsedUID != 0 && parsedGID == 0 { + return fmt.Errorf("local account root identity and GID disagree") + } + if home == "" || !filepath.IsAbs(home) || filepath.Clean(home) != home || strings.ContainsAny(home, ":\n\r") { + return fmt.Errorf("local account home is invalid") + } + return nil +} + +func rewriteLocalAccountFile(path string, name string, id string, idField int, replacement string) ([]byte, error) { + content, err := readLocalAccountFile(path) + if err != nil { + return nil, err + } + lines := strings.Split(strings.TrimSuffix(string(content), "\n"), "\n") + kept := make([]string, 0, len(lines)+1) + kept = append(kept, replacement) + for _, line := range lines { + if line == "" { + continue + } + fields := strings.Split(line, ":") + if len(fields) <= idField { + return nil, fmt.Errorf("%s contains a malformed entry", path) + } + if fields[0] != name { + kept = append(kept, line) + continue + } + if fields[idField] != id { + return nil, fmt.Errorf("%s already defines local account name %q with ID %s", path, name, fields[idField]) + } + } + return []byte(strings.Join(kept, "\n") + "\n"), nil +} + +func readLocalAccountFile(path string) ([]byte, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return []byte{}, nil + } + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() { + return nil, fmt.Errorf("%s must be a regular file", path) + } + return os.ReadFile(path) +} + +func writeLocalAccountFile(path string, content []byte) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + temporary, err := os.CreateTemp(filepath.Dir(path), ".reploy-account-*") + if err != nil { + return err + } + temporaryPath := temporary.Name() + remove := true + defer func() { + _ = temporary.Close() + if remove { + _ = os.Remove(temporaryPath) + } + }() + if err := temporary.Chmod(0o644); err != nil { + return err + } + if _, err := temporary.Write(content); err != nil { + return err + } + if err := temporary.Sync(); err != nil { + return err + } + if err := temporary.Close(); err != nil { + return err + } + if err := os.Rename(temporaryPath, path); err != nil { + return err + } + remove = false + return nil +} diff --git a/internal/probe/local_account_linux_test.go b/internal/probe/local_account_linux_test.go new file mode 100644 index 00000000..d612bfd3 --- /dev/null +++ b/internal/probe/local_account_linux_test.go @@ -0,0 +1,101 @@ +//go:build linux + +package probe + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestInstallLocalAccountFilesPrependsResolvableAccountAndPreservesAliases(t *testing.T) { + root := t.TempDir() + passwd := filepath.Join(root, "passwd") + group := filepath.Join(root, "group") + if err := os.WriteFile(passwd, []byte("root:x:0:0:root:/root:/bin/sh\nnode:x:1000:1000::/home/node:/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(group, []byte("root:x:0:\nnode:x:1000:\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := installLocalAccountFiles("reploy", "1000", "1000", "/mnt/reploy-home", passwd, group); err != nil { + t.Fatal(err) + } + passwdContent, err := os.ReadFile(passwd) + if err != nil { + t.Fatal(err) + } + groupContent, err := os.ReadFile(group) + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(string(passwdContent), "reploy:x:1000:1000::/mnt/reploy-home:/sbin/nologin\n") || + !strings.Contains(string(passwdContent), "node:x:1000:1000:") { + t.Fatalf("passwd = %q", passwdContent) + } + if !strings.HasPrefix(string(groupContent), "reploy:x:1000:\n") || !strings.Contains(string(groupContent), "node:x:1000:") { + t.Fatalf("group = %q", groupContent) + } +} + +func TestInstallLocalAccountFilesCreatesMissingDatabaseFiles(t *testing.T) { + root := t.TempDir() + passwd := filepath.Join(root, "etc", "passwd") + group := filepath.Join(root, "etc", "group") + if err := installLocalAccountFiles("reploy", "12345", "12345", "/mnt/reploy-home", passwd, group); err != nil { + t.Fatal(err) + } + for _, path := range []string{passwd, group} { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() || info.Mode().Perm() != 0o644 { + t.Fatalf("local account file %s = %#v, %v", path, info, err) + } + } +} + +func TestInstallLocalAccountFilesRejectsNameCollisionAndSpecialDatabase(t *testing.T) { + root := t.TempDir() + passwd := filepath.Join(root, "passwd") + group := filepath.Join(root, "group") + if err := os.WriteFile(passwd, []byte("reploy:x:44:44::/home/reploy:/bin/sh\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(group, []byte("root:x:0:\n"), 0o644); err != nil { + t.Fatal(err) + } + err := installLocalAccountFiles("reploy", "1000", "1000", "/mnt/reploy-home", passwd, group) + if err == nil || !strings.Contains(err.Error(), "already defines") { + t.Fatalf("name collision error = %v", err) + } + if err := os.Remove(passwd); err != nil { + t.Fatal(err) + } + if err := os.Symlink("target", passwd); err != nil { + t.Fatal(err) + } + err = installLocalAccountFiles("reploy", "1000", "1000", "/mnt/reploy-home", passwd, group) + if err == nil || !strings.Contains(err.Error(), "regular file") { + t.Fatalf("special passwd error = %v", err) + } +} + +func TestInstallLocalAccountFilesRejectsPrivilegedGroupMismatch(t *testing.T) { + root := t.TempDir() + for _, account := range []struct { + name string + uid string + gid string + }{ + {name: "reploy", uid: "1000", gid: "0"}, + {name: "root", uid: "0", gid: "1000"}, + } { + err := installLocalAccountFiles( + account.name, account.uid, account.gid, "/mnt/reploy-home", + filepath.Join(root, account.name+"-passwd"), filepath.Join(root, account.name+"-group"), + ) + if err == nil || !strings.Contains(err.Error(), "GID disagree") { + t.Fatalf("account %#v error = %v", account, err) + } + } +} diff --git a/internal/probe/local_account_unsupported.go b/internal/probe/local_account_unsupported.go new file mode 100644 index 00000000..93a42839 --- /dev/null +++ b/internal/probe/local_account_unsupported.go @@ -0,0 +1,9 @@ +//go:build !linux + +package probe + +import "fmt" + +func installApplicationLocalAccount(string, string, string, string) error { + return fmt.Errorf("local application accounts are unsupported on this target OS") +} diff --git a/internal/probe/main.go b/internal/probe/main.go index c598550b..58bfdf7a 100644 --- a/internal/probe/main.go +++ b/internal/probe/main.go @@ -43,6 +43,13 @@ func mainWithActions( readKernelStatus func() ([]byte, error), execApplication func([]string) error, ) int { + if len(args) == 5 && args[0] == "install-local-account" { + if err := installApplicationLocalAccount(args[1], args[2], args[3], args[4]); err != nil { + _, _ = fmt.Fprintf(stderr, "reploy-probe: install local account: %v\n", err) + return 1 + } + return 0 + } if len(args) >= 1 && args[0] == "verify-exec" { if len(args) < 3 || args[1] != "--" { _, _ = fmt.Fprintln(stderr, "reploy-probe: verify-exec requires -- followed by an absolute application command") @@ -69,7 +76,7 @@ func mainWithActions( 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 verify-exec mode") + _, _ = fmt.Fprintln(stderr, "reploy-probe accepts no arguments for one canonical stdin request, fixed hold mode, fixed copy-volume-tree mode, fixed install-local-account mode, or fixed verify-exec mode") return 2 } content, err := io.ReadAll(stdin)