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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/guides/native-candidate.md
Original file line number Diff line number Diff line change
Expand Up @@ -2538,6 +2538,27 @@ checks. Named users/groups remain unsupported for private delivery and refuse
instead of silently becoming root. An image with no user retains Docker's normal
root default; an explicit Compose numeric user takes precedence.

Services with private environment values and services with host dependency
bindings (including every service when project-wide host aliases exist) run their
process through a wrapper. The wrapper runs exactly what Docker would: the image
or Compose `entrypoint` followed by `command` (with no entrypoint, `command`
alone), and a bare executable name such as `docker-entrypoint.sh` or the
`mysqladmin` of `CMD mysqladmin ping` is resolved through the container's own
PATH after the environment is applied. A shell-form health check (`CMD-SHELL`
or a string `test`) runs through the image's shell (`/bin/sh -c` by default), as
Docker runs it. Stock images therefore need no absolute paths. What a wrapper
still cannot run is an empty process, a health test that names no executable, a
named (non-numeric) user, a stop signal other than SIGTERM, SIGINT, SIGQUIT or
SIGKILL, and a volume target at `/`, `/run` or a reserved wrapper path. Native
`up` refuses the Compose-declared cases before the VM starts, and the runtime
checks settings inherited from the pinned image before reserving anything. Both
name every refused service and field at once (for example
`service db user (use a numeric UID or UID:GID)`). `hack doctor` on the native
backend reports the Compose-declared cases in its `native process wrappers`
check, for every service native may wrap. Only profile-active services are
checked by `up` and `restart`; a service behind an unselected profile never runs
and cannot block them, so `doctor` lists such services as information only.

### Approved-host DNS on pool restart

The pinned Smol provider re-resolves approved hosts at each boot and appends the
Expand Down
11 changes: 7 additions & 4 deletions packages/relay-guest/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,15 +136,18 @@ shutdown and absence of surviving workers or descriptors.
The Linux-only gate modes do not read stdin or install signal handlers:

```text
hack-relay-guest --await-release <32 lowercase hex generation> -- <absolute executable> [args...]
hack-relay-guest --check-release <32 lowercase hex generation> -- <absolute executable> [args...]
hack-relay-guest --await-release <32 lowercase hex generation> -- <executable> [args...]
hack-relay-guest --check-release <32 lowercase hex generation> -- <executable> [args...]
```

Await mode watches `/run/hack-startup` with inotify before checking for `release`,
then waits on events with one anchored 120-second monotonic deadline. Check mode
checks once and refuses immediately while the marker is absent. A valid release
executes the supplied absolute executable in place, preserving arguments (including
non-UTF-8 arguments), environment, uid/gid and standard descriptors. Observation
executes the supplied executable in place, preserving arguments (including
non-UTF-8 arguments), environment, uid/gid and standard descriptors. Like Docker,
a name containing `/` runs as given and a bare name (such as
`docker-entrypoint.sh`) is resolved through the container's own PATH; no shell
runs and no host PATH is consulted. Observation
FDs close before exec. Graph launch must use Docker `Init=true` so normal init
signal forwarding applies while waiting and after replacement.

Expand Down
11 changes: 9 additions & 2 deletions packages/relay-guest/src/barrier.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! A public generation fence, not a credential or authentication boundary.
use crate::CandidateError;
use std::{ffi::OsString, os::unix::process::CommandExt, path::Path, process::Command};
use std::{ffi::OsString, os::unix::process::CommandExt, process::Command};

fn refused() -> CandidateError {
CandidateError::new("relay_guest_barrier", "Guest startup release refused.")
Expand All @@ -18,7 +18,14 @@ pub(super) fn valid(args: &[OsString]) -> bool {
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
})
&& args[2] == "--"
&& Path::new(&args[3]).is_absolute()
&& executable(&args[3])
}
/// Docker accepts an absolute path, a path relative to the working directory,
/// or a bare name it resolves through the container's PATH. `Command` performs
/// that same lookup with this process's (the container's) PATH; no shell runs.
fn executable(name: &OsString) -> bool {
let bytes = name.as_encoded_bytes();
!bytes.is_empty() && bytes != b"/" && !bytes.contains(&0)
}
pub(super) fn run(args: &[OsString]) -> Result<(), CandidateError> {
if !valid(args) {
Expand Down
2 changes: 1 addition & 1 deletion packages/relay-guest/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ use std::{
time::Duration,
};

const USAGE: &str = "hack-relay-guest --slot <0..31> --application-fd <3..2147483647>\nhack-relay-guest --slot <0..31> --listen-port <1..65535> [--listen-address <slot-loopback>]\nhack-relay-guest --await-release <generation> -- <absolute executable> [args...]\nhack-relay-guest --check-release <generation> -- <absolute executable> [args...]\nRelay credential: one private stdin descriptor. Listener address is 127.0.0.1 or 127.0.0.(slot+2).\n";
const USAGE: &str = "hack-relay-guest --slot <0..31> --application-fd <3..2147483647>\nhack-relay-guest --slot <0..31> --listen-port <1..65535> [--listen-address <slot-loopback>]\nhack-relay-guest --await-release <generation> -- <executable> [args...]\nhack-relay-guest --check-release <generation> -- <executable> [args...]\nRelay credential: one private stdin descriptor. Listener address is 127.0.0.1 or 127.0.0.(slot+2).\n";
mod options;
use options::{Mode, Options, options};
fn credential() -> Result<relay_auth::Credential, CandidateError> {
Expand Down
12 changes: 10 additions & 2 deletions packages/runtime-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -996,8 +996,16 @@ bound read-only at their targets. A compiled mount or tmpfs target that equals,
contains or sits under a member target refuses before effects. Members carry no
separate deadline: they live as long as the allocation, which is the container's, and
are retired with it by the existing allocation cleanup. A service with values keeps the
launcher contract (numeric user, absolute entrypoint, `CMD` health with an absolute
executable). A files-only service keeps its own process and needs only a numeric image
launcher contract: a numeric user, a non-empty selected process and an exec-form
`CMD` health test. The selected process is the image or Compose `Entrypoint ++ Cmd`
(with no entrypoint, `Cmd` alone), and a `CMD-SHELL` test becomes the exec form
Docker runs (the image `SHELL`, default `/bin/sh -c`). Executables may be bare
names such as `docker-entrypoint.sh`: the launcher resolves them through the
container's PATH after applying the environment, as Docker does. Host-dependency
services get the same materialized process behind their release barrier. Every
wrapped service is checked before any runtime state is reserved; a refusal keeps
its code and lists each service and closed field (`entrypoint`, `healthcheck`,
`user`, `stop_signal`, `mounts`) in `process_refusals`. A files-only service keeps its own process and needs only a numeric image
or Compose user. Live-owner restore and normalized serve-restore deliver fresh members
into new allocations. `graph exec` and one-off runs refuse envelopes with files; files
already in a running container are never re-delivered or renewed.
Expand Down
15 changes: 8 additions & 7 deletions packages/runtime-core/guest/environment-launcher.zig
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,18 @@ fn freshValues(value: std.json.Value, started: u64, now: u64) !std.json.ObjectMa
}
return values.object;
}
/// Resolve a user exec in this container after applying its environment. Never
/// consult a host PATH or start a shell. Startup and health still require an
/// absolute executable and do not call this function.
/// Exec the selected process in this container after applying its environment,
/// as Docker does: a name containing `/` is executed as given (relative to the
/// working directory), and a bare name is resolved through the container's PATH
/// (Docker's default PATH when none is set). Never consult a host PATH or start
/// a shell. Startup, health and exec all use this.
fn execCommand(args: []const [:0]u8, env: *const std.process.EnvMap) !void {
if (args.len == 0 or args[0].len == 0) return error.Arguments;
const command = args[0];
if (std.mem.indexOfScalar(u8, command, '/') != null) {
return std.process.execve(allocator, args, env);
}
const path = env.get("PATH") orelse "/usr/local/bin:/bin:/usr/bin";
const path = env.get("PATH") orelse "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
if (path.len > 8192) return error.Arguments;
const argv = try allocator.alloc([]const u8, args.len);
defer allocator.free(argv);
Expand Down Expand Up @@ -120,7 +122,7 @@ fn run() !void {
try std.posix.dup2(null_fd, 2);
}
const args = if (health or mounted_exec) raw[1..] else raw;
if (args.len < 4 or args.len > 4100 or (!mounted_exec and !std.fs.path.isAbsolute(args[3]))) return error.Arguments;
if (args.len < 4 or args.len > 4100 or args[3].len == 0) return error.Arguments;
const payload = try privateFile(args[1], 32768);
const expiry = try privateFile(args[2], 32);
const deadline = try std.fmt.parseInt(u64, expiry, 10);
Expand All @@ -135,8 +137,7 @@ fn run() !void {
const now = try std.posix.clock_gettime(.BOOTTIME);
if (now.sec < 0 or @as(u64, @intCast(now.sec)) >= deadline) return error.Expired;
// Successful exec replaces this process: no resident wrapper and no signal forwarding shim.
if (mounted_exec) return execCommand(args[3..], &env);
return std.process.execve(allocator, args[3..], &env);
return execCommand(args[3..], &env);
}
pub fn main() void {
run() catch {
Expand Down
38 changes: 38 additions & 0 deletions packages/runtime-core/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,39 @@ pub struct StartupLogsDiagnostic {
pub services: Vec<StartupLogTail>,
}

/// The container setting a private process wrapper could not run, named so a
/// refusal points at the Compose or image field to change. A closed vocabulary.
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProcessField {
/// The selected process: Compose `entrypoint`/`command` or the image's.
Entrypoint,
/// The selected health check test.
Healthcheck,
/// The selected user (Compose `user` or the image's).
User,
/// Compose `stop_signal`.
StopSignal,
/// A mount target overlapping reserved wrapper paths.
Mounts,
}

/// Value-free: admitted service names and closed fields only, never argv,
/// paths, users or other configuration values.
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProcessRefusal {
pub service: String,
pub field: ProcessField,
}

#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct ProcessRefusalsDiagnostic {
pub version: u8,
pub refusals: Vec<ProcessRefusal>,
}

#[derive(Debug, Serialize)]
pub struct CandidateError {
pub code: &'static str,
Expand All @@ -66,6 +99,10 @@ pub struct CandidateError {
/// [`StartupLogTail`]. Never persist an error carrying it.
#[serde(skip_serializing_if = "Option::is_none")]
pub startup_logs: Option<Box<StartupLogsDiagnostic>>,
/// Every service whose selected process a private wrapper refused, found in
/// one pass before any runtime state is reserved.
#[serde(skip_serializing_if = "Option::is_none")]
pub process_refusals: Option<Box<ProcessRefusalsDiagnostic>>,
}

impl CandidateError {
Expand All @@ -76,6 +113,7 @@ impl CandidateError {
cause_code: None,
stop_failures: None,
startup_logs: None,
process_refusals: None,
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/runtime-core/src/provider/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ impl PendingEnvironment {
pub(super) fn service(&self) -> &str {
&self.service
}
/// Whether launcher admission (absolute entrypoint, CMD health) applies.
/// Whether launcher admission (selected process, exec-form CMD health) applies.
pub(super) fn wraps_process(&self) -> bool {
self.wraps
}
Expand Down
Loading
Loading