diff --git a/docs/guides/native-candidate.md b/docs/guides/native-candidate.md index 25cc9811a..f4c88b67f 100644 --- a/docs/guides/native-candidate.md +++ b/docs/guides/native-candidate.md @@ -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 diff --git a/packages/relay-guest/README.md b/packages/relay-guest/README.md index d3a78ea81..0553b549c 100644 --- a/packages/relay-guest/README.md +++ b/packages/relay-guest/README.md @@ -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> -- [args...] -hack-relay-guest --check-release <32 lowercase hex generation> -- [args...] +hack-relay-guest --await-release <32 lowercase hex generation> -- [args...] +hack-relay-guest --check-release <32 lowercase hex generation> -- [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. diff --git a/packages/relay-guest/src/barrier.rs b/packages/relay-guest/src/barrier.rs index 7333c9b5c..13211c745 100644 --- a/packages/relay-guest/src/barrier.rs +++ b/packages/relay-guest/src/barrier.rs @@ -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.") @@ -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) { diff --git a/packages/relay-guest/src/main.rs b/packages/relay-guest/src/main.rs index 147a38f23..9df4262d5 100644 --- a/packages/relay-guest/src/main.rs +++ b/packages/relay-guest/src/main.rs @@ -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 ]\nhack-relay-guest --await-release -- [args...]\nhack-relay-guest --check-release -- [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 ]\nhack-relay-guest --await-release -- [args...]\nhack-relay-guest --check-release -- [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 { diff --git a/packages/runtime-core/README.md b/packages/runtime-core/README.md index e7abf4bcd..87dfbe345 100644 --- a/packages/runtime-core/README.md +++ b/packages/runtime-core/README.md @@ -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. diff --git a/packages/runtime-core/guest/environment-launcher.zig b/packages/runtime-core/guest/environment-launcher.zig index b63b04e89..64f0008db 100644 --- a/packages/runtime-core/guest/environment-launcher.zig +++ b/packages/runtime-core/guest/environment-launcher.zig @@ -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); @@ -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); @@ -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 { diff --git a/packages/runtime-core/src/error.rs b/packages/runtime-core/src/error.rs index 2e9f3132b..ad15b8dc9 100644 --- a/packages/runtime-core/src/error.rs +++ b/packages/runtime-core/src/error.rs @@ -53,6 +53,39 @@ pub struct StartupLogsDiagnostic { pub services: Vec, } +/// 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, +} + #[derive(Debug, Serialize)] pub struct CandidateError { pub code: &'static str, @@ -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>, + /// 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>, } impl CandidateError { @@ -76,6 +113,7 @@ impl CandidateError { cause_code: None, stop_failures: None, startup_logs: None, + process_refusals: None, } } diff --git a/packages/runtime-core/src/provider/environment.rs b/packages/runtime-core/src/provider/environment.rs index 5f565a1f2..9ca05fde2 100644 --- a/packages/runtime-core/src/provider/environment.rs +++ b/packages/runtime-core/src/provider/environment.rs @@ -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 } diff --git a/packages/runtime-core/src/provider/graph/image_process.rs b/packages/runtime-core/src/provider/graph/image_process.rs index 4e9fd6c25..31c10fdb4 100644 --- a/packages/runtime-core/src/provider/graph/image_process.rs +++ b/packages/runtime-core/src/provider/graph/image_process.rs @@ -1,8 +1,12 @@ //! Process defaults from an already verified immutable image, before wrappers. //! Compose null inherits; explicit entrypoint (including empty) suppresses image -//! CMD. No shell tokenization, PATH lookup or image selection occurs here. +//! CMD. No shell tokenization, PATH lookup or image selection occurs here: a +//! relative executable name stays relative and the wrapper resolves it through +//! the container's own PATH at exec time, as Docker does. //! See https://docs.docker.com/reference/compose-file/services/#entrypoint. +use super::process_refusals::Refused; use super::{CandidateError, error}; +use crate::error::ProcessField; use serde_json::{Value, json}; const MAX_ARGUMENTS: usize = 4096; @@ -78,47 +82,122 @@ pub(super) fn resolve(image: &Value, create: &Value) -> Result Result { +fn selected_user(image: &Value, create: &Value) -> Result { fn nonempty(value: Option<&Value>) -> Option<&Value> { value.filter(|value| !value.is_null() && value.as_str() != Some("")) } let user = nonempty(create.get("User")).or_else(|| nonempty(image.get("User"))); let user = match user { None => "", - Some(value) => value.as_str().ok_or_else(malformed)?, + Some(value) => value.as_str().ok_or_else(|| Refused { + field: ProcessField::User, + error: malformed(), + })?, }; - super::launcher::identity(&json!({"User":user}))?; + super::launcher::identity(&json!({"User":user})).map_err(|error| Refused { + field: ProcessField::User, + error, + })?; Ok(user.into()) } + +/// Docker runs a `CMD-SHELL` health check as the image's `SHELL` (default +/// `/bin/sh -c`) followed by the test text. Express exactly that as an exec-form +/// `CMD` test, which a wrapper can then prefix. Other tests are unchanged. +fn exec_form_health(create: &Value, image: &Value) -> Result, Refused> { + let refused = || { + Refused::new( + ProcessField::Healthcheck, + "environment_health", + "Private delivery requires a bounded health check test; values omitted.", + ) + }; + let Some(test) = create["Healthcheck"]["Test"].as_array() else { + return Ok(None); + }; + if test.first().and_then(Value::as_str) != Some("CMD-SHELL") { + return Ok(None); + } + let [_, Value::String(command)] = test.as_slice() else { + return Err(refused()); + }; + if command.is_empty() || command.contains('\0') || command.len() > MAX_BYTES { + return Err(refused()); + } + let shell: Vec = match image.get("Shell") { + None | Some(Value::Null) => vec!["/bin/sh".into(), "-c".into()], + Some(Value::Array(values)) + if !values.is_empty() + && values.len() <= 16 + && values.iter().all(|value| { + value + .as_str() + .is_some_and(|text| !text.is_empty() && !text.contains('\0')) + }) => + { + values + .iter() + .filter_map(|value| value.as_str().map(str::to_owned)) + .collect() + } + Some(_) => return Err(refused()), + }; + let mut exec = vec![json!("CMD")]; + exec.extend(shell.into_iter().map(Value::String)); + exec.push(json!(command)); + Ok(Some(json!(exec))) +} /// Materialize exactly the selected process for a private environment/dependency -/// wrapper. All validation precedes mutation. Relative executable names remain -/// unsupported here; private launch must never add ambient PATH resolution. -/// An empty entrypoint is preserved by `resolve` but refused by this adapter, -/// even when Cmd alone names an absolute executable (a separate argv-only gate). -pub(super) fn apply_private(create: &mut Value, image: &Value) -> Result<(), CandidateError> { - let process = resolve(image, create)?; - if !process - .entrypoint - .first() - .is_some_and(|name| name.starts_with('/') && name != "/") - { - return Err(error( +/// wrapper. All validation precedes mutation. +/// +/// Docker runs `Entrypoint ++ Cmd`; with no entrypoint, `Cmd` alone is the +/// process, so it becomes the materialized entrypoint (the same argv). An +/// executable name without `/` (such as `docker-entrypoint.sh`) is kept as is: +/// the wrapper resolves it through the container's PATH at exec time exactly as +/// Docker does, never through a host or wrapper PATH. A `CMD-SHELL` health check +/// becomes the equivalent exec-form test. Only an empty process refuses. +pub(super) fn apply_private(create: &mut Value, image: &Value) -> Result<(), Refused> { + let process = resolve(image, create).map_err(|error| Refused { + field: ProcessField::Entrypoint, + error, + })?; + let (entrypoint, cmd) = if process.entrypoint.is_empty() { + (process.cmd, Vec::new()) + } else { + (process.entrypoint, process.cmd) + }; + if !entrypoint.first().is_some_and(|name| executable(name)) { + return Err(Refused::new( + ProcessField::Entrypoint, "environment_entrypoint", - "Private delivery requires a pinned absolute entrypoint; values omitted.", + "Private delivery requires an entrypoint or command that names an executable; values omitted.", )); } let user = selected_user(image, create)?; + let health = exec_form_health(create, image)?; create["User"] = json!(user); - create["Entrypoint"] = json!(process.entrypoint); - create["Cmd"] = json!(process.cmd); + create["Entrypoint"] = json!(entrypoint); + create["Cmd"] = json!(cmd); + if let Some(test) = health { + create["Healthcheck"]["Test"] = test; + } Ok(()) } +/// An exec target Docker would accept: absolute, relative to the working +/// directory, or a bare name resolved through the container's PATH. +pub(super) fn executable(name: &str) -> bool { + !name.is_empty() && name != "/" && !name.contains('\0') +} + /// Files-only private delivery keeps the selected process unchanged and only pins the /// numeric owner that staged mode-0400 members must match. Validation precedes mutation. -pub(super) fn apply_file_owner(create: &mut Value, image: &Value) -> Result<(), CandidateError> { +pub(super) fn apply_file_owner(create: &mut Value, image: &Value) -> Result<(), Refused> { if !image.is_object() || !create.is_object() { - return Err(malformed()); + return Err(Refused { + field: ProcessField::User, + error: malformed(), + }); } let user = selected_user(image, create)?; create["User"] = json!(user); @@ -219,23 +298,98 @@ mod tests { } #[test] - fn refuses_path_lookup_or_empty_entry_without_partial_mutation() { - for image in [ - json!({"Entrypoint":["relative"],"Cmd":[]}), - json!({"Entrypoint":null,"Cmd":["/absolute"]}), + fn relative_stock_entrypoints_stay_relative_for_container_path_lookup() { + // A stock image such as `ENTRYPOINT ["docker-entrypoint.sh"]` keeps its + // exact argv; the wrapper resolves the name through the container PATH. + let image = json!({"Entrypoint":["docker-entrypoint.sh"],"Cmd":["mysqld"]}); + let mut create = json!({}); + apply_private(&mut create, &image).unwrap(); + assert_eq!(create["Entrypoint"], json!(["docker-entrypoint.sh"])); + assert_eq!(create["Cmd"], json!(["mysqld"])); + let mut relative = json!({"Entrypoint":["./bin/start"],"Cmd":null}); + apply_private(&mut relative, &json!({})).unwrap(); + assert_eq!(relative["Entrypoint"], json!(["./bin/start"])); + } + + #[test] + fn command_only_process_is_the_same_argv_as_docker_runs() { + for (image, create, argv) in [ + ( + json!({"Cmd":["python3","-m","http.server"]}), + json!({}), + json!(["python3", "-m", "http.server"]), + ), + ( + json!({"Entrypoint":["/image"]}), + json!({"Entrypoint":[],"Cmd":["/explicit","a"]}), + json!(["/explicit", "a"]), + ), + ( + json!({"Entrypoint":null,"Cmd":["/absolute"]}), + json!({}), + json!(["/absolute"]), + ), ] { - let mut create = json!({}); + let mut create = create; + apply_private(&mut create, &image).unwrap(); + assert_eq!(create["Entrypoint"], argv); + assert_eq!(create["Cmd"], json!([])); + } + } + + #[test] + fn refuses_an_empty_process_without_partial_mutation() { + for (image, create) in [ + (json!({"Entrypoint":null,"Cmd":null}), json!({})), + (json!({"Entrypoint":["/image"]}), json!({"Entrypoint":[]})), + (json!({"Entrypoint":[""],"Cmd":["x"]}), json!({})), + (json!({"Entrypoint":["/"]}), json!({})), + ] { + let mut create = create; let before = create.clone(); - assert_eq!( - apply_private(&mut create, &image).unwrap_err().code, - "environment_entrypoint" - ); + let refused = apply_private(&mut create, &image).unwrap_err(); + assert_eq!(refused.error.code, "environment_entrypoint"); + assert_eq!(refused.field, ProcessField::Entrypoint); + assert_eq!(create, before); + } + } + + #[test] + fn shell_form_health_becomes_the_exec_form_docker_runs() { + let image = json!({"Entrypoint":["docker-entrypoint.sh"]}); + let mut create = json!({"Healthcheck":{"Test":["CMD-SHELL","mysqladmin ping -h localhost"],"Retries":3}}); + apply_private(&mut create, &image).unwrap(); + assert_eq!( + create["Healthcheck"], + json!({"Test":["CMD","/bin/sh","-c","mysqladmin ping -h localhost"],"Retries":3}) + ); + let shell = json!({"Entrypoint":["/e"],"Shell":["/bin/bash","-o","pipefail","-c"]}); + let mut create = json!({"Healthcheck":{"Test":["CMD-SHELL","true"]}}); + apply_private(&mut create, &shell).unwrap(); + assert_eq!( + create["Healthcheck"]["Test"], + json!(["CMD", "/bin/bash", "-o", "pipefail", "-c", "true"]) + ); + // Exec-form tests, including relative names, are unchanged. + let mut create = json!({"Healthcheck":{"Test":["CMD","mysqladmin","ping"]}}); + apply_private(&mut create, &image).unwrap(); + assert_eq!( + create["Healthcheck"]["Test"], + json!(["CMD", "mysqladmin", "ping"]) + ); + for test in [ + json!(["CMD-SHELL"]), + json!(["CMD-SHELL", ""]), + json!(["CMD-SHELL", "a", "b"]), + ] { + let mut create = json!({"Healthcheck":{"Test":test}}); + let before = create.clone(); + let refused = apply_private(&mut create, &image).unwrap_err(); + assert_eq!(refused.field, ProcessField::Healthcheck); assert_eq!(create, before); } - let mut create = json!({"Entrypoint":[],"Cmd":["/explicit"]}); - let before = create.clone(); - assert!(apply_private(&mut create, &json!({"Entrypoint":["/image"]})).is_err()); - assert_eq!(create, before); + let mut create = json!({"Healthcheck":{"Test":["CMD-SHELL","true"]}}); + assert!(apply_private(&mut create, &json!({"Entrypoint":["/e"],"Shell":"sh"})).is_err()); } #[test] diff --git a/packages/runtime-core/src/provider/graph/launcher.rs b/packages/runtime-core/src/provider/graph/launcher.rs index 9d6a5c104..1baff55c7 100644 --- a/packages/runtime-core/src/provider/graph/launcher.rs +++ b/packages/runtime-core/src/provider/graph/launcher.rs @@ -1,5 +1,7 @@ //! Feature-gated static launcher; no resident wrapper or runtime credential provider. +use super::process_refusals::Refused; use super::*; +use crate::error::ProcessField; #[cfg(feature = "environment-launcher")] use base64::Engine as _; #[cfg(feature = "environment-launcher")] @@ -99,25 +101,40 @@ pub(super) fn identity(config: &Value) -> Result<(u32, u32), CandidateError> { Ok((parse(uid)?, parse(gid)?)) } -pub(super) fn validate(config: &Value) -> Result<(), CandidateError> { - identity(config)?; - let entry = config["Entrypoint"] +/// Wrapper admission for one materialized process (see +/// `image_process::apply_private`). Executables may be absolute, relative to +/// the working directory, or bare names: the wrapper resolves a bare name +/// through the container's PATH at exec time, as Docker does. Each refusal +/// names the field it concerns. +pub(super) fn validate(config: &Value) -> Result<(), Refused> { + identity(config).map_err(|error| Refused { + field: ProcessField::User, + error, + })?; + let executable = config["Entrypoint"] .as_array() - .filter(|a| !a.is_empty()) - .ok_or_else(|| { - error( - "environment_entrypoint", - "Environment delivery requires an explicit absolute entrypoint.", - ) - })?; - if !entry[0].as_str().is_some_and(|s| s.starts_with('/')) - || config["StopSignal"] - .as_str() - .is_some_and(|s| !["SIGTERM", "SIGINT", "SIGQUIT", "SIGKILL"].contains(&s)) + .and_then(|entry| entry.first()) + .and_then(Value::as_str) + .is_some_and(super::image_process::executable); + if !executable + || !config["Entrypoint"] + .as_array() + .is_some_and(|entry| entry.iter().all(Value::is_string)) { - return Err(error( + return Err(Refused::new( + ProcessField::Entrypoint, "environment_entrypoint", - "Environment delivery requires an absolute entrypoint ; unsupported stop signals remain gated.", + "Environment delivery requires an explicit entrypoint that names an executable.", + )); + } + if config["StopSignal"] + .as_str() + .is_some_and(|s| !["SIGTERM", "SIGINT", "SIGQUIT", "SIGKILL"].contains(&s)) + { + return Err(Refused::new( + ProcessField::StopSignal, + "environment_entrypoint", + "Environment delivery supports only SIGTERM, SIGINT, SIGQUIT or SIGKILL stop signals.", )); } validate_health(config)?; @@ -128,7 +145,8 @@ pub(super) fn validate(config: &Value) -> Result<(), CandidateError> { { let target = mount["Target"].as_str().unwrap_or(""); if ["/run", "/"].contains(&target) || target.starts_with("/run/hack-environment") { - return Err(error( + return Err(Refused::new( + ProcessField::Mounts, "environment_mount", "A mount overlaps reserved environment paths.", )); @@ -136,12 +154,13 @@ pub(super) fn validate(config: &Value) -> Result<(), CandidateError> { } Ok(()) } -fn validate_health(config: &Value) -> Result<(), CandidateError> { +fn validate_health(config: &Value) -> Result<(), Refused> { let Some(health) = config.get("Healthcheck") else { return Ok(()); }; let test = health["Test"].as_array().ok_or_else(|| { - error( + Refused::new( + ProcessField::Healthcheck, "environment_health", "An explicit CMD health check is required.", ) @@ -151,18 +170,21 @@ fn validate_health(config: &Value) -> Result<(), CandidateError> { } if test.len() < 2 || test[0] != "CMD" - || !test[1].as_str().is_some_and(|s| s.starts_with('/')) + || !test[1] + .as_str() + .is_some_and(super::image_process::executable) || !test.iter().all(Value::is_string) { - return Err(error( + return Err(Refused::new( + ProcessField::Healthcheck, "environment_health", - "Environment health delivery requires CMD with an absolute executable.", + "Environment health delivery requires an exec-form CMD test that names an executable.", )); } Ok(()) } pub(super) fn attach(config: &mut Value, path: &str, launcher: &str) -> Result<(), CandidateError> { - validate(config)?; + validate(config).map_err(Refused::into_error)?; // Preserve Docker's numeric UID-only group lookup and supplemental groups. identity(config)?; if config["Healthcheck"]["Test"][0] == "CMD" { @@ -339,16 +361,50 @@ mod tests { assert_eq!(value["Healthcheck"]["Interval"], 1000000000); assert_eq!(value["Healthcheck"]["Timeout"], 2000000000); assert_eq!(value["Healthcheck"]["Retries"], 3); + // apply_private turns CMD-SHELL into the exec form Docker runs first. for test in [ json!(["CMD-SHELL", "echo ok"]), json!(["CMD"]), json!(["CMD", "/bin/check", 5]), + json!(["CMD", ""]), ] { value["Healthcheck"]["Test"] = test; - assert!(validate(&value).is_err()); + let refused = validate(&value).unwrap_err(); + assert_eq!(refused.field, ProcessField::Healthcheck); + assert_eq!(refused.error.code, "environment_health"); } } #[test] + fn relative_entrypoint_and_health_names_are_kept_for_container_path_lookup() { + let mut value = config(); + value["Entrypoint"] = json!(["docker-entrypoint.sh"]); + value["Cmd"] = json!(["mysqld"]); + value["Healthcheck"] = json!({"Test":["CMD","mysqladmin","ping"]}); + attach(&mut value, "/run/slot/values.json", "/storage/launcher").unwrap(); + assert_eq!( + value["Entrypoint"], + json!([ + "/run/hack-environment-launcher", + "/run/hack-environment.json", + "/run/hack-environment.expires", + "docker-entrypoint.sh", + "mysqld" + ]) + ); + assert_eq!( + value["Healthcheck"]["Test"], + json!([ + "CMD", + "/run/hack-environment-launcher", + "--health", + "/run/hack-environment.json", + "/run/hack-environment.expires", + "mysqladmin", + "ping" + ]) + ); + } + #[test] fn uid_only_keeps_engine_group_resolution_while_private_files_use_owner_uid() { let mut value = config(); value["User"] = json!("1001"); @@ -429,12 +485,34 @@ mod tests { value["HostConfig"]["Mounts"] = json!([{"Target":target}]); assert!(validate(&value).is_err()); } + for (field, entry) in [ + (ProcessField::Entrypoint, json!([])), + (ProcessField::Entrypoint, json!([""])), + (ProcessField::Entrypoint, json!(["/"])), + (ProcessField::Entrypoint, json!(["app", 5])), + (ProcessField::Entrypoint, json!(null)), + ] { + let mut value = config(); + value["Entrypoint"] = entry; + assert_eq!(validate(&value).unwrap_err().field, field); + } let mut value = config(); - value["Healthcheck"] = json!({"Test":["CMD","true"]}); - assert!(validate(&value).is_err()); + value["StopSignal"] = json!("SIGUSR1"); + assert_eq!( + validate(&value).unwrap_err().field, + ProcessField::StopSignal + ); + let mut value = config(); + value["User"] = json!("bun"); + assert_eq!(validate(&value).unwrap_err().field, ProcessField::User); + let mut value = config(); + value["HostConfig"]["Mounts"] = json!([{"Target":"/run"}]); + assert_eq!(validate(&value).unwrap_err().field, ProcessField::Mounts); + // Relative names are accepted for container PATH lookup. let mut value = config(); value["Entrypoint"] = json!(["app"]); - assert!(validate(&value).is_err()); + value["Healthcheck"] = json!({"Test":["CMD","true"]}); + assert!(validate(&value).is_ok()); } } diff --git a/packages/runtime-core/src/provider/graph/mod.rs b/packages/runtime-core/src/provider/graph/mod.rs index 41017a492..befa300bc 100644 --- a/packages/runtime-core/src/provider/graph/mod.rs +++ b/packages/runtime-core/src/provider/graph/mod.rs @@ -123,6 +123,7 @@ pub use endpoints::GuestEndpoint; mod environment_crash_test; mod launcher; mod probes; +mod process_refusals; #[cfg(all(test, feature = "environment-launcher"))] mod startup_test; pub use environment::stage_environment; @@ -1329,17 +1330,44 @@ pub(super) fn validate_private_delivery( configs: &BTreeMap, environments: &BTreeMap, ) -> Result<(), CandidateError> { + let mut refusals = process_refusals::Collector::default(); for (name, pending) in environments { let config = &configs[name]; if pending.wraps_process() { - launcher::validate(config)?; + refusals.check(name, launcher::validate(config)); } else { - launcher::identity(config)?; + refusals.check( + name, + launcher::identity(config) + .map(|_| ()) + .map_err(|error| process_refusals::Refused { + field: crate::error::ProcessField::User, + error, + }), + ); } - launcher::validate_files(config, &pending.file_targets())?; + } + refusals.finish()?; + for (name, pending) in environments { + launcher::validate_files(&configs[name], &pending.file_targets())?; } Ok(()) } + +/// Wrapper admission for host-dependency services, before any runtime state is +/// reserved: their release barrier runs the same materialized process. +pub(super) fn validate_dependency_processes( + configs: &BTreeMap, + services: &BTreeSet, +) -> Result<(), CandidateError> { + let mut refusals = process_refusals::Collector::default(); + for name in services { + if let Some(config) = configs.get(name) { + refusals.check(name, launcher::validate(config)); + } + } + refusals.finish() +} /// Services whose process the private launcher wraps, and files-only services whose /// image user must still be materialized for exact file ownership. pub(super) fn private_services( @@ -1453,8 +1481,17 @@ fn run_inputs( super::environment::preflight_capacity(engine.guest(), environments.len())?; } // Resolve inherited process defaults only from the exact verified image, - // before the private launcher takes ownership of Entrypoint and Cmd. - let (wrapped, file_owners) = private_services(&environments); + // before the private launcher or the dependency release barrier takes + // ownership of Entrypoint and Cmd. + let (mut wrapped, mut file_owners) = private_services(&environments); + let dependency_processes = startup + .as_ref() + .map(|driver| driver.process_services()) + .unwrap_or_default(); + for name in &dependency_processes { + file_owners.remove(name); + wrapped.insert(name.clone()); + } let expected_environment = verify_images( &engine, &prepared.resources, @@ -1463,6 +1500,7 @@ fn run_inputs( &file_owners, )?; validate_private_delivery(&prepared.configs, &environments)?; + validate_dependency_processes(&prepared.configs, &dependency_processes)?; if let Some(driver) = startup.as_ref() { driver.check_cancelled()?; } @@ -2358,6 +2396,7 @@ fn verify_images( file_owners: &BTreeSet, ) -> Result>, CandidateError> { let mut expected = BTreeMap::new(); + let mut refusals = process_refusals::Collector::default(); for resource in resources.values().filter(|r| r.kind == Kind::Container) { let image = engine.request( Method::GET, @@ -2384,24 +2423,32 @@ fn verify_images( resources, )?; if private_services.contains(&resource.key) { - image_process::apply_private( - configs - .get_mut(&resource.key) - .expect("compiled service config"), - &image["Config"], - )?; + refusals.check( + &resource.key, + image_process::apply_private( + configs + .get_mut(&resource.key) + .expect("compiled service config"), + &image["Config"], + ), + ); } else if file_owners.contains(&resource.key) { - image_process::apply_file_owner( - configs - .get_mut(&resource.key) - .expect("compiled service config"), - &image["Config"], - )?; + refusals.check( + &resource.key, + image_process::apply_file_owner( + configs + .get_mut(&resource.key) + .expect("compiled service config"), + &image["Config"], + ), + ); } expected.insert( resource.key.clone(), image_environment::compose(&image["Config"]["Env"], &configs[&resource.key]["Env"])?, ); } + // Every wrapped service is checked before the first refusal is reported. + refusals.finish()?; Ok(expected) } diff --git a/packages/runtime-core/src/provider/graph/native/runtime.rs b/packages/runtime-core/src/provider/graph/native/runtime.rs index a0786804c..3aa30105a 100644 --- a/packages/runtime-core/src/provider/graph/native/runtime.rs +++ b/packages/runtime-core/src/provider/graph/native/runtime.rs @@ -1088,7 +1088,8 @@ pub(super) fn run_guarded( verify_native_images(&engine, &config.resources, &mut config.configs, &private)? }; for name in &private { - launcher::validate(&config.configs[name])?; + launcher::validate(&config.configs[name]) + .map_err(super::super::process_refusals::Refused::into_error)?; } check_startup(startup_guard)?; selected.assert_fresh(candidate)?; @@ -1793,7 +1794,8 @@ fn verify_native_images( } } if private.contains(&resource.key) { - image_process::apply_private(config, &image["Config"])?; + image_process::apply_private(config, &image["Config"]) + .map_err(super::super::process_refusals::Refused::into_error)?; } expected.insert( resource.key.clone(), diff --git a/packages/runtime-core/src/provider/graph/process_refusals.rs b/packages/runtime-core/src/provider/graph/process_refusals.rs new file mode 100644 index 000000000..30d0017fe --- /dev/null +++ b/packages/runtime-core/src/provider/graph/process_refusals.rs @@ -0,0 +1,145 @@ +//! Named, collected refusals of a private process wrapper's selected process. +//! +//! A wrapper (private environment delivery or the host-dependency release +//! barrier) runs the exact process Docker would run, so every wrapped service is +//! checked before any runtime state is reserved. Each refusal keeps its fixed +//! code and value-free message and also names the service and the closed field +//! to change. All wrapped services are checked in one pass so a project with +//! several affected stock images learns about all of them from one start. +use crate::error::{CandidateError, ProcessField, ProcessRefusal, ProcessRefusalsDiagnostic}; + +/// One wrapper refusal before it is attributed to a service. +#[derive(Debug)] +pub(super) struct Refused { + pub(super) field: ProcessField, + pub(super) error: CandidateError, +} + +impl Refused { + pub(super) fn new(field: ProcessField, code: &'static str, message: &str) -> Self { + Self { + field, + error: CandidateError::new(code, message), + } + } + + /// The plain refusal, for a caller that checks one process only. + pub(super) fn into_error(self) -> CandidateError { + self.error + } +} + +/// Bounded like other value-free per-service diagnostics. +const MAX_REFUSALS: usize = 32; + +/// Collects refusals across services; the first refusal's code is reported. +#[derive(Default)] +pub(super) struct Collector { + first: Option, + refusals: Vec, +} + +impl Collector { + pub(super) fn check(&mut self, service: &str, result: Result<(), Refused>) { + let Err(refused) = result else { + return; + }; + if self.refusals.len() < MAX_REFUSALS + && !self + .refusals + .iter() + .any(|known| known.service == service && known.field == refused.field) + { + self.refusals.push(ProcessRefusal { + service: service.into(), + field: refused.field, + }); + } + self.first.get_or_insert(refused.error); + } + + pub(super) fn finish(self) -> Result<(), CandidateError> { + match self.first { + None => Ok(()), + Some(mut error) => { + error.message = format!( + "{} Refused process settings are named in process_refusals.", + error.message + ); + error.process_refusals = Some(Box::new(ProcessRefusalsDiagnostic { + version: 1, + refusals: self.refusals, + })); + Err(error) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collects_every_service_and_field_and_keeps_the_first_code() { + let mut collector = Collector::default(); + collector.check("web", Ok(())); + collector.check( + "db", + Err(Refused::new( + ProcessField::User, + "environment_user", + "Invalid.", + )), + ); + collector.check( + "api", + Err(Refused::new( + ProcessField::Healthcheck, + "environment_health", + "Invalid.", + )), + ); + collector.check( + "db", + Err(Refused::new( + ProcessField::User, + "environment_user", + "Again.", + )), + ); + let error = collector.finish().unwrap_err(); + assert_eq!(error.code, "environment_user"); + let detail = error.process_refusals.expect("named refusals"); + assert_eq!(detail.version, 1); + assert_eq!( + detail.refusals, + vec![ + ProcessRefusal { + service: "db".into(), + field: ProcessField::User + }, + ProcessRefusal { + service: "api".into(), + field: ProcessField::Healthcheck + }, + ] + ); + let reply = serde_json::to_value(CandidateError { + process_refusals: Some(Box::new(ProcessRefusalsDiagnostic { + version: 1, + refusals: detail.refusals, + })), + ..CandidateError::new("environment_user", "Invalid.") + }) + .unwrap(); + assert_eq!( + reply["process_refusals"], + serde_json::json!({"version":1,"refusals":[ + {"service":"db","field":"user"}, + {"service":"api","field":"healthcheck"} + ]}) + ); + assert!(Collector::default().finish().is_ok()); + } +} diff --git a/packages/runtime-core/src/provider/graph/restore.rs b/packages/runtime-core/src/provider/graph/restore.rs index 756427a92..c254a456a 100644 --- a/packages/runtime-core/src/provider/graph/restore.rs +++ b/packages/runtime-core/src/provider/graph/restore.rs @@ -477,7 +477,15 @@ fn restore_inputs( if !environments.is_empty() { super::super::environment::preflight_capacity(engine.guest(), environments.len())?; } - let (wrapped, file_owners) = private_services(&environments); + let (mut wrapped, mut file_owners) = private_services(&environments); + let dependency_processes = startup + .as_ref() + .map(|driver| driver.process_services()) + .unwrap_or_default(); + for name in &dependency_processes { + file_owners.remove(name); + wrapped.insert(name.clone()); + } let expected_environment = verify_images( &engine, &prepared.resources, @@ -485,6 +493,7 @@ fn restore_inputs( &wrapped, &file_owners, )?; + super::validate_dependency_processes(&prepared.configs, &dependency_processes)?; for resource in receipt.resources.values() { let present = inspect_resource(&engine, &receipt, resource)?.is_some(); if resource.kind == Kind::Volume { diff --git a/packages/runtime-core/src/provider/graph/startup.rs b/packages/runtime-core/src/provider/graph/startup.rs index 2321ccfe1..c2bcf0605 100644 --- a/packages/runtime-core/src/provider/graph/startup.rs +++ b/packages/runtime-core/src/provider/graph/startup.rs @@ -188,6 +188,12 @@ pub(super) trait Driver { fn check_cancelled(&self) -> Result<(), CandidateError> { Ok(()) } + /// Services whose process this driver wraps behind its release barrier. + /// Their inherited image process is materialized before admission, exactly + /// as for private environment delivery, so the wrapper runs what Docker would. + fn process_services(&self) -> std::collections::BTreeSet { + std::collections::BTreeSet::new() + } fn validate_inputs( &self, inputs: &project::inputs::ExecutionInputs, diff --git a/packages/runtime-core/src/provider/graph/startup/runtime.rs b/packages/runtime-core/src/provider/graph/startup/runtime.rs index 139aad584..3a7f5be5a 100644 --- a/packages/runtime-core/src/provider/graph/startup/runtime.rs +++ b/packages/runtime-core/src/provider/graph/startup/runtime.rs @@ -656,6 +656,13 @@ impl Driver for HostRelayRuntime { self.run.is_some() } + fn process_services(&self) -> std::collections::BTreeSet { + self.dependencies + .keys() + .map(|(service, _)| service.clone()) + .collect() + } + fn check_cancelled(&self) -> Result<(), CandidateError> { if self.startup_cancelled.is_some_and(|check| check()) { return Err(error( @@ -789,7 +796,8 @@ impl Driver for HostRelayRuntime { let config = configs .get(name) .ok_or_else(|| stage_refused("graph_startup_service_config"))?; - launcher::validate(config)?; + launcher::validate(config) + .map_err(super::super::process_refusals::Refused::into_error)?; if config["HostConfig"]["Init"] != true { return Err(stage_refused("graph_startup_init")); } diff --git a/src/backends/native-project-process.ts b/src/backends/native-project-process.ts index 54670e94b..0737909c7 100644 --- a/src/backends/native-project-process.ts +++ b/src/backends/native-project-process.ts @@ -1,6 +1,7 @@ import { isAbsolute } from "node:path"; import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import type { NativeProcessRefusal } from "../lib/native-process-wrappers.ts"; import { type NativeAuthoredReceipt, type NativeAuthoredReview, @@ -29,6 +30,8 @@ export type NativeExitDiagnostic = { readonly nativeCode?: string; /** Bounded cause the runtime allows with `nativeCode`, such as a wrapped stopped runtime. */ readonly nativeCauseCode?: string; + /** Every service and closed field a process wrapper refused; value-free. */ + readonly processRefusals?: readonly NativeProcessRefusal[]; }; type ProcessOptions = { @@ -482,6 +485,7 @@ async function serveGraphProcess( const failure = await failureCodes; const nativeCode = failure?.code; const nativeCauseCode = failure?.causeCode; + const processRefusals = failure?.processRefusals; clearTimeout(drainTimer); drain.abort(); try { @@ -489,6 +493,7 @@ async function serveGraphProcess( exitCode, ...(nativeCode ? { nativeCode } : {}), ...(nativeCauseCode ? { nativeCauseCode } : {}), + ...(processRefusals ? { processRefusals } : {}), }); } catch { // Observation must never replace the startup result or cleanup authority. diff --git a/src/backends/native-project-restart-preflight.ts b/src/backends/native-project-restart-preflight.ts index a06616231..bf63a9c46 100644 --- a/src/backends/native-project-restart-preflight.ts +++ b/src/backends/native-project-restart-preflight.ts @@ -4,6 +4,7 @@ import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { ClosedCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import { requireWrappableNativeProcesses } from "../lib/native-process-wrappers.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { adaptNativeGcloudEnvironment } from "./native-gcloud-environment.ts"; import { prepareNativeProjectAdaptation } from "./native-project-adaptation.ts"; @@ -453,6 +454,15 @@ export async function preflightNativeRestart(opts: { sources: opts.internalExtraHostSources, }); prepareNativeDependencyServices({ dependencies, services: specs }); + // The replacement startup would refuse these before its VM work; refuse + // before the current graph is stopped. + requireWrappableNativeProcesses({ + services: specs, + profiles: selected.profiles, + managedEnvironment: input.managedEnvironment, + managedFiles: input.managedFiles, + dependencyServices: dependencies.map((binding) => binding.service), + }); const activeImages = stoppedRetry ? undefined : await deps.activeImages({ diff --git a/src/backends/native-project-start.ts b/src/backends/native-project-start.ts index 6debd0d44..e7b73a3ca 100644 --- a/src/backends/native-project-start.ts +++ b/src/backends/native-project-start.ts @@ -8,6 +8,11 @@ import { withComposeServiceEnvFingerprints, } from "../lib/compose-env-fingerprint.ts"; import { isRecord } from "../lib/guards.ts"; +import { + describeNativeProcessRefusals, + type NativeProcessRefusal, + requireWrappableNativeProcesses, +} from "../lib/native-process-wrappers.ts"; import { adaptNativeAwsEnvironment } from "./native-aws-environment.ts"; import { scheduleNativeAwsExpiryWarnings } from "./native-aws-expiry.ts"; import { @@ -872,13 +877,21 @@ const NATIVE_CODE = /^[A-Za-z0-9_]{1,80}$/; * inspection reports the run's journal exactly absent. Nothing was created, so * no cleanup is pending and the startup diagnostic is the whole story. */ -function nothingReserved(startupFailure: unknown, nativeCode?: string): Error { +function nothingReserved( + startupFailure: unknown, + nativeCode?: string, + processRefusals?: readonly NativeProcessRefusal[] +): Error { const message = startupFailure instanceof Error ? startupFailure.message : ""; const diagnostic = SAFE_STARTUP_DIAGNOSTIC.exec(message); const code = nativeCode && NATIVE_CODE.test(nativeCode) ? nativeCode : diagnostic?.[2]; + const refused = + processRefusals && processRefusals.length > 0 + ? ` Native cannot wrap these services' processes: ${describeNativeProcessRefusals(processRefusals)}.` + : ""; return new ClosedCliError( - `Native graph startup failed${code ? ` (${code})` : ""} before any runtime state was reserved; nothing was created and no cleanup is needed.` + `Native graph startup failed${code ? ` (${code})` : ""} before any runtime state was reserved; nothing was created and no cleanup is needed.${refused}` ); } /** @@ -894,6 +907,7 @@ function uninspectableStartupFailure(opts: { readonly restored: boolean; readonly mappingAttempted: boolean; readonly nativeCode: string | undefined; + readonly processRefusals?: readonly NativeProcessRefusal[]; }): { readonly error: Error; readonly nothingReserved: boolean } { if ( opts.serveFailure === undefined || @@ -911,7 +925,11 @@ function uninspectableStartupFailure(opts: { }; } return { - error: nothingReserved(opts.serveFailure, opts.nativeCode), + error: nothingReserved( + opts.serveFailure, + opts.nativeCode, + opts.processRefusals + ), nothingReserved: true, }; } @@ -1559,6 +1577,14 @@ export async function startNativeProject(opts: { dependencies: hostDependencies, services: specs, }); + // Refuse Compose settings no process wrapper can run before the VM starts. + requireWrappableNativeProcesses({ + services: specs, + profiles, + managedEnvironment: input.managedEnvironment, + managedFiles: input.managedFiles, + dependencyServices: hostDependencies.map((binding) => binding.service), + }); await verifyNativeRetainedMapping({ run: retained, scope: opts.scope, @@ -1765,6 +1791,9 @@ export async function startNativeProject(opts: { let code = 1; let serveFailure: unknown; let nativeExitCode: string | undefined; + let nativeProcessRefusals: + | readonly NativeProcessRefusal[] + | undefined; // Redacted at receipt; the raw tail is never retained or persisted. let startupLogReport = ""; try { @@ -1803,6 +1832,7 @@ export async function startNativeProject(opts: { ...stop?.serve, onExitDiagnostic: (diagnostic) => { nativeExitCode = diagnostic.nativeCode; + nativeProcessRefusals = diagnostic.processRefusals; }, onStartupLogs: (logs) => { startupLogReport = formatNativeStartupLogReport({ @@ -1937,6 +1967,7 @@ export async function startNativeProject(opts: { restored: restore !== undefined, mappingAttempted, nativeCode: nativeExitCode, + processRefusals: nativeProcessRefusals, }); graphCleanupConfirmed = settled.nothingReserved; throw settled.error; diff --git a/src/backends/native-runtime-client.ts b/src/backends/native-runtime-client.ts index 67d52f89d..cfb124b88 100644 --- a/src/backends/native-runtime-client.ts +++ b/src/backends/native-runtime-client.ts @@ -1,6 +1,10 @@ import { isAbsolute } from "node:path"; import { ClosedCliError, HackCliError } from "../lib/cli-result.ts"; import { isRecord } from "../lib/guards.ts"; +import { + type NativeProcessRefusal, + readNativeProcessRefusals, +} from "../lib/native-process-wrappers.ts"; import { nativeAuthoredExecOptions } from "./native-authored-exec-options.ts"; import { NATIVE_STARTUP_FAILURE_REPLY_BYTES, @@ -23,6 +27,8 @@ interface NativeFailure { readonly causeCode?: string; readonly stopFailures?: readonly NativeStopFailure[]; readonly startupLogs?: readonly NativeStartupLogTail[]; + /** Services and closed fields a process wrapper refused; value-free. */ + readonly processRefusals?: readonly NativeProcessRefusal[]; } /** @@ -642,8 +648,16 @@ function nativeFailureFromReply( ...(startupLogs && value.startup_logs !== undefined ? { startupLogs: readNativeStartupLogs(value.startup_logs) } : {}), + ...processRefusalsOf(value.process_refusals), }; } +function processRefusalsOf(value: unknown): { + readonly processRefusals?: readonly NativeProcessRefusal[]; +} { + const processRefusals = + value === undefined ? undefined : readNativeProcessRefusals(value); + return processRefusals ? { processRefusals } : {}; +} /** Only bounded native error codes leave this boundary, never stderr messages. */ async function readNativeFailure( @@ -722,6 +736,7 @@ export async function readNativeStartupFailure( readonly code: string; readonly causeCode?: string; readonly startupLogs?: readonly NativeStartupLogTail[]; + readonly processRefusals?: readonly NativeProcessRefusal[]; } | undefined > { @@ -736,6 +751,9 @@ export async function readNativeStartupFailure( code: failure.code, ...(failure.causeCode ? { causeCode: failure.causeCode } : {}), ...(failure.startupLogs ? { startupLogs: failure.startupLogs } : {}), + ...(failure.processRefusals + ? { processRefusals: failure.processRefusals } + : {}), }; } diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 81f57aee6..14739e145 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -129,6 +129,7 @@ import { confirmSafe, requireInteractive, } from "../lib/interactivity.ts"; +import { resolveInternalExtraHosts } from "../lib/internal-extra-hosts.ts"; import { findHackLocalCaCertPath } from "../lib/local-ca.ts"; import { noteBrowserTargetsForLocalNetworkHint } from "../lib/local-network-hint.ts"; import { @@ -148,7 +149,10 @@ import { repairNativeCaTrust, } from "../lib/native-ca-doctor.ts"; import { selectLegacyComposeAdoptedRoot } from "../lib/native-compose-adoption-command.ts"; -import { readComposeBackendReading } from "../lib/native-compose-backend-features.ts"; +import { + readComposeBackendReading, + readNativeComposeDocument, +} from "../lib/native-compose-backend-features.ts"; import { NATIVE_DATA_CHECK, type NativeDataReport, @@ -166,6 +170,11 @@ import { reportHackLocalRoute, } from "../lib/native-hack-local-route.ts"; import { createNativeHackLocalRouteDependencies } from "../lib/native-hack-local-route-host.ts"; +import { + NATIVE_PROCESS_CHECK, + type NativeProcessReport, + nativeProcessCheckFromCompose, +} from "../lib/native-process-wrappers.ts"; import { isMac } from "../lib/os.ts"; import { defaultProjectSlugFromPath, @@ -251,6 +260,8 @@ interface CheckResult { readonly composeBackend?: ComposeBackendReport; /** Closed native data advisory codes and service names only. */ readonly nativeData?: NativeDataReport; + /** Service names and closed process fields only. */ + readonly nativeProcess?: NativeProcessReport; } const HACK_LOCAL_ROUTE_CHECK = "native hack.local route"; @@ -1155,6 +1166,11 @@ async function handleNativeDoctor(opts: { checkNativeDataAdvisories({ startDir: opts.startDir }) ) ); + results.push( + await runCheck(progress, NATIVE_PROCESS_CHECK, () => + checkNativeProcessWrappers({ startDir: opts.startDir }) + ) + ); } progress.stop(`Native doctor checks complete (${results.length} checks)`); const hasError = results.some((result) => result.status === "error"); @@ -1344,6 +1360,8 @@ export type DoctorJsonCheck = { readonly composeBackend?: ComposeBackendReport; /** Present only on the native data-service check: closed codes and service names. */ readonly nativeData?: NativeDataReport; + /** Present only on the native process-wrapper check: service names and closed fields. */ + readonly nativeProcess?: NativeProcessReport; }; export type DoctorJsonData = { @@ -1383,6 +1401,7 @@ export function buildDoctorJsonData(opts: { ? { composeBackend: result.composeBackend } : {}), ...(result.nativeData ? { nativeData: result.nativeData } : {}), + ...(result.nativeProcess ? { nativeProcess: result.nativeProcess } : {}), })), summary: buildDoctorSummaryLines({ results: opts.results }), counts, @@ -3043,6 +3062,36 @@ async function checkNativeDataAdvisories(opts: { return nativeDataCheckResult(await readNativeDataAdvisories(ctx.composeFile)); } +/** + * Predicts the Compose-declared settings native up's process wrappers refuse + * (HACK-1939), for the services it may wrap: every service when project-wide + * host aliases exist, services with host-gateway extra hosts, and services + * with bare environment keys. Read-only and value-free; image-inherited + * settings are checked by native up against the pinned image. + */ +async function checkNativeProcessWrappers(opts: { + readonly startDir: string; +}): Promise { + const ctx = await findProjectContext(opts.startDir); + if (!ctx) { + return { + name: NATIVE_PROCESS_CHECK, + status: "warn", + message: `Skipped (no ${HACK_PROJECT_DIR_PRIMARY}/ found)`, + }; + } + const cfg = await readProjectConfig(ctx); + const aliases = await resolveInternalExtraHosts({ + projectRoot: ctx.projectRoot, + projectDir: ctx.projectDir, + staticHosts: cfg.internal?.extraHosts, + }).catch(() => ({ hosts: {} })); + return nativeProcessCheckFromCompose({ + compose: await readNativeComposeDocument(ctx.composeFile), + projectWideAliases: Object.keys(aliases.hosts).length > 0, + }); +} + /** * Read-only key-strength and exposure audit (HACK-1127). `--fix` never acts on * it; each finding suggests the exact command instead. It also catches a key diff --git a/src/lib/native-process-wrappers.ts b/src/lib/native-process-wrappers.ts new file mode 100644 index 000000000..5d4c7087c --- /dev/null +++ b/src/lib/native-process-wrappers.ts @@ -0,0 +1,533 @@ +import { resolveNativeComposeText } from "../backends/native-compose-interpolation.ts"; +import { HackCliError } from "./cli-result.ts"; +import { isRecord } from "./guards.ts"; + +/** + * Native wraps some services' processes: private environment delivery runs + * the selected process through a launcher, and host-dependency services run it + * behind a release barrier. Both run exactly what Docker would (image or + * Compose `entrypoint` + `command`, bare executable names resolved through the + * container's PATH, shell-form health checks through `/bin/sh -c`), but a few + * settings still cannot be wrapped. Native up checks every wrapped service + * before any runtime state is reserved and names each refused service and + * field; this module predicts the Compose-declared cases before `up` starts + * anything, and parses and describes the runtime's named refusals. + * + * Settings inherited from an image (its ENTRYPOINT, CMD or USER) are not read + * here; native up checks those against the pinned image and reports every + * refused service at once. + */ +export const NATIVE_PROCESS_FIELDS = [ + "entrypoint", + "healthcheck", + "user", + "stop_signal", + "mounts", +] as const; + +export type NativeProcessField = (typeof NATIVE_PROCESS_FIELDS)[number]; + +/** One refused (service, field) pair; value-free. */ +export type NativeProcessRefusal = { + readonly service: string; + readonly field: NativeProcessField; +}; + +const SERVICE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; +const MAX_REFUSALS = 32; + +/** + * Accept only the runtime's versioned, bounded `process_refusals` contract; + * anything else is dropped whole. + */ +export function readNativeProcessRefusals( + value: unknown +): readonly NativeProcessRefusal[] | undefined { + if ( + !isRecord(value) || + Object.keys(value).length !== 2 || + value.version !== 1 || + !Array.isArray(value.refusals) || + value.refusals.length < 1 || + value.refusals.length > MAX_REFUSALS + ) { + return; + } + const result: NativeProcessRefusal[] = []; + for (const entry of value.refusals) { + if ( + !isRecord(entry) || + Object.keys(entry).length !== 2 || + typeof entry.service !== "string" || + !SERVICE.test(entry.service) || + !NATIVE_PROCESS_FIELDS.includes(entry.field as NativeProcessField) + ) { + return; + } + result.push({ + service: entry.service, + field: entry.field as NativeProcessField, + }); + } + return result; +} + +const FIELD_LABELS: Readonly> = { + entrypoint: "entrypoint or command", + healthcheck: "healthcheck test", + user: "user", + stop_signal: "stop_signal", + mounts: "volume target", +}; + +const FIELD_ADVICE: Readonly> = { + entrypoint: "the process must name an executable", + healthcheck: "use an exec-form CMD test that names an executable", + user: "use a numeric UID or UID:GID", + stop_signal: "use SIGTERM, SIGINT, SIGQUIT or SIGKILL", + mounts: "a volume target overlaps a path the wrapper reserves", +}; + +/** `service web (user: use a numeric UID or UID:GID); ...`, fields in a fixed order. */ +export function describeNativeProcessRefusals( + refusals: readonly NativeProcessRefusal[] +): string { + return refusals + .map( + (refusal) => + `service ${refusal.service} ${FIELD_LABELS[refusal.field]} (${FIELD_ADVICE[refusal.field]})` + ) + .join("; "); +} + +const SIGNALS = new Set(["SIGTERM", "SIGINT", "SIGQUIT", "SIGKILL"]); +const NUMERIC_USER = /^(\d{1,10})(?::(\d{1,10}))?$/; +const MAX_ID = 2_147_483_647; +const ENVIRONMENT_RESERVED = ["/run/hack-environment"]; +const DEPENDENCY_RESERVED = [ + "/run/hack-relay-guest", + "/run/hack-startup", + "/run/hack-dependencies", +]; + +/** A Compose string as native execution resolves it; null when not knowable. */ +function compiled(value: string): string | null { + return resolveNativeComposeText({ text: value }); +} + +function numericUser(value: unknown): boolean | undefined { + if (value === undefined || value === null) { + return true; + } + if (typeof value === "number") { + return Number.isInteger(value) && value >= 0 && value <= MAX_ID; + } + if (typeof value !== "string") { + return false; + } + const user = compiled(value); + if (user === null) { + return; + } + if (user === "") { + return true; + } + const match = NUMERIC_USER.exec(user); + return ( + match !== null && + Number(match[1]) <= MAX_ID && + (match[2] === undefined || Number(match[2]) <= MAX_ID) + ); +} + +function emptyArgv(value: unknown): boolean { + if (Array.isArray(value)) { + return value.length === 0; + } + return typeof value === "string" && value.trim() === ""; +} + +/** True when the declared entrypoint and command select no executable. */ +function emptyProcess(service: Readonly>): boolean { + const entrypoint = service.entrypoint; + if (Array.isArray(entrypoint) && entrypoint.length > 0) { + const first = entrypoint[0]; + return typeof first === "string" && (first === "" || first === "/"); + } + if (!emptyArgv(entrypoint)) { + return false; + } + // An explicit empty entrypoint suppresses the image's CMD as well. + const command = service.command; + if (Array.isArray(command) && command.length > 0) { + const first = command[0]; + return typeof first === "string" && (first === "" || first === "/"); + } + return command === undefined || command === null || emptyArgv(command); +} + +function unwrappableHealth(health: unknown): boolean { + // A native HTTP probe replaces the container health check entirely. + if ( + !isRecord(health) || + health.disable === true || + Object.hasOwn(health, "x-hack-http") + ) { + return false; + } + const test = health.test; + if (test === undefined || test === null) { + return false; + } + if (typeof test === "string") { + return test.trim() === ""; + } + if (!Array.isArray(test) || test.length === 0) { + return true; + } + const [kind, ...rest] = test; + if (kind === "NONE") { + return false; + } + if (kind === "CMD") { + return !( + typeof rest[0] === "string" && + rest[0] !== "" && + rest.every((part) => typeof part === "string") + ); + } + if (kind === "CMD-SHELL") { + return !( + rest.length === 1 && + typeof rest[0] === "string" && + rest[0] !== "" + ); + } + return true; +} + +function volumeTargets(volumes: unknown): string[] { + if (!Array.isArray(volumes)) { + return []; + } + const targets: string[] = []; + for (const volume of volumes) { + if (typeof volume === "string") { + const parts = volume.split(":"); + const target = parts.length >= 2 ? parts[1] : parts[0]; + if (target) { + targets.push(target); + } + } else if (isRecord(volume) && typeof volume.target === "string") { + targets.push(volume.target); + } + } + return targets; +} + +/** The same reserved targets the environment launcher and release barrier refuse. */ +function reservedTarget(target: string, dependency: boolean): boolean { + const normalized = + target.length > 1 && target.endsWith("/") ? target.slice(0, -1) : target; + return ( + normalized === "/" || + normalized === "/run" || + ENVIRONMENT_RESERVED.some((prefix) => normalized.startsWith(prefix)) || + (dependency && + DEPENDENCY_RESERVED.some( + (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`) + )) + ); +} + +/** + * Compose-declared settings native cannot wrap, for every wrapped service: + * `wrapped` services have their process wrapped, `fileOwners` receive private + * files only (their user must be numeric). `dependencies` are the wrapped + * services that also run behind the host-dependency release barrier. + * Interpolated values are compiled like native execution; unknown ones are + * left to the runtime check. + */ +export function predictNativeProcessRefusals(opts: { + readonly services: Readonly< + Record>> + >; + readonly wrapped: ReadonlySet; + readonly dependencies?: ReadonlySet; + readonly fileOwners?: ReadonlySet; +}): NativeProcessRefusal[] { + const refusals: NativeProcessRefusal[] = []; + for (const name of Object.keys(opts.services).sort()) { + const service = opts.services[name]; + const wrapped = opts.wrapped.has(name); + if ( + !( + service && + SERVICE.test(name) && + (wrapped || opts.fileOwners?.has(name)) + ) + ) { + continue; + } + const add = (field: NativeProcessField) => { + if (refusals.length < MAX_REFUSALS) { + refusals.push({ service: name, field }); + } + }; + if (wrapped && emptyProcess(service)) { + add("entrypoint"); + } + if (wrapped && unwrappableHealth(service.healthcheck)) { + add("healthcheck"); + } + if (numericUser(service.user) === false) { + add("user"); + } + if ( + wrapped && + typeof service.stop_signal === "string" && + !SIGNALS.has(service.stop_signal) + ) { + add("stop_signal"); + } + const dependency = opts.dependencies?.has(name) === true; + if ( + wrapped && + volumeTargets(service.volumes).some((target) => + reservedTarget(target, dependency) + ) + ) { + add("mounts"); + } + } + return refusals; +} + +/** + * Compose profile activation: a service without `profiles` (or with an empty + * list) always runs; otherwise it runs only when one of its profiles is + * selected. Inactive services are never scheduled, so nothing wraps them. + */ +export function activeNativeService( + service: Readonly>, + profiles: readonly string[] +): boolean { + const declared = service.profiles; + if (declared === undefined || declared === null) { + return true; + } + return ( + Array.isArray(declared) && + (declared.length === 0 || + declared.some( + (profile) => typeof profile === "string" && profiles.includes(profile) + )) + ); +} + +function activeServices( + services: Readonly>>>, + profiles: readonly string[] +): Record>> { + const result: Record>> = {}; + for (const [name, service] of Object.entries(services)) { + if (activeNativeService(service, profiles)) { + Object.defineProperty(result, name, { + value: service, + enumerable: true, + writable: true, + configurable: true, + }); + } + } + return result; +} + +/** Leading text of the refusal for Compose settings no process wrapper can run. */ +export const NATIVE_PROCESS_WRAPPER_REFUSAL = + "Native up cannot wrap these services' processes; nothing was started."; + +/** + * Refuse, before the VM starts, the Compose-declared settings the runtime's + * process wrappers refuse. Services with private environment values or host + * dependency bindings have their process wrapped, and services with private + * files only need a numeric user. Settings inherited from images are checked + * by the runtime against the pinned image before any state is reserved. + */ +export function requireWrappableNativeProcesses(opts: { + readonly services: Readonly< + Record>> + >; + /** Selected profiles: services outside them never run and are not checked. */ + readonly profiles: readonly string[]; + readonly managedEnvironment: Readonly< + Record>> + >; + readonly managedFiles?: Readonly>; + readonly dependencyServices: readonly string[]; +}): void { + const dependencies = new Set(opts.dependencyServices); + const wrapped = new Set(dependencies); + for (const [service, values] of Object.entries(opts.managedEnvironment)) { + if (Object.keys(values).length > 0) { + wrapped.add(service); + } + } + const fileOwners = new Set( + Object.entries(opts.managedFiles ?? {}) + .filter(([service, files]) => files.length > 0 && !wrapped.has(service)) + .map(([service]) => service) + ); + const refusals = predictNativeProcessRefusals({ + services: activeServices(opts.services, opts.profiles), + wrapped, + dependencies, + fileOwners, + }); + if (refusals.length > 0) { + throw new HackCliError({ + code: "E_NATIVE_PROJECT_UNSUPPORTED", + message: `${NATIVE_PROCESS_WRAPPER_REFUSAL} Refused: ${describeNativeProcessRefusals(refusals)}.`, + }); + } +} + +/** Doctor check id for the process-wrapper prediction. */ +export const NATIVE_PROCESS_CHECK = "native process wrappers"; + +/** Value-free JSON payload: service names and closed fields only. */ +export type NativeProcessReport = { + readonly refusals: readonly NativeProcessRefusal[]; + /** Services behind a profile: refused only when that profile is selected. */ + readonly profiled?: readonly NativeProcessRefusal[]; +}; + +function declaresHostGateway(extraHosts: unknown): boolean { + if (isRecord(extraHosts)) { + return Object.values(extraHosts).some( + (target) => target === "host-gateway" + ); + } + return ( + Array.isArray(extraHosts) && + extraHosts.some( + (entry) => typeof entry === "string" && entry.endsWith("host-gateway") + ) + ); +} + +/** Bare or null environment keys are where Hack delivers managed values. */ +function declaresManagedKeys(environment: unknown): boolean { + if (isRecord(environment)) { + return Object.values(environment).some((value) => value === null); + } + return ( + Array.isArray(environment) && + environment.some( + (entry) => typeof entry === "string" && !entry.includes("=") + ) + ); +} + +/** + * The services native up may wrap, from the Compose file alone: every service + * when project-wide host aliases exist (each then needs a host dependency + * binding), services that declare a host-gateway extra host, and services + * that declare bare environment keys Hack can deliver privately. + */ +export function nativeWrappedServiceCandidates(opts: { + readonly compose: unknown; + readonly projectWideAliases: boolean; +}): { + readonly services: Record>>; + readonly wrapped: ReadonlySet; + readonly dependencies: ReadonlySet; +} { + const services: Record>> = {}; + const wrapped = new Set(); + const dependencies = new Set(); + if (!(isRecord(opts.compose) && isRecord(opts.compose.services))) { + return { services, wrapped, dependencies }; + } + for (const [name, service] of Object.entries(opts.compose.services)) { + if (!(isRecord(service) && SERVICE.test(name))) { + continue; + } + Object.defineProperty(services, name, { + value: service, + enumerable: true, + writable: true, + configurable: true, + }); + if (opts.projectWideAliases || declaresHostGateway(service.extra_hosts)) { + dependencies.add(name); + wrapped.add(name); + } + if (declaresManagedKeys(service.environment)) { + wrapped.add(name); + } + } + return { services, wrapped, dependencies }; +} + +/** + * Doctor result for the process-wrapper prediction. Warning when a + * Compose-declared setting would be refused, otherwise ok. Read-only and + * value-free apart from service names. + */ +export function nativeProcessCheckResult( + refusals: readonly NativeProcessRefusal[], + /** Refusals of services that run only when one of their profiles is selected. */ + profiled: readonly NativeProcessRefusal[] = [] +): { + readonly name: string; + readonly status: "ok" | "warn"; + readonly message: string; + readonly nativeProcess: NativeProcessReport; +} { + const nativeProcess = { + refusals: refusals.map((entry) => ({ ...entry })), + ...(profiled.length > 0 + ? { profiled: profiled.map((entry) => ({ ...entry })) } + : {}), + }; + const note = + profiled.length > 0 + ? ` Only when their profile is selected: ${describeNativeProcessRefusals(profiled)}.` + : ""; + if (refusals.length === 0) { + return { + name: NATIVE_PROCESS_CHECK, + status: "ok", + message: `No Compose setting blocks native process wrapping for the services that always run; native up checks image entrypoints, commands and users before it reserves anything and names every refused service at once.${note}`, + nativeProcess, + }; + } + return { + name: NATIVE_PROCESS_CHECK, + status: "warn", + message: `Native up would refuse to wrap these services' processes: ${describeNativeProcessRefusals(refusals)}.${note}`, + nativeProcess, + }; +} + +/** + * Doctor's prediction, split by profile activation: services that always run + * can block `up` (warning); services behind a profile block it only when that + * profile is selected, so they are reported as information only. + */ +export function nativeProcessCheckFromCompose(opts: { + readonly compose: unknown; + readonly projectWideAliases: boolean; +}): ReturnType { + const candidates = nativeWrappedServiceCandidates(opts); + const refusals = predictNativeProcessRefusals(candidates); + const always = refusals.filter((refusal) => { + const service = candidates.services[refusal.service]; + return service !== undefined && activeNativeService(service, []); + }); + return nativeProcessCheckResult( + always, + refusals.filter((refusal) => !always.includes(refusal)) + ); +} diff --git a/tests/native-process-wrappers.test.ts b/tests/native-process-wrappers.test.ts new file mode 100644 index 000000000..81c48a8ed --- /dev/null +++ b/tests/native-process-wrappers.test.ts @@ -0,0 +1,279 @@ +/* biome-ignore-all lint/suspicious/noTemplateCurlyInString: literal Compose interpolation fixtures. */ +import { expect, test } from "bun:test"; +import { HackCliError } from "../src/lib/cli-result.ts"; +import { + describeNativeProcessRefusals, + NATIVE_PROCESS_CHECK, + NATIVE_PROCESS_WRAPPER_REFUSAL, + nativeProcessCheckFromCompose, + nativeProcessCheckResult, + nativeWrappedServiceCandidates, + predictNativeProcessRefusals, + readNativeProcessRefusals, + requireWrappableNativeProcesses, +} from "../src/lib/native-process-wrappers.ts"; + +function predict( + services: Record>, + dependencies: string[] = [] +) { + return predictNativeProcessRefusals({ + services, + wrapped: new Set(Object.keys(services)), + dependencies: new Set(dependencies), + }); +} + +test("stock images with relative entrypoints and health checks are wrappable", () => { + expect( + predict({ + db: { + image: "mysql:8.0", + healthcheck: { test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] }, + }, + shell: { + image: "postgres:16", + healthcheck: { test: "pg_isready -U postgres" }, + }, + shellform: { + image: "redis:7", + healthcheck: { test: ["CMD-SHELL", "redis-cli ping"] }, + }, + relative: { + image: "alpine:3.21", + entrypoint: ["docker-entrypoint.sh"], + command: ["serve"], + }, + commandOnly: { image: "python:3", entrypoint: [], command: ["python3"] }, + numeric: { image: "node:22", user: "1000:1000" }, + root: { image: "node:22", user: 0 }, + probe: { + image: "nginx", + healthcheck: { test: ["CMD"], "x-hack-http": { path: "/" } }, + }, + disabled: { image: "nginx", healthcheck: { disable: true, test: [] } }, + interpolated: { image: "nginx", user: "${APP_UID:-1000}" }, + }) + ).toEqual([]); +}); + +test("Compose settings no wrapper can run are named by service and field", () => { + expect( + predict( + { + empty: { image: "alpine", entrypoint: [] }, + blank: { image: "alpine", entrypoint: "", command: [] }, + slash: { image: "alpine", entrypoint: ["/"] }, + health: { image: "alpine", healthcheck: { test: ["CMD"] } }, + shell: { image: "alpine", healthcheck: { test: "" } }, + named: { image: "postgres", user: "postgres" }, + group: { image: "postgres", user: "999:postgres" }, + signal: { image: "nginx", stop_signal: "SIGUSR1" }, + run: { image: "nginx", volumes: ["data:/run"] }, + launcher: { + image: "nginx", + volumes: [{ target: "/run/hack-environment.json" }], + }, + relay: { image: "nginx", volumes: ["./x:/run/hack-startup/x:ro"] }, + }, + ["relay"] + ) + ).toEqual([ + { service: "blank", field: "entrypoint" }, + { service: "empty", field: "entrypoint" }, + { service: "group", field: "user" }, + { service: "health", field: "healthcheck" }, + { service: "launcher", field: "mounts" }, + { service: "named", field: "user" }, + { service: "relay", field: "mounts" }, + { service: "run", field: "mounts" }, + { service: "shell", field: "healthcheck" }, + { service: "signal", field: "stop_signal" }, + { service: "slash", field: "entrypoint" }, + ]); + // Release-barrier paths are reserved only for dependency services. + expect( + predict({ app: { image: "nginx", volumes: ["./x:/run/hack-startup/x"] } }) + ).toEqual([]); +}); + +test("only wrapped services and private-file owners are checked", () => { + const services = { + plain: { image: "postgres", user: "postgres", entrypoint: [] }, + files: { image: "postgres", user: "postgres", entrypoint: [] }, + }; + expect( + predictNativeProcessRefusals({ + services, + wrapped: new Set(), + fileOwners: new Set(["files"]), + }) + ).toEqual([{ service: "files", field: "user" }]); +}); + +test("the up preflight refuses before runtime work, naming every service and field", () => { + let thrown: unknown; + try { + requireWrappableNativeProcesses({ + services: { + db: { image: "postgres", user: "postgres" }, + web: { image: "nginx", healthcheck: { test: ["CMD"] } }, + idle: { image: "nginx", user: "nginx" }, + }, + profiles: [], + managedEnvironment: { db: { PASSWORD: "synthetic" }, idle: {} }, + dependencyServices: ["web"], + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(HackCliError); + expect(thrown).toHaveProperty("code", "E_NATIVE_PROJECT_UNSUPPORTED"); + expect((thrown as Error).message).toBe( + `${NATIVE_PROCESS_WRAPPER_REFUSAL} Refused: service db user (use a numeric UID or UID:GID); service web healthcheck test (use an exec-form CMD test that names an executable).` + ); + expect((thrown as Error).message).not.toContain("synthetic"); + expect((thrown as Error).message).not.toContain("postgres"); +}); + +test("the runtime refusal list is accepted only in its closed, bounded form", () => { + expect( + readNativeProcessRefusals({ + version: 1, + refusals: [{ service: "db", field: "stop_signal" }], + }) + ).toEqual([{ service: "db", field: "stop_signal" }]); + for (const value of [ + null, + { version: 1 }, + { version: 1, refusals: [{ service: "db", field: "env" }] }, + { version: 1, refusals: [{ service: "", field: "user" }] }, + { + version: 1, + refusals: Array.from({ length: 33 }, (_, i) => ({ + service: `s${i}`, + field: "user", + })), + }, + ]) { + expect(readNativeProcessRefusals(value)).toBeUndefined(); + } + expect( + describeNativeProcessRefusals([{ service: "db", field: "mounts" }]) + ).toBe( + "service db volume target (a volume target overlaps a path the wrapper reserves)" + ); +}); + +test("doctor predicts for every service when project-wide aliases exist", () => { + const compose = { + services: { + api: { image: "node", user: "node" }, + gateway: { + image: "nginx", + user: "nginx", + extra_hosts: ["api.example.test:host-gateway"], + }, + secret: { + image: "postgres", + user: "postgres", + environment: ["PASSWORD"], + }, + literal: { + image: "postgres", + user: "postgres", + environment: { MODE: "dev" }, + }, + }, + }; + const local = nativeWrappedServiceCandidates({ + compose, + projectWideAliases: false, + }); + expect([...local.wrapped].sort()).toEqual(["gateway", "secret"]); + expect([...local.dependencies]).toEqual(["gateway"]); + const wide = nativeWrappedServiceCandidates({ + compose, + projectWideAliases: true, + }); + const result = nativeProcessCheckResult(predictNativeProcessRefusals(wide)); + expect(result.name).toBe(NATIVE_PROCESS_CHECK); + expect(result.status).toBe("warn"); + expect(result.nativeProcess.refusals.map((entry) => entry.service)).toEqual([ + "api", + "gateway", + "literal", + "secret", + ]); + expect(result.message).toContain( + "service api user (use a numeric UID or UID:GID)" + ); + expect(nativeProcessCheckResult([]).status).toBe("ok"); +}); + +test("only profile-active services can block the up preflight", () => { + const services = { + web: { image: "nginx" }, + optional: { image: "postgres", user: "postgres", profiles: ["extra"] }, + }; + const opts = { + services, + managedEnvironment: { optional: { PASSWORD: "synthetic" } }, + dependencyServices: [], + }; + requireWrappableNativeProcesses({ ...opts, profiles: [] }); + requireWrappableNativeProcesses({ ...opts, profiles: ["other"] }); + expect(() => + requireWrappableNativeProcesses({ ...opts, profiles: ["extra"] }) + ).toThrow("Refused: service optional user (use a numeric UID or UID:GID)."); + // An empty profiles list always runs, like Compose. + expect(() => + requireWrappableNativeProcesses({ + ...opts, + services: { optional: { ...services.optional, profiles: [] } }, + profiles: [], + }) + ).toThrow("service optional user"); +}); + +test("doctor reports profile-gated services as information, never as a warning", () => { + const compose = { + services: { + web: { image: "nginx", environment: ["TOKEN"] }, + optional: { + image: "postgres", + user: "postgres", + environment: ["PASSWORD"], + profiles: ["extra"], + }, + }, + }; + const gated = nativeProcessCheckFromCompose({ + compose, + projectWideAliases: false, + }); + expect(gated.status).toBe("ok"); + expect(gated.nativeProcess).toEqual({ + refusals: [], + profiled: [{ service: "optional", field: "user" }], + }); + expect(gated.message).toContain( + "Only when their profile is selected: service optional user" + ); + const always = nativeProcessCheckFromCompose({ + compose: { + services: { + ...compose.services, + web: { ...compose.services.web, user: "nginx" }, + }, + }, + projectWideAliases: false, + }); + expect(always.status).toBe("warn"); + expect(always.nativeProcess.refusals).toEqual([ + { service: "web", field: "user" }, + ]); + expect(always.nativeProcess.profiled).toEqual([ + { service: "optional", field: "user" }, + ]); +}); diff --git a/tests/native-project-process.test.ts b/tests/native-project-process.test.ts index 7f6c030cc..1b837edaa 100644 --- a/tests/native-project-process.test.ts +++ b/tests/native-project-process.test.ts @@ -662,3 +662,70 @@ test("a second stop request during beforeStop forces the owner to stop", async ( ); expect(canceled).toBe(true); }, 15_000); + +test("HACK-1939: a startup failure reports every named process refusal to the exit diagnostic", async () => { + const reply = JSON.stringify({ + code: "environment_user", + message: "synthetic-private-detail", + process_refusals: { + version: 1, + refusals: [ + { service: "db", field: "user" }, + { service: "web", field: "healthcheck" }, + ], + }, + }); + const opts = await fixture( + `console.error(${JSON.stringify(reply)}); process.exit(2);` + ); + const diagnostics: unknown[] = []; + const failure = await serveNativeProjectGraph({ + ...opts, + onExitDiagnostic: (diagnostic) => { + diagnostics.push(diagnostic); + }, + onReady: async () => { + throw new Error("must not become ready"); + }, + }).catch((error: unknown) => String(error)); + expect(failure).toContain("environment_user"); + expect(failure).not.toContain("synthetic-private-detail"); + expect(diagnostics).toEqual([ + { + exitCode: 2, + nativeCode: "environment_user", + processRefusals: [ + { service: "db", field: "user" }, + { service: "web", field: "healthcheck" }, + ], + }, + ]); +}); + +test("a malformed process refusal list is dropped whole", async () => { + for (const process_refusals of [ + { version: 2, refusals: [{ service: "db", field: "user" }] }, + { version: 1, refusals: [{ service: "db", field: "argv" }] }, + { version: 1, refusals: [{ service: "../db", field: "user" }] }, + { version: 1, refusals: [] }, + { version: 1, refusals: [{ service: "db", field: "user", value: "x" }] }, + ]) { + const reply = JSON.stringify({ + code: "environment_user", + process_refusals, + }); + const opts = await fixture( + `console.error(${JSON.stringify(reply)}); process.exit(2);` + ); + const diagnostics: Record[] = []; + await serveNativeProjectGraph({ + ...opts, + onExitDiagnostic: (diagnostic) => { + diagnostics.push(diagnostic); + }, + onReady: async () => {}, + }).catch(() => undefined); + expect(diagnostics[0]?.processRefusals).toBeUndefined(); + expect(diagnostics[0]?.nativeCode).toBe("environment_user"); + } +}); diff --git a/tests/native-project-start.test.ts b/tests/native-project-start.test.ts index 7ddc106dc..43d64f11a 100644 --- a/tests/native-project-start.test.ts +++ b/tests/native-project-start.test.ts @@ -4461,3 +4461,127 @@ test("the confirmed-stop line reflects whether data was retained", () => { nativeStoppedNotice({ receipt: { phase: "stopped-data-retained" } }) ).toContain("Named volumes and the stopped run mapping are kept"); }); + +test("HACK-1939: a wrapped service whose Compose user cannot be wrapped refuses before the VM starts", async () => { + const { opts, events } = await fixture(); + const prepare = opts.dependencies.prepare!; + opts.dependencies.prepare = async (request) => { + const input = await prepare(request); + const compose = JSON.parse(input.normalizedComposeJson); + compose.services.web.user = "postgres"; + return { ...input, normalizedComposeJson: JSON.stringify(compose) }; + }; + const error = await startNativeProject(opts).catch( + (caught: unknown) => caught + ); + expect(error).toBeInstanceOf(HackCliError); + expect(error).toHaveProperty("code", "E_NATIVE_PROJECT_UNSUPPORTED"); + expect((error as Error).message).toBe( + "Native up cannot wrap these services' processes; nothing was started. Refused: service web user (use a numeric UID or UID:GID)." + ); + expect(events).not.toContain("runtime up"); + expect(events.at(-1)).toBe("cleanup"); +}); + +test("HACK-1939: runtime process refusals are named in the nothing-reserved report", async () => { + const { opts } = await fixture(); + const invoke = opts.dependencies.invoke!; + opts.dependencies.serve = async (request) => { + request.onExitDiagnostic?.({ + exitCode: 2, + nativeCode: "environment_entrypoint", + processRefusals: [ + { service: "web", field: "entrypoint" }, + { service: "worker", field: "user" }, + ], + }); + throw new Error( + "Native graph startup was interrupted or failed (environment_entrypoint); inspect owned state before retrying." + ); + }; + opts.dependencies.invoke = async (request) => { + if (request.args[1] === "inspect") { + throw new NativeRuntimeRequestError({ + message: "Native graph run is not reserved.", + nativeCode: NATIVE_GRAPH_UNRESERVED, + }); + } + return await invoke(request); + }; + const error = await startNativeProject(opts).catch( + (caught: unknown) => caught + ); + expect((error as Error).message).toBe( + "Native graph startup failed (environment_entrypoint) before any runtime state was reserved; nothing was created and no cleanup is needed. Native cannot wrap these services' processes: service web entrypoint or command (the process must name an executable); service worker user (use a numeric UID or UID:GID)." + ); +}); + +/** A fixture whose inactive-by-default `optional` service cannot be wrapped. */ +async function optionalProfileFixture() { + const fixtureResult = await fixture(); + const prepare = fixtureResult.opts.dependencies.prepare!; + fixtureResult.opts.dependencies.prepare = async (request) => { + const input = await prepare(request); + const compose = JSON.parse(input.normalizedComposeJson); + compose.services.optional = { + image: `sha256:${"e".repeat(64)}`, + user: "postgres", + profiles: ["extra"], + }; + return { + ...input, + normalizedComposeJson: JSON.stringify(compose), + managedEnvironment: { + ...input.managedEnvironment, + optional: { PASSWORD: "synthetic-secret" }, + }, + serviceNames: [...input.serviceNames, "optional"], + }; + }; + return fixtureResult; +} + +test("an inactive-profile service a wrapper cannot run does not block up; an active one does", async () => { + const inactive = await optionalProfileFixture(); + expect(await startNativeProject(inactive.opts)).toBe(0); + const active = await optionalProfileFixture(); + const error = await startNativeProject({ + ...active.opts, + profiles: ["extra"], + }).catch((caught: unknown) => caught); + expect((error as Error).message).toBe( + "Native up cannot wrap these services' processes; nothing was started. Refused: service optional user (use a numeric UID or UID:GID)." + ); + expect(active.events).not.toContain("runtime up"); +}); + +test("restart preflight ignores inactive-profile services a wrapper cannot run", async () => { + const { opts } = await optionalProfileFixture(); + const preflight = (profiles: string[]) => + preflightNativeRestart({ + runtime: opts.runtime, + scope: opts.scope, + composeFile: opts.composeFile, + run: { + run: "1".repeat(32), + owner: "c".repeat(32), + namespace: "b".repeat(64), + planId: "a".repeat(64), + effectiveEnvName: null, + profiles, + aws: null, + }, + dependencies: { + prepare: opts.dependencies.prepare, + review: opts.dependencies.review, + dependencies: async () => [], + invoke: restartPreflightInvoke, + }, + }); + const refusal = await preflight(["extra"]).catch((caught: unknown) => caught); + expect((refusal as Error).message).toContain( + "Refused: service optional user (use a numeric UID or UID:GID)." + ); + // The same preflight without the profile completes. + await preflight([]); +});